grada-run 0.0.2 → 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,460 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { ECSClient, ListTasksCommand, DescribeTasksCommand } from '@aws-sdk/client-ecs';
|
|
3
|
+
import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
import { intro, outro, spinner } from '@clack/prompts';
|
|
6
|
+
import { trackEvent, flushTelemetry, trackSuccess, trackFailure } from '../core/telemetry.js';
|
|
7
|
+
import { failCommand, failProjectNotInitialized } from '../utils/command.js';
|
|
8
|
+
import { normalizeOptions } from '../utils/args.js';
|
|
9
|
+
import { handleAuthErrorBranch, resolveClient } from '../utils/aws.js';
|
|
10
|
+
import { resolveRegion, resolveProjectName, resolveCluster, resolveLogGroup, resolveCwd, readTerraformComputeTarget } from '../utils/resolvers.js';
|
|
11
|
+
|
|
12
|
+
export const LOG_FETCH_LIMIT = 50;
|
|
13
|
+
|
|
14
|
+
export function extractStoppedReason(task) {
|
|
15
|
+
return task?.stoppedReason || 'Unknown';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getFailingContainer(task) {
|
|
19
|
+
const containers = task?.containers || [];
|
|
20
|
+
return (
|
|
21
|
+
containers.find((c) => c.exitCode !== undefined && c.exitCode !== 0) ||
|
|
22
|
+
containers.find((c) => c.reason && c.reason !== 'Essential container in task exited') ||
|
|
23
|
+
containers[0] ||
|
|
24
|
+
{}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function taskActivityTime(task) {
|
|
29
|
+
// Prefer container exit time over ENI deprovisioning time when present.
|
|
30
|
+
const stamp = task?.executionStoppedAt ?? task?.stoppingAt ?? task?.stoppedAt
|
|
31
|
+
?? task?.startedAt ?? task?.createdAt;
|
|
32
|
+
if (stamp === undefined || stamp === null) return 0;
|
|
33
|
+
return new Date(stamp).getTime() || 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parses `.../task-definition/<family>:<revision>` (ECS-managed format).
|
|
37
|
+
// Returns null when the ARN is missing or doesn't match, so callers fail
|
|
38
|
+
// closed into the timestamp rule below.
|
|
39
|
+
export function parseTaskDefinitionRef(taskDefinitionArn) {
|
|
40
|
+
if (typeof taskDefinitionArn !== 'string') return null;
|
|
41
|
+
const match = taskDefinitionArn.match(/task-definition\/([^/:]+):(\d+)\s*$/);
|
|
42
|
+
if (!match) return null;
|
|
43
|
+
return { family: match[1], revision: parseInt(match[2], 10) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function taskStartTime(task) {
|
|
47
|
+
// `startedAt` is set when the task actually starts running. `createdAt`
|
|
48
|
+
// predates provisioning, so it must never stand in for uptime — a task
|
|
49
|
+
// without `startedAt` has not run yet and fails closed to 0.
|
|
50
|
+
const stamp = task?.startedAt;
|
|
51
|
+
if (stamp === undefined || stamp === null) return 0;
|
|
52
|
+
return new Date(stamp).getTime() || 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatAge(ms) {
|
|
56
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
57
|
+
if (seconds < 60) return `${seconds}s`;
|
|
58
|
+
const minutes = Math.floor(seconds / 60);
|
|
59
|
+
if (minutes < 60) return `${minutes}m`;
|
|
60
|
+
const hours = Math.floor(minutes / 60);
|
|
61
|
+
if (hours < 48) return `${hours}h`;
|
|
62
|
+
return `${Math.floor(hours / 24)}d`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function pickMostRecentTask(tasks) {
|
|
66
|
+
if (!tasks || tasks.length === 0) return null;
|
|
67
|
+
return [...tasks].sort((a, b) => taskActivityTime(b) - taskActivityTime(a))[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractTaskIdFromArn(taskArn) {
|
|
71
|
+
if (typeof taskArn !== 'string' || !taskArn.includes('/')) return '';
|
|
72
|
+
return taskArn.split('/').pop() || '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLogGroupNotFoundError(error) {
|
|
76
|
+
return !!error && error.name === 'ResourceNotFoundException';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Lambda diagnosis without new dependencies: function configuration comes
|
|
80
|
+
// from the AWS CLI (`get-function`), recent output from the CloudWatch Logs
|
|
81
|
+
// SDK client the ECS path already uses.
|
|
82
|
+
export async function runLambdaDiagnose(input = {}) {
|
|
83
|
+
const options = normalizeOptions(input);
|
|
84
|
+
const projectName = options.projectName;
|
|
85
|
+
const region = options.region;
|
|
86
|
+
const logGroup = options.logGroup;
|
|
87
|
+
const functionName = typeof options.functionName === 'string' && options.functionName.trim()
|
|
88
|
+
? options.functionName.trim()
|
|
89
|
+
: `${projectName}-fn`;
|
|
90
|
+
const runSync = options.spawnSyncImpl || spawnSync;
|
|
91
|
+
|
|
92
|
+
intro(color.bgCyan(color.black(' grada diagnose 🩺 ')));
|
|
93
|
+
|
|
94
|
+
const s = spinner();
|
|
95
|
+
s.start('Looking up the Lambda function configuration...');
|
|
96
|
+
|
|
97
|
+
const logsClient = resolveClient(options.logsClient, CloudWatchLogsClient, { region });
|
|
98
|
+
|
|
99
|
+
const res = runSync('aws', [
|
|
100
|
+
'lambda', 'get-function',
|
|
101
|
+
'--function-name', functionName,
|
|
102
|
+
'--region', region,
|
|
103
|
+
'--output', 'json',
|
|
104
|
+
], { encoding: 'utf8' });
|
|
105
|
+
|
|
106
|
+
if (res?.error?.code === 'ENOENT') {
|
|
107
|
+
s.stop(color.red('❌ Diagnose failed.'));
|
|
108
|
+
await trackFailure('diagnose_run', { error_code: 'AWS_CLI_MISSING', log_source: 'none' });
|
|
109
|
+
return failCommand({
|
|
110
|
+
message: '✖ The AWS CLI is required for Lambda diagnosis.',
|
|
111
|
+
hint: 'Install it from https://aws.amazon.com/cli/, then try again.',
|
|
112
|
+
reason: 'aws-cli-missing',
|
|
113
|
+
resultExtra: { functionName, region },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (res?.status !== 0) {
|
|
118
|
+
const detail = String(res?.stderr || res?.stdout || '').trim();
|
|
119
|
+
if (/ResourceNotFound/i.test(detail)) {
|
|
120
|
+
s.stop('Function not found.');
|
|
121
|
+
console.log(`\n The Lambda function ${color.cyan(functionName)} does not exist.`);
|
|
122
|
+
console.log(` Run ${color.green('npx grada-run apply')} to provision your infrastructure.\n`);
|
|
123
|
+
await trackFailure('diagnose_run', { error_code: 'NOT_DEPLOYED', log_source: 'none' });
|
|
124
|
+
return { healthy: false, reason: 'not-deployed', functionName, logs: [] };
|
|
125
|
+
}
|
|
126
|
+
s.stop(color.red('❌ Diagnose failed.'));
|
|
127
|
+
await trackFailure('diagnose_run', { error_code: 'GET_FUNCTION_FAILED', log_source: 'none' });
|
|
128
|
+
return failCommand({
|
|
129
|
+
message: `✖ ${detail || 'aws lambda get-function failed.'}`,
|
|
130
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
131
|
+
resultExtra: { functionName, region },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let configuration;
|
|
136
|
+
try {
|
|
137
|
+
configuration = JSON.parse(res.stdout || '{}').Configuration || {};
|
|
138
|
+
} catch {
|
|
139
|
+
s.stop(color.red('❌ Diagnose failed.'));
|
|
140
|
+
await trackFailure('diagnose_run', { error_code: 'BAD_RESPONSE', log_source: 'none' });
|
|
141
|
+
return failCommand({
|
|
142
|
+
message: '✖ Could not parse the Lambda get-function response.',
|
|
143
|
+
hint: 'Check your AWS CLI version, then try again.',
|
|
144
|
+
resultExtra: { functionName, region },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const state = configuration.State || 'UNKNOWN';
|
|
149
|
+
const lastUpdateStatus = configuration.LastUpdateStatus || 'UNKNOWN';
|
|
150
|
+
const configUnhealthy = state !== 'Active' || lastUpdateStatus === 'Failed';
|
|
151
|
+
|
|
152
|
+
s.message(`Fetching last ${LOG_FETCH_LIMIT} log lines for "${functionName}"...`);
|
|
153
|
+
let logs = [];
|
|
154
|
+
let logGroupMissing = false;
|
|
155
|
+
try {
|
|
156
|
+
const logsResp = await logsClient.send(new FilterLogEventsCommand({
|
|
157
|
+
logGroupName: logGroup,
|
|
158
|
+
startTime: Date.now() - 60 * 60 * 1000,
|
|
159
|
+
limit: LOG_FETCH_LIMIT,
|
|
160
|
+
}));
|
|
161
|
+
logs = (logsResp.events || []).slice(-LOG_FETCH_LIMIT).map((e) => e.message);
|
|
162
|
+
} catch (logsError) {
|
|
163
|
+
if (handleAuthErrorBranch(logsError, s, options)) return;
|
|
164
|
+
if (isLogGroupNotFoundError(logsError)) {
|
|
165
|
+
logGroupMissing = true;
|
|
166
|
+
logs = [];
|
|
167
|
+
} else {
|
|
168
|
+
logs = [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const errorLines = logs.filter((line) => /error|exception|failed|fatal|outofmemory|killed/i.test(String(line ?? '')));
|
|
173
|
+
const healthy = !configUnhealthy && errorLines.length === 0;
|
|
174
|
+
|
|
175
|
+
s.stop('Diagnosis complete.\n');
|
|
176
|
+
|
|
177
|
+
if (!healthy && configUnhealthy) {
|
|
178
|
+
console.log(` ${color.red(color.bold('✖ Function state:'))} ${color.red(`${state} / ${lastUpdateStatus}`)}`);
|
|
179
|
+
}
|
|
180
|
+
console.log(` ${color.dim('Function:')} ${color.cyan(functionName)}`);
|
|
181
|
+
console.log(` ${color.dim('State:')} ${state === 'Active' ? color.green(state) : color.yellow(state)} ${color.dim(`/ ${lastUpdateStatus}`)}`);
|
|
182
|
+
if (configuration.LastModified) console.log(` ${color.dim('Modified:')} ${configuration.LastModified}`);
|
|
183
|
+
|
|
184
|
+
if (logs.length > 0) {
|
|
185
|
+
console.log(`\n ${color.bold(`Last ${logs.length} log lines (${color.cyan(logGroup)}):`)}`);
|
|
186
|
+
for (const line of logs) {
|
|
187
|
+
console.log(` ${color.dim('│')} ${highlightErrorLine(line)}`);
|
|
188
|
+
}
|
|
189
|
+
} else if (logGroupMissing) {
|
|
190
|
+
console.log(color.yellow(`\n⚠ Log group not found (expected ${logGroup}).`));
|
|
191
|
+
console.log(color.dim(`List matching groups with: aws logs describe-log-groups --log-group-name-prefix "/aws/lambda/" --region ${region}`));
|
|
192
|
+
} else {
|
|
193
|
+
console.log(color.dim(`\n No recent log events found in ${logGroup}.`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (healthy) {
|
|
197
|
+
outro(color.green('Diagnose complete. Nothing to fix!'));
|
|
198
|
+
await trackSuccess('diagnose_run', { healthy: true, log_source: logs.length > 0 ? 'group-fallback' : 'none' });
|
|
199
|
+
return { healthy: true, functionName, state, lastUpdateStatus, logs: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
outro(color.green('Diagnose complete. Fix the error above, then redeploy. 🚀'));
|
|
203
|
+
await trackSuccess('diagnose_run', { healthy: false, log_source: logs.length > 0 ? 'group-fallback' : 'none' });
|
|
204
|
+
return { healthy: false, functionName, state, lastUpdateStatus, logs };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function runDiagnose(input = {}) {
|
|
208
|
+
const options = normalizeOptions(input);
|
|
209
|
+
let cwd;
|
|
210
|
+
let projectName;
|
|
211
|
+
let region;
|
|
212
|
+
let cluster;
|
|
213
|
+
let logGroup;
|
|
214
|
+
try {
|
|
215
|
+
cwd = resolveCwd(options);
|
|
216
|
+
projectName = resolveProjectName(options, cwd);
|
|
217
|
+
region = resolveRegion(options, cwd);
|
|
218
|
+
cluster = resolveCluster(options, cwd);
|
|
219
|
+
logGroup = resolveLogGroup(options, cwd);
|
|
220
|
+
} catch {
|
|
221
|
+
return failProjectNotInitialized({ event: 'diagnose_run' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (readTerraformComputeTarget(cwd) === 'lambda') {
|
|
225
|
+
return runLambdaDiagnose({ ...options, cwd, projectName, region, logGroup });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
intro(color.bgCyan(color.black(' grada diagnose 🩺 ')));
|
|
229
|
+
|
|
230
|
+
const s = spinner();
|
|
231
|
+
s.start('Looking up recent stopped ECS tasks...');
|
|
232
|
+
|
|
233
|
+
const ecsClient = resolveClient(options.ecsClient, ECSClient, { region });
|
|
234
|
+
const logsClient = resolveClient(options.logsClient, CloudWatchLogsClient, { region });
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const listResp = await ecsClient.send(
|
|
238
|
+
new ListTasksCommand({
|
|
239
|
+
cluster,
|
|
240
|
+
desiredStatus: 'STOPPED',
|
|
241
|
+
sort: 'DESC',
|
|
242
|
+
maxResults: 100
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const taskArns = listResp.taskArns || [];
|
|
247
|
+
|
|
248
|
+
if (taskArns.length === 0) {
|
|
249
|
+
s.stop('No stopped tasks found.');
|
|
250
|
+
console.log(color.green('✅ No stopped tasks — your service looks healthy.'));
|
|
251
|
+
outro(color.green('Diagnose complete. Nothing to fix!'));
|
|
252
|
+
await trackSuccess('diagnose_run', { healthy: true, log_source: 'none' });
|
|
253
|
+
return { healthy: true, stoppedReason: null, logs: [] };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ListTasks order is arbitrary — describe the full batch (up to the
|
|
257
|
+
// DescribeTasks 100-ARN limit) and pick the latest stopped task below.
|
|
258
|
+
s.message(`Describing ${Math.min(taskArns.length, 100)} stopped task(s)...`);
|
|
259
|
+
const descResp = await ecsClient.send(
|
|
260
|
+
new DescribeTasksCommand({
|
|
261
|
+
cluster,
|
|
262
|
+
tasks: taskArns.slice(0, 100)
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const tasks = descResp.tasks || [];
|
|
267
|
+
if (tasks.length === 0) {
|
|
268
|
+
s.stop('No task details returned.');
|
|
269
|
+
console.log(color.yellow('⚠ Stopped task ARNs were listed, but ECS returned no task details.'));
|
|
270
|
+
outro(color.yellow('Diagnose finished with no details.'));
|
|
271
|
+
await trackFailure('diagnose_run', { error_code: 'NO_TASK_DETAILS', log_source: 'none' });
|
|
272
|
+
return { healthy: false, stoppedReason: null, logs: [] };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const failedTask = pickMostRecentTask(tasks);
|
|
276
|
+
const stoppedReason = extractStoppedReason(failedTask);
|
|
277
|
+
const failingContainer = getFailingContainer(failedTask);
|
|
278
|
+
const containerName = failingContainer.name || 'unknown';
|
|
279
|
+
const exitCode = failingContainer.exitCode;
|
|
280
|
+
const containerReason = failingContainer.reason;
|
|
281
|
+
|
|
282
|
+
// A STOPPED task from an old crash lingers in ECS for ~1h. If a
|
|
283
|
+
// RUNNING task started after the latest stop, the service has already
|
|
284
|
+
// recovered — report that instead of the stale crash.
|
|
285
|
+
s.message('Checking for running tasks...');
|
|
286
|
+
const stoppedActivity = taskActivityTime(failedTask);
|
|
287
|
+
const runningListResp = await ecsClient.send(
|
|
288
|
+
new ListTasksCommand({
|
|
289
|
+
cluster,
|
|
290
|
+
desiredStatus: 'RUNNING',
|
|
291
|
+
sort: 'DESC',
|
|
292
|
+
maxResults: 100
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
const runningArns = runningListResp.taskArns || [];
|
|
296
|
+
let newestRunning = null;
|
|
297
|
+
if (runningArns.length > 0) {
|
|
298
|
+
const runningDescResp = await ecsClient.send(
|
|
299
|
+
new DescribeTasksCommand({
|
|
300
|
+
cluster,
|
|
301
|
+
tasks: runningArns.slice(0, 100)
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
const runningTasks = runningDescResp.tasks || [];
|
|
305
|
+
for (const task of runningTasks) {
|
|
306
|
+
// ListTasks({ desiredStatus: 'RUNNING' }) also returns tasks
|
|
307
|
+
// still PROVISIONING/PENDING toward that goal. Only a task
|
|
308
|
+
// whose actual lastStatus is RUNNING — and which isn't
|
|
309
|
+
// UNHEALTHY — proves the service recovered.
|
|
310
|
+
if (task?.lastStatus !== 'RUNNING' || task?.healthStatus === 'UNHEALTHY') continue;
|
|
311
|
+
if (!newestRunning || taskStartTime(task) > taskStartTime(newestRunning)) {
|
|
312
|
+
newestRunning = task;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const latestRunningStart = newestRunning ? taskStartTime(newestRunning) : 0;
|
|
317
|
+
|
|
318
|
+
// A crash on a superseded revision is stale by construction, even when
|
|
319
|
+
// its stop timestamp postdates the running task (normal drain order).
|
|
320
|
+
// Anything else falls through to the timestamp comparison.
|
|
321
|
+
const stoppedRef = parseTaskDefinitionRef(failedTask.taskDefinitionArn);
|
|
322
|
+
const runningRef = newestRunning ? parseTaskDefinitionRef(newestRunning.taskDefinitionArn) : null;
|
|
323
|
+
const supersededRevision = Boolean(
|
|
324
|
+
stoppedRef && runningRef
|
|
325
|
+
&& stoppedRef.family === runningRef.family
|
|
326
|
+
&& stoppedRef.revision < runningRef.revision
|
|
327
|
+
);
|
|
328
|
+
const recoveredByTimestamp = Boolean(newestRunning) && latestRunningStart > stoppedActivity;
|
|
329
|
+
|
|
330
|
+
if (supersededRevision || recoveredByTimestamp) {
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const sinceClock = new Date(latestRunningStart).toISOString().slice(11, 19);
|
|
333
|
+
const exitSuffix = exitCode !== undefined ? ` (exit code ${exitCode})` : '';
|
|
334
|
+
const crashContext = supersededRevision
|
|
335
|
+
? `ℹ Previous crash (revision ${stoppedRef.revision}, ${formatAge(now - stoppedActivity)} ago): ${stoppedReason} — ${containerName}${exitSuffix}. Revision ${runningRef.revision} is now running. No action needed.`
|
|
336
|
+
: `ℹ Previous crash (${formatAge(now - stoppedActivity)} ago): ${stoppedReason} — ${containerName}${exitSuffix}. No action needed.`;
|
|
337
|
+
s.stop('Service recovered.');
|
|
338
|
+
console.log(color.green(`\n✔ Service recovered — a healthy task has been running since ${sinceClock} (${formatAge(now - latestRunningStart)}).`));
|
|
339
|
+
console.log(color.dim(crashContext));
|
|
340
|
+
outro(color.green('Diagnose complete. The service recovered after that crash. ✅'));
|
|
341
|
+
await trackSuccess('diagnose_run', { healthy: true, recovered: true, log_source: 'none' });
|
|
342
|
+
return {
|
|
343
|
+
healthy: true,
|
|
344
|
+
recovered: true,
|
|
345
|
+
stoppedReason,
|
|
346
|
+
taskArn: failedTask.taskArn,
|
|
347
|
+
containerName,
|
|
348
|
+
logs: [],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
s.message(`Fetching last ${LOG_FETCH_LIMIT} log lines for "${containerName}"...`);
|
|
353
|
+
|
|
354
|
+
// CloudWatch stream names follow `ecs/<containerName>/<taskId>`.
|
|
355
|
+
const taskId = extractTaskIdFromArn(failedTask.taskArn);
|
|
356
|
+
const streamPrefix = containerName && containerName !== 'unknown' && taskId
|
|
357
|
+
? `ecs/${containerName}/${taskId}`
|
|
358
|
+
: '';
|
|
359
|
+
|
|
360
|
+
const fetchLogMessages = async (input) => {
|
|
361
|
+
const logsResp = await logsClient.send(new FilterLogEventsCommand(input));
|
|
362
|
+
return (logsResp.events || []).slice(-LOG_FETCH_LIMIT).map((e) => e.message);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
let logs = [];
|
|
366
|
+
let logGroupMissing = false;
|
|
367
|
+
let logSource = 'none';
|
|
368
|
+
if (streamPrefix) {
|
|
369
|
+
try {
|
|
370
|
+
logs = await fetchLogMessages({
|
|
371
|
+
logGroupName: logGroup,
|
|
372
|
+
logStreamNamePrefix: streamPrefix,
|
|
373
|
+
limit: LOG_FETCH_LIMIT,
|
|
374
|
+
});
|
|
375
|
+
if (logs.length > 0) logSource = 'task-stream';
|
|
376
|
+
} catch (scopedError) {
|
|
377
|
+
if (isLogGroupNotFoundError(scopedError)) {
|
|
378
|
+
logGroupMissing = true;
|
|
379
|
+
logs = [];
|
|
380
|
+
}
|
|
381
|
+
// Any other scoped-query error falls through to the group fallback.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!logGroupMissing && logs.length === 0) {
|
|
385
|
+
try {
|
|
386
|
+
logs = await fetchLogMessages({
|
|
387
|
+
logGroupName: logGroup,
|
|
388
|
+
startTime: Date.now() - 60 * 60 * 1000,
|
|
389
|
+
limit: LOG_FETCH_LIMIT,
|
|
390
|
+
});
|
|
391
|
+
if (logs.length > 0) logSource = 'group-fallback';
|
|
392
|
+
} catch (fallbackError) {
|
|
393
|
+
if (isLogGroupNotFoundError(fallbackError)) logGroupMissing = true;
|
|
394
|
+
logs = [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
s.stop('Diagnosis complete.\n');
|
|
399
|
+
|
|
400
|
+
console.log(` ${color.red(color.bold('✖ Stopped reason:'))} ${color.red(stoppedReason)}`);
|
|
401
|
+
console.log(` ${color.dim('Cluster:')} ${color.cyan(cluster)}`);
|
|
402
|
+
console.log(` ${color.dim('Task:')} ${color.dim(failedTask.taskArn || taskArns[0])} ${color.dim(`(stopped ${formatAge(Date.now() - stoppedActivity)} ago)`)}`);
|
|
403
|
+
console.log(` ${color.dim('Container:')} ${color.yellow(containerName)}${exitCode !== undefined ? color.dim(` (exit code ${exitCode})`) : ''}`);
|
|
404
|
+
if (containerReason && containerReason !== stoppedReason) {
|
|
405
|
+
console.log(` ${color.dim('Container reason:')} ${color.yellow(containerReason)}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (logs.length > 0) {
|
|
409
|
+
console.log(`\n ${color.bold(`Last ${logs.length} log lines (${color.cyan(logGroup)}):`)}`);
|
|
410
|
+
for (const line of logs) {
|
|
411
|
+
console.log(` ${color.dim('│')} ${highlightErrorLine(line)}`);
|
|
412
|
+
}
|
|
413
|
+
} else if (logGroupMissing) {
|
|
414
|
+
console.log(color.yellow(`\n⚠ Log group not found (expected ${logGroup}).`));
|
|
415
|
+
console.log(color.dim(`List matching groups with: aws logs describe-log-groups --log-group-name-prefix "/ecs/" --region ${region}`));
|
|
416
|
+
} else {
|
|
417
|
+
console.log(color.dim(`\n No recent log events found in ${logGroup}.`));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
outro(color.green('Diagnose complete. Fix the error above, then redeploy. 🚀'));
|
|
421
|
+
|
|
422
|
+
await trackSuccess('diagnose_run', { healthy: false, log_source: logSource });
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
healthy: false,
|
|
426
|
+
cluster,
|
|
427
|
+
taskArn: failedTask.taskArn || taskArns[0],
|
|
428
|
+
stoppedReason,
|
|
429
|
+
containerName,
|
|
430
|
+
exitCode,
|
|
431
|
+
containerReason,
|
|
432
|
+
logs
|
|
433
|
+
};
|
|
434
|
+
} catch (error) {
|
|
435
|
+
await trackFailure('diagnose_run', {
|
|
436
|
+
error_code: error.name || 'UNKNOWN',
|
|
437
|
+
error_message: error.message,
|
|
438
|
+
stack_trace: error.name === 'TypeError' ? error.stack : undefined,
|
|
439
|
+
log_source: 'none'
|
|
440
|
+
});
|
|
441
|
+
if (handleAuthErrorBranch(error, s, options)) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
s.stop(color.red('❌ Diagnose failed.'));
|
|
445
|
+
return failCommand({
|
|
446
|
+
message: `✖ ${error.message || error}`,
|
|
447
|
+
hint: 'Check your AWS credentials and region, then try again.',
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function highlightErrorLine(line) {
|
|
453
|
+
if (/error|exception|failed|fatal|outofmemory|killed/i.test(line)) {
|
|
454
|
+
return color.red(line);
|
|
455
|
+
}
|
|
456
|
+
if (/warn/i.test(line)) {
|
|
457
|
+
return color.yellow(line);
|
|
458
|
+
}
|
|
459
|
+
return color.gray(line);
|
|
460
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { intro, outro, spinner } from '@clack/prompts';
|
|
2
|
+
import color from 'picocolors';
|
|
3
|
+
import { checkDependency } from '../utils/system.js';
|
|
4
|
+
import { trackEvent, flushTelemetry, detectCiProvider } from '../core/telemetry.js';
|
|
5
|
+
|
|
6
|
+
// Stable snake_case identifiers for the binary presence checks below.
|
|
7
|
+
// Only these static IDs ever reach telemetry — never paths or error text.
|
|
8
|
+
export const DOCTOR_CHECKS = [
|
|
9
|
+
{ id: 'terraform', label: 'Terraform', binary: 'terraform' },
|
|
10
|
+
{ id: 'aws_cli', label: 'AWS CLI', binary: 'aws' },
|
|
11
|
+
{ id: 'docker', label: 'Docker', binary: 'docker' },
|
|
12
|
+
{ id: 'git', label: 'Git', binary: 'git' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const INSTALL_HINTS = {
|
|
16
|
+
terraform: {
|
|
17
|
+
default: 'Install via Homebrew: brew install terraform',
|
|
18
|
+
linux: 'Install via the HashiCorp apt repo: https://developer.hashicorp.com/terraform/install',
|
|
19
|
+
win32: 'Install via winget: winget install Hashicorp.Terraform',
|
|
20
|
+
},
|
|
21
|
+
aws_cli: {
|
|
22
|
+
default: 'Install via Homebrew: brew install awscli',
|
|
23
|
+
linux: 'Install AWS CLI v2 for Linux: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html',
|
|
24
|
+
win32: 'Install via winget: winget install Amazon.AWSCLI',
|
|
25
|
+
},
|
|
26
|
+
docker: {
|
|
27
|
+
default: 'Install via Homebrew: brew install docker',
|
|
28
|
+
linux: 'Install Docker Engine for your distro: https://docs.docker.com/engine/install/',
|
|
29
|
+
win32: 'Install via winget: winget install Docker.DockerDesktop',
|
|
30
|
+
},
|
|
31
|
+
git: {
|
|
32
|
+
default: 'Install via Homebrew: brew install git',
|
|
33
|
+
linux: 'Install via your package manager: sudo apt-get install -y git (Debian/Ubuntu) or sudo dnf install -y git (RHEL/Fedora)',
|
|
34
|
+
win32: 'Install via winget: winget install Git.Git',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function installHint(checkId, platform = process.platform) {
|
|
39
|
+
const hints = INSTALL_HINTS[checkId];
|
|
40
|
+
if (!hints) return '';
|
|
41
|
+
if (platform === 'linux' && hints.linux) return hints.linux;
|
|
42
|
+
if (platform === 'win32' && hints.win32) return hints.win32;
|
|
43
|
+
return hints.default;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Deduplicates concurrent in-flight binary checks by binary name so parallel
|
|
47
|
+
// runDoctor() invocations share child processes instead of multiplying them.
|
|
48
|
+
// Entries are removed on settle, so sequential calls always run fresh checks.
|
|
49
|
+
const inFlightChecks = new Map();
|
|
50
|
+
|
|
51
|
+
function dedupedCheckDependency(binary) {
|
|
52
|
+
if (!inFlightChecks.has(binary)) {
|
|
53
|
+
inFlightChecks.set(
|
|
54
|
+
binary,
|
|
55
|
+
checkDependency(binary).finally(() => {
|
|
56
|
+
inFlightChecks.delete(binary);
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return inFlightChecks.get(binary);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runDoctor() {
|
|
64
|
+
intro(color.bgCyan(color.black(' grada ☁️ ')));
|
|
65
|
+
|
|
66
|
+
const s = spinner();
|
|
67
|
+
s.start('Running pre-flight checks...');
|
|
68
|
+
|
|
69
|
+
const results = await Promise.all(
|
|
70
|
+
DOCTOR_CHECKS.map(async (check) => ({
|
|
71
|
+
...check,
|
|
72
|
+
ok: await dedupedCheckDependency(check.binary),
|
|
73
|
+
}))
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
s.stop('Pre-flight checks complete.\n');
|
|
77
|
+
|
|
78
|
+
const printStatus = (ok, label, fix) => {
|
|
79
|
+
const icon = ok ? color.green('✅') : color.red('❌');
|
|
80
|
+
const message = ok ? `${label} (✓)` : `${label} (✗)`;
|
|
81
|
+
console.log(` ${icon} ${message}`);
|
|
82
|
+
if (!ok) {
|
|
83
|
+
console.log(` ┌─ Try: ${color.dim(fix)}`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const check of results) {
|
|
88
|
+
printStatus(check.ok, check.label, installHint(check.id));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const passedChecks = results.filter((check) => check.ok).map((check) => check.id);
|
|
92
|
+
const failedChecks = results.filter((check) => !check.ok).map((check) => check.id);
|
|
93
|
+
|
|
94
|
+
if (failedChecks.length === 0) {
|
|
95
|
+
outro(color.green('Your system is 100% ready to provision and deploy! 🚀'));
|
|
96
|
+
} else {
|
|
97
|
+
outro(color.yellow('Please install the missing dependencies before running the provisioning tool.'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
trackEvent('doctor_run', {
|
|
101
|
+
success: failedChecks.length === 0,
|
|
102
|
+
...(failedChecks.length > 0 ? { error_code: 'DOCTOR_CHECKS_FAILED' } : {}),
|
|
103
|
+
passed_checks: passedChecks,
|
|
104
|
+
failed_checks: failedChecks,
|
|
105
|
+
total_failed: failedChecks.length,
|
|
106
|
+
ci_provider: detectCiProvider(),
|
|
107
|
+
});
|
|
108
|
+
await flushTelemetry();
|
|
109
|
+
}
|