@grada-run/grada 0.0.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- package/index.js +0 -2
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// NOTE: helper imports must stay above src imports: vi.mock factories run
|
|
3
|
+
// during module evaluation and need the factories initialized.
|
|
4
|
+
import { clackPromptsMockFactory } from './helpers/clack.js';
|
|
5
|
+
import { telemetryMockFactory } from './helpers/telemetry.js';
|
|
6
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { runDiagnose, runLambdaDiagnose, pickMostRecentTask, extractTaskIdFromArn, formatAge, parseTaskDefinitionRef } from '../src/commands/diagnose.js';
|
|
11
|
+
import { trackEvent } from '../src/core/telemetry.js';
|
|
12
|
+
import { outro } from '@clack/prompts';
|
|
13
|
+
|
|
14
|
+
// Mock the AWS SDK clients (no real credentials needed)
|
|
15
|
+
const {
|
|
16
|
+
mockEcsSend,
|
|
17
|
+
mockLogsSend,
|
|
18
|
+
MockECSClient,
|
|
19
|
+
MockListTasksCommand,
|
|
20
|
+
MockDescribeTasksCommand,
|
|
21
|
+
MockLogsClient,
|
|
22
|
+
MockFilterLogEventsCommand
|
|
23
|
+
} = vi.hoisted(() => {
|
|
24
|
+
const ecsSend = vi.fn();
|
|
25
|
+
const logsSend = vi.fn();
|
|
26
|
+
return {
|
|
27
|
+
mockEcsSend: ecsSend,
|
|
28
|
+
mockLogsSend: logsSend,
|
|
29
|
+
MockECSClient: vi.fn(function () {
|
|
30
|
+
this.send = ecsSend;
|
|
31
|
+
}),
|
|
32
|
+
MockListTasksCommand: vi.fn(function (input) {
|
|
33
|
+
Object.assign(this, input);
|
|
34
|
+
}),
|
|
35
|
+
MockDescribeTasksCommand: vi.fn(function (input) {
|
|
36
|
+
Object.assign(this, input);
|
|
37
|
+
}),
|
|
38
|
+
MockLogsClient: vi.fn(function () {
|
|
39
|
+
this.send = logsSend;
|
|
40
|
+
}),
|
|
41
|
+
MockFilterLogEventsCommand: vi.fn(function (input) {
|
|
42
|
+
Object.assign(this, input);
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
vi.mock('@aws-sdk/client-ecs', () => {
|
|
48
|
+
return {
|
|
49
|
+
ECSClient: MockECSClient,
|
|
50
|
+
DescribeTasksCommand: MockDescribeTasksCommand,
|
|
51
|
+
ListTasksCommand: MockListTasksCommand
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
vi.mock('@aws-sdk/client-cloudwatch-logs', () => {
|
|
56
|
+
return {
|
|
57
|
+
CloudWatchLogsClient: MockLogsClient,
|
|
58
|
+
FilterLogEventsCommand: MockFilterLogEventsCommand
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Silence interactive prompts and telemetry during tests
|
|
63
|
+
vi.mock('@clack/prompts', () => clackPromptsMockFactory());
|
|
64
|
+
|
|
65
|
+
vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
|
|
66
|
+
|
|
67
|
+
describe('Command: diagnose', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
mockEcsSend.mockReset();
|
|
71
|
+
mockLogsSend.mockReset();
|
|
72
|
+
// Default: no running tasks (keeps pre-existing tests on the unresolved path).
|
|
73
|
+
mockEcsSend.mockResolvedValue({ taskArns: [] });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should extract and format the stoppedReason from a failed ECS task', async () => {
|
|
77
|
+
const stoppedReason = 'OutOfMemoryError: Container killed due to memory usage';
|
|
78
|
+
|
|
79
|
+
// 1. ListTasks -> one stopped task; DescribeTasks -> failed task detail
|
|
80
|
+
mockEcsSend
|
|
81
|
+
.mockResolvedValueOnce({
|
|
82
|
+
taskArns: ['arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123']
|
|
83
|
+
})
|
|
84
|
+
.mockResolvedValueOnce({
|
|
85
|
+
tasks: [
|
|
86
|
+
{
|
|
87
|
+
taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123',
|
|
88
|
+
stoppedReason,
|
|
89
|
+
stoppedAt: new Date().toISOString(),
|
|
90
|
+
containers: [
|
|
91
|
+
{ name: 'app', exitCode: 137, reason: 'Essential container in task exited' }
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 2. CloudWatch -> 55 events, only the last 50 should be used
|
|
98
|
+
const events = Array.from({ length: 55 }, (_, i) => ({
|
|
99
|
+
message: i === 54 ? `FATAL ${stoppedReason}` : `log line ${i + 1}`
|
|
100
|
+
}));
|
|
101
|
+
mockLogsSend.mockResolvedValueOnce({ events });
|
|
102
|
+
|
|
103
|
+
// 3. Capture formatted console output
|
|
104
|
+
const output = [];
|
|
105
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
106
|
+
output.push(args.join(' '));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await runDiagnose({
|
|
111
|
+
cluster: 'test-cluster',
|
|
112
|
+
region: 'us-east-2',
|
|
113
|
+
logGroup: '/ecs/test'
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Return value carries the extracted reason
|
|
117
|
+
expect(result.stoppedReason).toBe(stoppedReason);
|
|
118
|
+
// Last-50-lines limit honored
|
|
119
|
+
expect(result.logs).toHaveLength(50);
|
|
120
|
+
// Formatted output highlights the exact error
|
|
121
|
+
expect(output.join('\n')).toContain(stoppedReason);
|
|
122
|
+
// Log fetch asked CloudWatch for the last 50 lines
|
|
123
|
+
expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({ limit: 50 })
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
consoleSpy.mockRestore();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should report healthy when there are no stopped tasks', async () => {
|
|
132
|
+
mockEcsSend.mockResolvedValueOnce({ taskArns: [] });
|
|
133
|
+
|
|
134
|
+
const output = [];
|
|
135
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
136
|
+
output.push(args.join(' '));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2' });
|
|
141
|
+
expect(result.healthy).toBe(true);
|
|
142
|
+
expect(mockLogsSend).not.toHaveBeenCalled();
|
|
143
|
+
expect(output.join('\n')).toMatch(/healthy|No stopped tasks/i);
|
|
144
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
145
|
+
'diagnose_run',
|
|
146
|
+
expect.objectContaining({ success: true, healthy: true, log_source: 'none' })
|
|
147
|
+
);
|
|
148
|
+
} finally {
|
|
149
|
+
consoleSpy.mockRestore();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('pickMostRecentTask', () => {
|
|
154
|
+
const task = (overrides) => ({
|
|
155
|
+
taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/aaa',
|
|
156
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
157
|
+
...overrides,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('selects the latest stoppedAt across unordered tasks', () => {
|
|
161
|
+
const oldest = task({ taskArn: 'arn/old', stoppedAt: '2026-09-26T12:00:00.000Z' });
|
|
162
|
+
const newest = task({ taskArn: 'arn/new', stoppedAt: '2026-09-26T12:19:47.000Z' });
|
|
163
|
+
const middle = task({ taskArn: 'arn/mid', stoppedAt: '2026-09-26T12:10:00.000Z' });
|
|
164
|
+
expect(pickMostRecentTask([oldest, newest, middle]).taskArn).toBe('arn/new');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('falls back to startedAt then createdAt when stoppedAt is missing', () => {
|
|
168
|
+
const noStop = task({ taskArn: 'arn/started', startedAt: '2026-09-26T12:15:00.000Z' });
|
|
169
|
+
const createdOnly = task({ taskArn: 'arn/created', createdAt: '2026-09-26T12:18:00.000Z' });
|
|
170
|
+
const ancient = task({ taskArn: 'arn/old', stoppedAt: '2026-09-26T11:00:00.000Z' });
|
|
171
|
+
// createdAt 12:18 beats startedAt 12:15; both beat stoppedAt 11:00 here
|
|
172
|
+
// only to prove fallbacks participate — stoppedAt still wins when newest.
|
|
173
|
+
expect(pickMostRecentTask([ancient, noStop, createdOnly]).taskArn).toBe('arn/created');
|
|
174
|
+
const freshStop = task({ taskArn: 'arn/fresh', stoppedAt: '2026-09-26T12:20:00.000Z' });
|
|
175
|
+
expect(pickMostRecentTask([createdOnly, freshStop]).taskArn).toBe('arn/fresh');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns null for empty input', () => {
|
|
179
|
+
expect(pickMostRecentTask([])).toBeNull();
|
|
180
|
+
expect(pickMostRecentTask(null)).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('extractTaskIdFromArn returns the trailing task id', () => {
|
|
184
|
+
expect(extractTaskIdFromArn('arn:aws:ecs:us-east-2:123456789012:task/test-cluster/4ae4e44b8e484ac481d44fc408e3b9e7'))
|
|
185
|
+
.toBe('4ae4e44b8e484ac481d44fc408e3b9e7');
|
|
186
|
+
expect(extractTaskIdFromArn('no-slash')).toBe('');
|
|
187
|
+
expect(extractTaskIdFromArn(undefined)).toBe('');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('selects the most recently stopped task even when listed out of order', async () => {
|
|
192
|
+
const arns = ['arn-old', 'arn-new', 'arn-mid'];
|
|
193
|
+
mockEcsSend
|
|
194
|
+
.mockResolvedValueOnce({ taskArns: arns })
|
|
195
|
+
.mockResolvedValueOnce({
|
|
196
|
+
tasks: [
|
|
197
|
+
{ taskArn: 'arn-old', stoppedReason: 'old', stoppedAt: '2026-09-26T12:00:00.000Z', containers: [{ name: 'app', exitCode: 1 }] },
|
|
198
|
+
{ taskArn: 'arn-new', stoppedReason: 'fresh crash', stoppedAt: '2026-09-26T12:19:47.000Z', containers: [{ name: 'app', exitCode: 1 }] },
|
|
199
|
+
{ taskArn: 'arn-mid', stoppedReason: 'mid', stoppedAt: '2026-09-26T12:10:00.000Z', containers: [{ name: 'app', exitCode: 1 }] },
|
|
200
|
+
]
|
|
201
|
+
});
|
|
202
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
203
|
+
|
|
204
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
205
|
+
expect(result.taskArn).toBe('arn-new');
|
|
206
|
+
expect(result.stoppedReason).toBe('fresh crash');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('describes up to 100 ARNs in one batch', async () => {
|
|
210
|
+
const arns = Array.from({ length: 101 }, (_, i) => `arn-${i}`);
|
|
211
|
+
mockEcsSend
|
|
212
|
+
.mockResolvedValueOnce({ taskArns: arns })
|
|
213
|
+
.mockResolvedValueOnce({ tasks: [] });
|
|
214
|
+
mockLogsSend.mockResolvedValueOnce({ events: [] });
|
|
215
|
+
|
|
216
|
+
await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
217
|
+
expect(MockDescribeTasksCommand).toHaveBeenCalledWith(
|
|
218
|
+
expect.objectContaining({ tasks: expect.arrayContaining(arns.slice(0, 100)) })
|
|
219
|
+
);
|
|
220
|
+
const described = MockDescribeTasksCommand.mock.calls[0][0].tasks;
|
|
221
|
+
expect(described).toHaveLength(100);
|
|
222
|
+
expect(described).not.toContain('arn-100');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('scopes the first log query to the crashed task stream', async () => {
|
|
226
|
+
mockEcsSend
|
|
227
|
+
.mockResolvedValueOnce({ taskArns: ['arn-task'] })
|
|
228
|
+
.mockResolvedValueOnce({
|
|
229
|
+
tasks: [{
|
|
230
|
+
taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/4ae4e44b8e484ac481d44fc408e3b9e7',
|
|
231
|
+
stoppedReason: 'crash',
|
|
232
|
+
stoppedAt: '2026-09-26T12:19:47.000Z',
|
|
233
|
+
containers: [{ name: 'myapp-container', exitCode: 1 }],
|
|
234
|
+
}]
|
|
235
|
+
});
|
|
236
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'FATAL: boom' }] });
|
|
237
|
+
|
|
238
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
239
|
+
expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(
|
|
240
|
+
expect.objectContaining({
|
|
241
|
+
logGroupName: '/ecs/test',
|
|
242
|
+
logStreamNamePrefix: 'ecs/myapp-container/4ae4e44b8e484ac481d44fc408e3b9e7',
|
|
243
|
+
limit: 50,
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
247
|
+
expect(result.logs).toEqual(['FATAL: boom']);
|
|
248
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
249
|
+
'diagnose_run',
|
|
250
|
+
expect.objectContaining({ success: true, healthy: false, log_source: 'task-stream' })
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('falls back to a group-wide 1h query when the task stream is empty', async () => {
|
|
255
|
+
mockEcsSend
|
|
256
|
+
.mockResolvedValueOnce({ taskArns: ['arn-task'] })
|
|
257
|
+
.mockResolvedValueOnce({
|
|
258
|
+
tasks: [{
|
|
259
|
+
taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/abc123',
|
|
260
|
+
stoppedReason: 'crash',
|
|
261
|
+
stoppedAt: '2026-09-26T12:19:47.000Z',
|
|
262
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
263
|
+
}]
|
|
264
|
+
});
|
|
265
|
+
mockLogsSend
|
|
266
|
+
.mockResolvedValueOnce({ events: [] })
|
|
267
|
+
.mockResolvedValueOnce({ events: [{ message: 'group tail line' }] });
|
|
268
|
+
|
|
269
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
270
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(2);
|
|
271
|
+
expect(MockFilterLogEventsCommand).toHaveBeenNthCalledWith(2,
|
|
272
|
+
expect.objectContaining({
|
|
273
|
+
logGroupName: '/ecs/test',
|
|
274
|
+
startTime: expect.any(Number),
|
|
275
|
+
limit: 50,
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
const secondInput = MockFilterLogEventsCommand.mock.calls[1][0];
|
|
279
|
+
expect(secondInput.logStreamNamePrefix).toBeUndefined();
|
|
280
|
+
expect(secondInput.startTime).toBeGreaterThan(Date.now() - 61 * 60 * 1000);
|
|
281
|
+
expect(result.logs).toEqual(['group tail line']);
|
|
282
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
283
|
+
'diagnose_run',
|
|
284
|
+
expect.objectContaining({ success: true, healthy: false, log_source: 'group-fallback' })
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("reports log_source 'none' when both queries return empty", async () => {
|
|
289
|
+
mockEcsSend
|
|
290
|
+
.mockResolvedValueOnce({ taskArns: ['arn-task'] })
|
|
291
|
+
.mockResolvedValueOnce({
|
|
292
|
+
tasks: [{
|
|
293
|
+
taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/abc123',
|
|
294
|
+
stoppedReason: 'crash',
|
|
295
|
+
stoppedAt: '2026-09-26T12:19:47.000Z',
|
|
296
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
297
|
+
}]
|
|
298
|
+
});
|
|
299
|
+
mockLogsSend
|
|
300
|
+
.mockResolvedValueOnce({ events: [] })
|
|
301
|
+
.mockResolvedValueOnce({ events: [] });
|
|
302
|
+
|
|
303
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
304
|
+
expect(result.logs).toEqual([]);
|
|
305
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
306
|
+
'diagnose_run',
|
|
307
|
+
expect.objectContaining({ success: true, healthy: false, log_source: 'none' })
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('recovered service detection', () => {
|
|
312
|
+
const stoppedTask = {
|
|
313
|
+
taskArn: 'arn-stopped',
|
|
314
|
+
stoppedReason: 'old crash',
|
|
315
|
+
stoppedAt: '2026-09-26T12:10:00.000Z',
|
|
316
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const mockStoppedOnly = () => {
|
|
320
|
+
mockEcsSend
|
|
321
|
+
.mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
|
|
322
|
+
.mockResolvedValueOnce({ tasks: [stoppedTask] });
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const runWithOutput = async () => {
|
|
326
|
+
const output = [];
|
|
327
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
328
|
+
output.push(args.join(' '));
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
332
|
+
return { result, text: stripVTControlCharacters(output.join('\n')) };
|
|
333
|
+
} finally {
|
|
334
|
+
consoleSpy.mockRestore();
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
it('reports recovery without fetching logs when a running task is newer', async () => {
|
|
339
|
+
mockStoppedOnly();
|
|
340
|
+
mockEcsSend
|
|
341
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
342
|
+
.mockResolvedValueOnce({
|
|
343
|
+
tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T12:15:00.000Z', containers: [{ name: 'app' }] }],
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const { result, text } = await runWithOutput();
|
|
347
|
+
expect(result).toMatchObject({
|
|
348
|
+
healthy: true,
|
|
349
|
+
recovered: true,
|
|
350
|
+
stoppedReason: 'old crash',
|
|
351
|
+
taskArn: 'arn-stopped',
|
|
352
|
+
containerName: 'app',
|
|
353
|
+
logs: [],
|
|
354
|
+
});
|
|
355
|
+
expect(mockLogsSend).not.toHaveBeenCalled();
|
|
356
|
+
expect(text).toContain('Service recovered');
|
|
357
|
+
expect(text).toContain('Previous crash');
|
|
358
|
+
expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.stringContaining('recovered after that crash'));
|
|
359
|
+
expect(vi.mocked(outro)).not.toHaveBeenCalledWith(expect.stringContaining('Fix the error above'));
|
|
360
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
361
|
+
'diagnose_run',
|
|
362
|
+
expect.objectContaining({ success: true, healthy: true, recovered: true, log_source: 'none' })
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('stays unresolved when the running task started at the same instant (tie)', async () => {
|
|
367
|
+
mockStoppedOnly();
|
|
368
|
+
mockEcsSend
|
|
369
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
370
|
+
.mockResolvedValueOnce({
|
|
371
|
+
tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T12:10:00.000Z', containers: [{ name: 'app' }] }],
|
|
372
|
+
});
|
|
373
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
374
|
+
|
|
375
|
+
const { result } = await runWithOutput();
|
|
376
|
+
expect(result.healthy).toBe(false);
|
|
377
|
+
expect(result.recovered).toBeUndefined();
|
|
378
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('stays unresolved when running tasks predate the stop (failed rollout)', async () => {
|
|
382
|
+
mockStoppedOnly();
|
|
383
|
+
mockEcsSend
|
|
384
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
385
|
+
.mockResolvedValueOnce({
|
|
386
|
+
tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T12:00:00.000Z', containers: [{ name: 'app' }] }],
|
|
387
|
+
});
|
|
388
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
389
|
+
|
|
390
|
+
const { result, text } = await runWithOutput();
|
|
391
|
+
expect(result.healthy).toBe(false);
|
|
392
|
+
expect(text).toContain('Stopped reason: old crash');
|
|
393
|
+
expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.stringContaining('Fix the error above'));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('stays unresolved when running ARNs return no task details', async () => {
|
|
397
|
+
mockStoppedOnly();
|
|
398
|
+
mockEcsSend
|
|
399
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
400
|
+
.mockResolvedValueOnce({ tasks: [] });
|
|
401
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
402
|
+
|
|
403
|
+
const { result } = await runWithOutput();
|
|
404
|
+
expect(result.healthy).toBe(false);
|
|
405
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('diagnoses the crash when the desired-RUNNING task is still PENDING (crash loop)', async () => {
|
|
409
|
+
// Live case: ListTasks({ desiredStatus: 'RUNNING' }) returns a
|
|
410
|
+
// 2-second-old PENDING task (CannotPullContainerError loop) next
|
|
411
|
+
// to the STOPPED crash. Fresh createdAt must not read as recovery.
|
|
412
|
+
mockStoppedOnly();
|
|
413
|
+
mockEcsSend
|
|
414
|
+
.mockResolvedValueOnce({ taskArns: ['arn-pending'] })
|
|
415
|
+
.mockResolvedValueOnce({
|
|
416
|
+
tasks: [{
|
|
417
|
+
taskArn: 'arn-pending',
|
|
418
|
+
desiredStatus: 'RUNNING',
|
|
419
|
+
lastStatus: 'PENDING',
|
|
420
|
+
createdAt: '2026-09-26T12:15:00.000Z',
|
|
421
|
+
containers: [{ name: 'app' }],
|
|
422
|
+
}],
|
|
423
|
+
});
|
|
424
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
425
|
+
|
|
426
|
+
const { result, text } = await runWithOutput();
|
|
427
|
+
expect(result.healthy).toBe(false);
|
|
428
|
+
expect(result.recovered).toBeUndefined();
|
|
429
|
+
expect(text).toContain('Stopped reason: old crash');
|
|
430
|
+
expect(text).not.toContain('Service recovered');
|
|
431
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('diagnoses the crash when the only RUNNING task is UNHEALTHY', async () => {
|
|
435
|
+
mockStoppedOnly();
|
|
436
|
+
mockEcsSend
|
|
437
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
438
|
+
.mockResolvedValueOnce({
|
|
439
|
+
tasks: [{
|
|
440
|
+
taskArn: 'arn-running',
|
|
441
|
+
lastStatus: 'RUNNING',
|
|
442
|
+
healthStatus: 'UNHEALTHY',
|
|
443
|
+
startedAt: '2026-09-26T12:15:00.000Z',
|
|
444
|
+
containers: [{ name: 'app' }],
|
|
445
|
+
}],
|
|
446
|
+
});
|
|
447
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
448
|
+
|
|
449
|
+
const { result, text } = await runWithOutput();
|
|
450
|
+
expect(result.healthy).toBe(false);
|
|
451
|
+
expect(result.recovered).toBeUndefined();
|
|
452
|
+
expect(text).not.toContain('Service recovered');
|
|
453
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe('parseTaskDefinitionRef', () => {
|
|
458
|
+
it('parses family and revision from ECS task definition ARNs', () => {
|
|
459
|
+
expect(parseTaskDefinitionRef('arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task:9'))
|
|
460
|
+
.toEqual({ family: 'myapp-task', revision: 9 });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('returns null for missing or malformed ARNs', () => {
|
|
464
|
+
expect(parseTaskDefinitionRef(undefined)).toBeNull();
|
|
465
|
+
expect(parseTaskDefinitionRef('arn-stopped')).toBeNull();
|
|
466
|
+
expect(parseTaskDefinitionRef('arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task')).toBeNull();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('revision-based recovery', () => {
|
|
471
|
+
const taskDef = (rev) => `arn:aws:ecs:us-east-2:123456789012:task-definition/myapp-task:${rev}`;
|
|
472
|
+
|
|
473
|
+
const mockStoppedAtRev = (rev, stoppedAt) => {
|
|
474
|
+
mockEcsSend
|
|
475
|
+
.mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
|
|
476
|
+
.mockResolvedValueOnce({
|
|
477
|
+
tasks: [{
|
|
478
|
+
taskArn: 'arn-stopped',
|
|
479
|
+
taskDefinitionArn: taskDef(rev),
|
|
480
|
+
stoppedReason: 'old crash',
|
|
481
|
+
stoppedAt,
|
|
482
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
483
|
+
}],
|
|
484
|
+
});
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const mockRunningAtRev = (rev, startedAt) => {
|
|
488
|
+
mockEcsSend
|
|
489
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
490
|
+
.mockResolvedValueOnce({
|
|
491
|
+
tasks: [{
|
|
492
|
+
taskArn: 'arn-running',
|
|
493
|
+
taskDefinitionArn: taskDef(rev),
|
|
494
|
+
lastStatus: 'RUNNING',
|
|
495
|
+
startedAt,
|
|
496
|
+
containers: [{ name: 'app' }],
|
|
497
|
+
}],
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const runWithOutput = async () => {
|
|
502
|
+
const output = [];
|
|
503
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
504
|
+
output.push(args.join(' '));
|
|
505
|
+
});
|
|
506
|
+
try {
|
|
507
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/test' });
|
|
508
|
+
return { result, text: stripVTControlCharacters(output.join('\n')) };
|
|
509
|
+
} finally {
|
|
510
|
+
consoleSpy.mockRestore();
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
it('recovers when the stop is on a superseded revision even if it stopped later (drain order)', async () => {
|
|
515
|
+
// Live case: :9 started 14:44:19, :8 drained at 14:44:26.
|
|
516
|
+
mockStoppedAtRev(8, '2026-09-26T14:44:26.000Z');
|
|
517
|
+
mockRunningAtRev(9, '2026-09-26T14:44:19.000Z');
|
|
518
|
+
|
|
519
|
+
const { result, text } = await runWithOutput();
|
|
520
|
+
expect(result).toMatchObject({ healthy: true, recovered: true });
|
|
521
|
+
expect(mockLogsSend).not.toHaveBeenCalled();
|
|
522
|
+
expect(text).toContain('Revision 9 is now running');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('recovers a rollback when the older revision restarted after the crash', async () => {
|
|
526
|
+
// :9 crashed, then `rollback 8` launched a fresh :8 afterwards.
|
|
527
|
+
mockStoppedAtRev(9, '2026-09-26T14:40:00.000Z');
|
|
528
|
+
mockRunningAtRev(8, '2026-09-26T14:45:00.000Z');
|
|
529
|
+
|
|
530
|
+
const { result } = await runWithOutput();
|
|
531
|
+
expect(result).toMatchObject({ healthy: true, recovered: true });
|
|
532
|
+
expect(mockLogsSend).not.toHaveBeenCalled();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('stays unresolved when the crash is on a newer revision than the survivor', async () => {
|
|
536
|
+
// Failed rollout: :8 kept serving while :9 crashed.
|
|
537
|
+
mockStoppedAtRev(9, '2026-09-26T14:44:26.000Z');
|
|
538
|
+
mockRunningAtRev(8, '2026-09-26T14:40:00.000Z');
|
|
539
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
540
|
+
|
|
541
|
+
const { result, text } = await runWithOutput();
|
|
542
|
+
expect(result.healthy).toBe(false);
|
|
543
|
+
expect(result.recovered).toBeUndefined();
|
|
544
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
545
|
+
expect(text).toMatch(/\(stopped \S+ ago\)/);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('falls back to timestamps when revisions match or are unparseable', async () => {
|
|
549
|
+
// Same revision: newer running task still recovers via timestamps.
|
|
550
|
+
mockStoppedAtRev(9, '2026-09-26T14:40:00.000Z');
|
|
551
|
+
mockRunningAtRev(9, '2026-09-26T14:45:00.000Z');
|
|
552
|
+
|
|
553
|
+
const first = await runWithOutput();
|
|
554
|
+
expect(first.result).toMatchObject({ healthy: true, recovered: true });
|
|
555
|
+
|
|
556
|
+
// Unparseable ARNs: timestamp rule decides (older running → unresolved).
|
|
557
|
+
vi.clearAllMocks();
|
|
558
|
+
mockEcsSend.mockResolvedValue({ taskArns: [] });
|
|
559
|
+
mockEcsSend
|
|
560
|
+
.mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
|
|
561
|
+
.mockResolvedValueOnce({
|
|
562
|
+
tasks: [{
|
|
563
|
+
taskArn: 'arn-stopped',
|
|
564
|
+
stoppedReason: 'old crash',
|
|
565
|
+
stoppedAt: '2026-09-26T14:44:00.000Z',
|
|
566
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
567
|
+
}],
|
|
568
|
+
})
|
|
569
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
570
|
+
.mockResolvedValueOnce({
|
|
571
|
+
tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T14:40:00.000Z', containers: [{ name: 'app' }] }],
|
|
572
|
+
});
|
|
573
|
+
mockLogsSend.mockResolvedValueOnce({ events: [{ message: 'boom' }] });
|
|
574
|
+
|
|
575
|
+
const second = await runWithOutput();
|
|
576
|
+
expect(second.result.healthy).toBe(false);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('prefers container exit time over task stop time for the comparison', async () => {
|
|
580
|
+
// Task fully stopped (ENI deprovisioned) after the running task
|
|
581
|
+
// started, but the container itself exited long before.
|
|
582
|
+
mockEcsSend
|
|
583
|
+
.mockResolvedValueOnce({ taskArns: ['arn-stopped'] })
|
|
584
|
+
.mockResolvedValueOnce({
|
|
585
|
+
tasks: [{
|
|
586
|
+
taskArn: 'arn-stopped',
|
|
587
|
+
stoppedReason: 'old crash',
|
|
588
|
+
executionStoppedAt: '2026-09-26T14:30:00.000Z',
|
|
589
|
+
stoppedAt: '2026-09-26T14:50:00.000Z',
|
|
590
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
591
|
+
}],
|
|
592
|
+
})
|
|
593
|
+
.mockResolvedValueOnce({ taskArns: ['arn-running'] })
|
|
594
|
+
.mockResolvedValueOnce({
|
|
595
|
+
tasks: [{ taskArn: 'arn-running', lastStatus: 'RUNNING', startedAt: '2026-09-26T14:40:00.000Z', containers: [{ name: 'app' }] }],
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const { result } = await runWithOutput();
|
|
599
|
+
expect(result).toMatchObject({ healthy: true, recovered: true });
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe('formatAge', () => {
|
|
604
|
+
it('formats seconds, minutes, hours, and days', () => {
|
|
605
|
+
expect(formatAge(0)).toBe('0s');
|
|
606
|
+
expect(formatAge(-5000)).toBe('0s');
|
|
607
|
+
expect(formatAge(45 * 1000)).toBe('45s');
|
|
608
|
+
expect(formatAge(5 * 60 * 1000)).toBe('5m');
|
|
609
|
+
expect(formatAge(2 * 60 * 60 * 1000)).toBe('2h');
|
|
610
|
+
expect(formatAge(3 * 24 * 60 * 60 * 1000)).toBe('3d');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('reports a missing log group instead of an empty stream', async () => {
|
|
615
|
+
mockEcsSend
|
|
616
|
+
.mockResolvedValueOnce({ taskArns: ['arn-task'] })
|
|
617
|
+
.mockResolvedValueOnce({
|
|
618
|
+
tasks: [{
|
|
619
|
+
taskArn: 'arn:aws:ecs:us-east-2:123456789012:task/test-cluster/abc123',
|
|
620
|
+
stoppedReason: 'crash',
|
|
621
|
+
stoppedAt: '2026-09-26T12:19:47.000Z',
|
|
622
|
+
containers: [{ name: 'app', exitCode: 1 }],
|
|
623
|
+
}]
|
|
624
|
+
});
|
|
625
|
+
const notFound = new Error('The specified log group does not exist.');
|
|
626
|
+
notFound.name = 'ResourceNotFoundException';
|
|
627
|
+
mockLogsSend.mockRejectedValueOnce(notFound);
|
|
628
|
+
|
|
629
|
+
const output = [];
|
|
630
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
631
|
+
output.push(args.join(' '));
|
|
632
|
+
});
|
|
633
|
+
try {
|
|
634
|
+
const result = await runDiagnose({ cluster: 'test-cluster', region: 'us-east-2', logGroup: '/ecs/missing' });
|
|
635
|
+
expect(mockLogsSend).toHaveBeenCalledTimes(1);
|
|
636
|
+
expect(result.logs).toEqual([]);
|
|
637
|
+
expect(output.join('\n')).toContain('Log group not found');
|
|
638
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
639
|
+
'diagnose_run',
|
|
640
|
+
expect.objectContaining({ success: true, healthy: false, log_source: 'none' })
|
|
641
|
+
);
|
|
642
|
+
} finally {
|
|
643
|
+
consoleSpy.mockRestore();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should exit gracefully on UnrecognizedClientException instead of throwing', async () => {
|
|
648
|
+
const error = new Error('Invalid token');
|
|
649
|
+
error.name = 'UnrecognizedClientException';
|
|
650
|
+
mockEcsSend.mockRejectedValueOnce(error);
|
|
651
|
+
|
|
652
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
653
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
await runDiagnose({ cluster: 'test-cluster', region: 'us-east-1', spawnSyncImpl: () => ({ status: 0 }) });
|
|
657
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('AWS Session Expired'));
|
|
658
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
659
|
+
} finally {
|
|
660
|
+
exitSpy.mockRestore();
|
|
661
|
+
consoleSpy.mockRestore();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
|
|
666
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
667
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
668
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
669
|
+
try {
|
|
670
|
+
const result = await runDiagnose(null);
|
|
671
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
672
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
673
|
+
expect(trackEvent).toHaveBeenCalledWith('diagnose_run', expect.objectContaining({
|
|
674
|
+
success: false,
|
|
675
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
676
|
+
}));
|
|
677
|
+
} finally {
|
|
678
|
+
cwdSpy.mockRestore();
|
|
679
|
+
exitSpy.mockRestore();
|
|
680
|
+
consoleSpy.mockRestore();
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe('Command: diagnose on --target lambda projects', () => {
|
|
686
|
+
let exitSpy;
|
|
687
|
+
|
|
688
|
+
beforeEach(() => {
|
|
689
|
+
vi.clearAllMocks();
|
|
690
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
afterEach(() => {
|
|
694
|
+
exitSpy.mockRestore();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const ACTIVE = JSON.stringify({ Configuration: { State: 'Active', LastUpdateStatus: 'Successful' } });
|
|
698
|
+
|
|
699
|
+
function baseInput(overrides = {}) {
|
|
700
|
+
return {
|
|
701
|
+
projectName: 'myapp',
|
|
702
|
+
region: 'us-east-2',
|
|
703
|
+
logGroup: '/aws/lambda/myapp-fn',
|
|
704
|
+
spawnSyncImpl: vi.fn(() => ({ status: 0, stdout: ACTIVE, stderr: '' })),
|
|
705
|
+
logsClient: { send: vi.fn().mockResolvedValue({ events: [] }) },
|
|
706
|
+
...overrides,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
it('reports healthy when the function is active with no error logs', async () => {
|
|
711
|
+
const result = await runLambdaDiagnose(baseInput());
|
|
712
|
+
expect(result.healthy).toBe(true);
|
|
713
|
+
expect(result.functionName).toBe('myapp-fn');
|
|
714
|
+
expect(trackEvent).toHaveBeenCalledWith('diagnose_run', expect.objectContaining({ healthy: true, success: true }));
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('surfaces failed updates and recent error logs', async () => {
|
|
718
|
+
const failed = JSON.stringify({ Configuration: { State: 'Active', LastUpdateStatus: 'Failed' } });
|
|
719
|
+
const logsClient = { send: vi.fn().mockResolvedValue({ events: [{ message: 'ERROR boom' }, { message: 'ok line' }] }) };
|
|
720
|
+
const result = await runLambdaDiagnose(baseInput({
|
|
721
|
+
spawnSyncImpl: vi.fn(() => ({ status: 0, stdout: failed, stderr: '' })),
|
|
722
|
+
logsClient,
|
|
723
|
+
}));
|
|
724
|
+
expect(result.healthy).toBe(false);
|
|
725
|
+
expect(result.logs).toHaveLength(2);
|
|
726
|
+
expect(outro).toHaveBeenCalled();
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('flags error logs even when the configuration looks healthy', async () => {
|
|
730
|
+
const logsClient = { send: vi.fn().mockResolvedValue({ events: [{ message: 'FATAL out of memory' }] }) };
|
|
731
|
+
const result = await runLambdaDiagnose(baseInput({ logsClient }));
|
|
732
|
+
expect(result.healthy).toBe(false);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('handles missing functions and missing CLIs without throwing', async () => {
|
|
736
|
+
const missing = await runLambdaDiagnose(baseInput({
|
|
737
|
+
spawnSyncImpl: vi.fn(() => ({ status: 254, stdout: '', stderr: 'ResourceNotFoundException' })),
|
|
738
|
+
}));
|
|
739
|
+
expect(missing).toMatchObject({ healthy: false, reason: 'not-deployed' });
|
|
740
|
+
|
|
741
|
+
await runLambdaDiagnose(baseInput({ spawnSyncImpl: vi.fn(() => ({ error: { code: 'ENOENT' } })) }));
|
|
742
|
+
expect(trackEvent).toHaveBeenCalledWith('diagnose_run', expect.objectContaining({ error_code: 'AWS_CLI_MISSING' }));
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('dispatches lambda projects from runDiagnose', async () => {
|
|
746
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'diagnose-lambda-test-'));
|
|
747
|
+
try {
|
|
748
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
749
|
+
fs.writeFileSync(
|
|
750
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
751
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
|
|
752
|
+
);
|
|
753
|
+
const result = await runDiagnose({
|
|
754
|
+
cwd: dir,
|
|
755
|
+
region: 'us-east-2',
|
|
756
|
+
spawnSyncImpl: vi.fn(() => ({ status: 0, stdout: ACTIVE, stderr: '' })),
|
|
757
|
+
logsClient: { send: vi.fn().mockResolvedValue({ events: [] }) },
|
|
758
|
+
});
|
|
759
|
+
expect(result.healthy).toBe(true);
|
|
760
|
+
expect(result.functionName).toBe('myapp-fn');
|
|
761
|
+
} finally {
|
|
762
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
});
|