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,370 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { runStatus, runLambdaStatus, parseStatusArgs, getServiceHealth, getLambdaHealth, DEGRADED_MESSAGE } from '../src/commands/status.js';
|
|
6
|
+
import { runDiagnose } from '../src/commands/diagnose.js';
|
|
7
|
+
import { trackEvent } from '../src/core/telemetry.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('../src/commands/diagnose.js', () => ({
|
|
10
|
+
runDiagnose: vi.fn().mockResolvedValue({ healthy: false }),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('@clack/prompts', () => ({
|
|
14
|
+
intro: vi.fn(),
|
|
15
|
+
outro: vi.fn(),
|
|
16
|
+
spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../src/core/telemetry.js', () => {
|
|
20
|
+
const trackEvent = vi.fn();
|
|
21
|
+
const flushTelemetry = vi.fn().mockResolvedValue();
|
|
22
|
+
// Mirrors the real trackSuccess delegation so success-path assertions
|
|
23
|
+
// keep observing trackEvent (the real helper is unit-tested separately).
|
|
24
|
+
const trackSuccess = vi.fn(async (event, properties) => {
|
|
25
|
+
trackEvent(event, { ...properties, success: true });
|
|
26
|
+
await flushTelemetry();
|
|
27
|
+
});
|
|
28
|
+
const trackFailure = vi.fn(async (event, properties) => {
|
|
29
|
+
trackEvent(event, { ...properties, success: false });
|
|
30
|
+
await flushTelemetry();
|
|
31
|
+
});
|
|
32
|
+
return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function mockEcsClient(serviceDesc) {
|
|
36
|
+
return { send: vi.fn().mockResolvedValue({ services: [serviceDesc] }) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockCloudWatchClient(alarms = []) {
|
|
40
|
+
return { send: vi.fn().mockResolvedValue({ MetricAlarms: alarms, CompositeAlarms: [] }) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const healthyService = {
|
|
44
|
+
serviceName: 'myapp-service',
|
|
45
|
+
status: 'ACTIVE',
|
|
46
|
+
desiredCount: 2,
|
|
47
|
+
runningCount: 2,
|
|
48
|
+
pendingCount: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('status: CLI args', () => {
|
|
52
|
+
it('parses --region and --json', () => {
|
|
53
|
+
expect(parseStatusArgs(['status', '--region', 'eu-west-1', '--json'])).toEqual({
|
|
54
|
+
region: 'eu-west-1',
|
|
55
|
+
json: true,
|
|
56
|
+
});
|
|
57
|
+
expect(parseStatusArgs(['status', '--region=us-west-2'])).toEqual({ region: 'us-west-2' });
|
|
58
|
+
expect(parseStatusArgs(['status'])).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('status: health calculation', () => {
|
|
63
|
+
it('is healthy when running matches desired and alarms are OK', () => {
|
|
64
|
+
const health = getServiceHealth(healthyService, [{ AlarmName: 'a', StateValue: 'OK' }]);
|
|
65
|
+
expect(health.healthy).toBe(true);
|
|
66
|
+
expect(health.alarmed).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('is unhealthy when running is below desired', () => {
|
|
70
|
+
const health = getServiceHealth({ ...healthyService, runningCount: 1 }, []);
|
|
71
|
+
expect(health.healthy).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('is unhealthy when any alarm is in ALARM state', () => {
|
|
75
|
+
const health = getServiceHealth(healthyService, [{ AlarmName: 'x', StateValue: 'ALARM' }]);
|
|
76
|
+
expect(health.healthy).toBe(false);
|
|
77
|
+
expect(health.alarmed).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('Command: status (mocked ECS + CloudWatch)', () => {
|
|
82
|
+
const savedEnv = { ...process.env };
|
|
83
|
+
let exitSpy;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
delete process.env.AWS_REGION;
|
|
88
|
+
delete process.env.ECS_CLUSTER;
|
|
89
|
+
delete process.env.ECS_SERVICE;
|
|
90
|
+
delete process.env.ECS_LOG_GROUP;
|
|
91
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
process.env = { ...savedEnv };
|
|
96
|
+
exitSpy.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('exits cleanly with a green dashboard when healthy', async () => {
|
|
100
|
+
const ecsClient = mockEcsClient(healthyService);
|
|
101
|
+
const cloudWatchClient = mockCloudWatchClient([{ AlarmName: 'myapp-high-5xx-errors', StateValue: 'OK' }]);
|
|
102
|
+
|
|
103
|
+
const output = [];
|
|
104
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
105
|
+
output.push(args.join(' '));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = await runStatus({ projectName: 'myapp', region: 'us-east-2', ecsClient, cloudWatchClient });
|
|
110
|
+
expect(result.healthy).toBe(true);
|
|
111
|
+
expect(result.service.runningCount).toBe(2);
|
|
112
|
+
expect(result.service.desiredCount).toBe(2);
|
|
113
|
+
expect(runDiagnose).not.toHaveBeenCalled();
|
|
114
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
115
|
+
const text = output.join('\n');
|
|
116
|
+
expect(text).toContain('myapp-service');
|
|
117
|
+
expect(text).toContain('2/2');
|
|
118
|
+
expect(text).toContain('[OK]');
|
|
119
|
+
} finally {
|
|
120
|
+
consoleSpy.mockRestore();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('runs automated diagnostics and exits 1 on a crash loop', async () => {
|
|
125
|
+
const ecsClient = mockEcsClient({ ...healthyService, runningCount: 1, pendingCount: 1 });
|
|
126
|
+
const cloudWatchClient = mockCloudWatchClient([]);
|
|
127
|
+
|
|
128
|
+
const output = [];
|
|
129
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
130
|
+
output.push(args.join(' '));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const result = await runStatus({ projectName: 'myapp', region: 'us-east-2', ecsClient, cloudWatchClient });
|
|
135
|
+
expect(result.healthy).toBe(false);
|
|
136
|
+
expect(output.join('\n')).toContain(DEGRADED_MESSAGE);
|
|
137
|
+
expect(runDiagnose).toHaveBeenCalledWith(
|
|
138
|
+
expect.objectContaining({ cluster: 'myapp-cluster', region: 'us-east-2', logGroup: '/ecs/myapp' })
|
|
139
|
+
);
|
|
140
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
141
|
+
} finally {
|
|
142
|
+
consoleSpy.mockRestore();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('hands off to diagnose when a 5XX alarm fires', async () => {
|
|
147
|
+
const ecsClient = mockEcsClient(healthyService);
|
|
148
|
+
const cloudWatchClient = mockCloudWatchClient([
|
|
149
|
+
{ AlarmName: 'myapp-high-5xx-errors', StateValue: 'ALARM' },
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
153
|
+
try {
|
|
154
|
+
const result = await runStatus({ projectName: 'myapp', region: 'us-east-2', ecsClient, cloudWatchClient });
|
|
155
|
+
expect(result.healthy).toBe(false);
|
|
156
|
+
expect(runDiagnose).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
158
|
+
} finally {
|
|
159
|
+
consoleSpy.mockRestore();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('--json prints the raw payload and disables auto-diagnose', async () => {
|
|
164
|
+
const ecsClient = mockEcsClient({ ...healthyService, runningCount: 0 });
|
|
165
|
+
const cloudWatchClient = mockCloudWatchClient([]);
|
|
166
|
+
|
|
167
|
+
const output = [];
|
|
168
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
169
|
+
output.push(args.join(' '));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const result = await runStatus({ projectName: 'myapp', region: 'us-east-2', json: true, ecsClient, cloudWatchClient });
|
|
174
|
+
expect(runDiagnose).not.toHaveBeenCalled();
|
|
175
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
176
|
+
const payload = JSON.parse(output.join('\n'));
|
|
177
|
+
expect(payload.healthy).toBe(false);
|
|
178
|
+
expect(payload.service.runningCount).toBe(0);
|
|
179
|
+
expect(result.healthy).toBe(false);
|
|
180
|
+
} finally {
|
|
181
|
+
consoleSpy.mockRestore();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('prints SSO recovery guidance and exits 1 on expired sessions', async () => {
|
|
186
|
+
const error = new Error('Invalid token');
|
|
187
|
+
error.name = 'UnrecognizedClientException';
|
|
188
|
+
const ecsClient = { send: vi.fn().mockRejectedValue(error) };
|
|
189
|
+
const cloudWatchClient = mockCloudWatchClient([]);
|
|
190
|
+
|
|
191
|
+
const output = [];
|
|
192
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
193
|
+
output.push(args.join(' '));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await runStatus({ projectName: 'myapp', region: 'us-east-2', ecsClient, cloudWatchClient, spawnSyncImpl: () => ({ status: 0 }) });
|
|
198
|
+
expect(output.join('\n')).toContain('aws sso login');
|
|
199
|
+
expect(runDiagnose).not.toHaveBeenCalled();
|
|
200
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
201
|
+
} finally {
|
|
202
|
+
consoleSpy.mockRestore();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('is wired into bin/cli.js with diagnose hidden from help', () => {
|
|
207
|
+
const cliPath = path.resolve(__dirname, '../bin/cli.js');
|
|
208
|
+
const content = fs.readFileSync(cliPath, 'utf8');
|
|
209
|
+
expect(content).toContain('runStatus');
|
|
210
|
+
expect(content).toContain("'status'");
|
|
211
|
+
|
|
212
|
+
const helpBlock = content.match(/HELP_TEXT = \[([\s\S]*?)\];/);
|
|
213
|
+
expect(helpBlock).not.toBeNull();
|
|
214
|
+
expect(helpBlock[1]).toContain('status');
|
|
215
|
+
expect(helpBlock[1]).not.toContain('diagnose');
|
|
216
|
+
expect(helpBlock[1]).not.toContain('wtf');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('exits 0 and prints guidance when the service does not exist', async () => {
|
|
220
|
+
const ecsClient = { send: vi.fn().mockResolvedValue({ services: [] }) };
|
|
221
|
+
const cloudWatchClient = mockCloudWatchClient([]);
|
|
222
|
+
|
|
223
|
+
const output = [];
|
|
224
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
225
|
+
output.push(args.join(' '));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await runStatus({ projectName: 'myapp', region: 'us-east-2', ecsClient, cloudWatchClient });
|
|
230
|
+
|
|
231
|
+
const text = output.join('\n');
|
|
232
|
+
expect(text).toContain('does not exist or is inactive');
|
|
233
|
+
expect(text).toContain('npx grada-run apply');
|
|
234
|
+
|
|
235
|
+
expect(runDiagnose).not.toHaveBeenCalled();
|
|
236
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
237
|
+
} finally {
|
|
238
|
+
consoleSpy.mockRestore();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('status: fuzzer hardening', () => {
|
|
244
|
+
let exitSpy;
|
|
245
|
+
let consoleSpy;
|
|
246
|
+
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
vi.clearAllMocks();
|
|
249
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
250
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
exitSpy.mockRestore();
|
|
255
|
+
consoleSpy.mockRestore();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
|
|
259
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
260
|
+
try {
|
|
261
|
+
const result = await runStatus(null);
|
|
262
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
263
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
264
|
+
expect(trackEvent).toHaveBeenCalledWith('status_run', expect.objectContaining({
|
|
265
|
+
success: false,
|
|
266
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
267
|
+
}));
|
|
268
|
+
} finally {
|
|
269
|
+
cwdSpy.mockRestore();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it.each([null, 42, true, { port: 'string' }])('parseStatusArgs(%s) returns defaults', (bad) => {
|
|
274
|
+
expect(parseStatusArgs(bad)).toEqual({});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('status: lambda target (mocked AWS CLI)', () => {
|
|
279
|
+
let exitSpy;
|
|
280
|
+
let consoleSpy;
|
|
281
|
+
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
vi.clearAllMocks();
|
|
284
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
285
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
exitSpy.mockRestore();
|
|
290
|
+
consoleSpy.mockRestore();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const ACTIVE = JSON.stringify({
|
|
294
|
+
Configuration: {
|
|
295
|
+
State: 'Active',
|
|
296
|
+
LastUpdateStatus: 'Successful',
|
|
297
|
+
LastModified: '2026-01-01T00:00:00Z',
|
|
298
|
+
MemorySize: 512,
|
|
299
|
+
Timeout: 30,
|
|
300
|
+
Code: { ImageUri: '123.dkr.ecr.us-east-2.amazonaws.com/myapp-repo:abc1234' },
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
function cliWith(stdout, status = 0) {
|
|
305
|
+
return vi.fn(() => ({ status, stdout, stderr: '' }));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it('judges health from function State and LastUpdateStatus', () => {
|
|
309
|
+
expect(getLambdaHealth({ State: 'Active', LastUpdateStatus: 'Successful' }).healthy).toBe(true);
|
|
310
|
+
expect(getLambdaHealth({ State: 'Active', LastUpdateStatus: 'Failed' }).healthy).toBe(false);
|
|
311
|
+
expect(getLambdaHealth({ State: 'Pending', LastUpdateStatus: 'Successful' }).healthy).toBe(false);
|
|
312
|
+
expect(getLambdaHealth({}).healthy).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('reports a healthy function without touching ECS', async () => {
|
|
316
|
+
const spawnSyncImpl = cliWith(ACTIVE);
|
|
317
|
+
const result = await runLambdaStatus({ projectName: 'myapp', region: 'us-east-2', spawnSyncImpl });
|
|
318
|
+
expect(result.healthy).toBe(true);
|
|
319
|
+
expect(result.computeTarget).toBe('lambda');
|
|
320
|
+
expect(result.function.name).toBe('myapp-fn');
|
|
321
|
+
expect(spawnSyncImpl).toHaveBeenCalledWith(
|
|
322
|
+
'aws',
|
|
323
|
+
expect.arrayContaining(['lambda', 'get-function', '--function-name', 'myapp-fn']),
|
|
324
|
+
expect.anything()
|
|
325
|
+
);
|
|
326
|
+
expect(trackEvent).toHaveBeenCalledWith('status_run', expect.objectContaining({ healthy: true, success: true }));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('supports --json and explicit function names', async () => {
|
|
330
|
+
const spawnSyncImpl = cliWith(ACTIVE);
|
|
331
|
+
const result = await runLambdaStatus({ projectName: 'myapp', region: 'us-east-2', functionName: 'custom-fn', json: true, spawnSyncImpl });
|
|
332
|
+
expect(result.function.name).toBe('custom-fn');
|
|
333
|
+
expect(spawnSyncImpl.mock.calls[0][1]).toContain('custom-fn');
|
|
334
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"computeTarget": "lambda"'));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('runs diagnostics and exits 1 when degraded', async () => {
|
|
338
|
+
const failed = JSON.stringify({ Configuration: { State: 'Active', LastUpdateStatus: 'Failed' } });
|
|
339
|
+
const result = await runLambdaStatus({ projectName: 'myapp', region: 'us-east-2', spawnSyncImpl: cliWith(failed) });
|
|
340
|
+
expect(result.healthy).toBe(false);
|
|
341
|
+
expect(runDiagnose).toHaveBeenCalled();
|
|
342
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('guides missing functions to apply and missing CLIs to install', async () => {
|
|
346
|
+
const missing = vi.fn(() => ({ status: 254, stdout: '', stderr: 'ResourceNotFoundException' }));
|
|
347
|
+
const result = await runLambdaStatus({ projectName: 'myapp', region: 'us-east-2', json: true, spawnSyncImpl: missing });
|
|
348
|
+
expect(result).toMatchObject({ healthy: false, missing: true });
|
|
349
|
+
|
|
350
|
+
const noCli = vi.fn(() => ({ error: { code: 'ENOENT' } }));
|
|
351
|
+
await runLambdaStatus({ projectName: 'myapp', region: 'us-east-2', spawnSyncImpl: noCli });
|
|
352
|
+
expect(trackEvent).toHaveBeenCalledWith('status_run', expect.objectContaining({ error_code: 'AWS_CLI_MISSING' }));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('dispatches lambda projects from runStatus', async () => {
|
|
356
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'status-lambda-test-'));
|
|
357
|
+
try {
|
|
358
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
359
|
+
fs.writeFileSync(
|
|
360
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
361
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
|
|
362
|
+
);
|
|
363
|
+
const result = await runStatus({ cwd: dir, region: 'us-east-2', spawnSyncImpl: cliWith(ACTIVE) });
|
|
364
|
+
expect(result.healthy).toBe(true);
|
|
365
|
+
expect(result.computeTarget).toBe('lambda');
|
|
366
|
+
} finally {
|
|
367
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { pollUntil, parseTimeoutSeconds } from '../src/utils/system.js';
|
|
3
|
+
|
|
4
|
+
describe('parseTimeoutSeconds', () => {
|
|
5
|
+
it('returns the default when omitted or blank', () => {
|
|
6
|
+
expect(parseTimeoutSeconds(undefined, 600)).toBe(600);
|
|
7
|
+
expect(parseTimeoutSeconds(null, 900)).toBe(900);
|
|
8
|
+
expect(parseTimeoutSeconds('', 600)).toBe(600);
|
|
9
|
+
expect(parseTimeoutSeconds(' ', 600)).toBe(600);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('parses positive integers', () => {
|
|
13
|
+
expect(parseTimeoutSeconds('60', 600)).toBe(60);
|
|
14
|
+
expect(parseTimeoutSeconds(' 120 ', 600)).toBe(120);
|
|
15
|
+
expect(parseTimeoutSeconds(30, 600)).toBe(30);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it.each(['0', '-5', 'abc', '60s', '1.5', '0x10'])('rejects %s', (value) => {
|
|
19
|
+
expect(parseTimeoutSeconds(value, 600)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('pollUntil', () => {
|
|
24
|
+
it('resolves immediately when the first tick is done', async () => {
|
|
25
|
+
const sleepFn = vi.fn(() => Promise.resolve());
|
|
26
|
+
const outcome = await pollUntil({
|
|
27
|
+
intervalMs: 1000,
|
|
28
|
+
timeoutMs: 60000,
|
|
29
|
+
sleepFn,
|
|
30
|
+
nowFn: () => 0,
|
|
31
|
+
onTick: async () => ({ done: true, value: 'v' }),
|
|
32
|
+
});
|
|
33
|
+
expect(outcome).toEqual({ done: true, value: 'v' });
|
|
34
|
+
expect(sleepFn).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ticks until done, reporting elapsed time', async () => {
|
|
38
|
+
let now = 0;
|
|
39
|
+
const seen = [];
|
|
40
|
+
const outcome = await pollUntil({
|
|
41
|
+
intervalMs: 100,
|
|
42
|
+
timeoutMs: 10000,
|
|
43
|
+
sleepFn: async () => { now += 100; },
|
|
44
|
+
nowFn: () => now,
|
|
45
|
+
onTick: async ({ elapsedMs }) => {
|
|
46
|
+
seen.push(elapsedMs);
|
|
47
|
+
return seen.length >= 3 ? { done: true, value: 'late' } : { done: false };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(outcome).toEqual({ done: true, value: 'late' });
|
|
51
|
+
expect(seen).toEqual([0, 100, 200]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('times out without running the tick past the deadline', async () => {
|
|
55
|
+
let now = 0;
|
|
56
|
+
let ticks = 0;
|
|
57
|
+
const outcome = await pollUntil({
|
|
58
|
+
intervalMs: 100,
|
|
59
|
+
timeoutMs: 250,
|
|
60
|
+
sleepFn: async () => { now += 100; },
|
|
61
|
+
nowFn: () => now,
|
|
62
|
+
onTick: async () => {
|
|
63
|
+
ticks += 1;
|
|
64
|
+
return { done: false };
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
expect(outcome).toEqual({ timedOut: true });
|
|
68
|
+
expect(ticks).toBe(3);
|
|
69
|
+
});
|
|
70
|
+
});
|