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,185 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import zlib from 'node:zlib';
|
|
4
|
+
import {
|
|
5
|
+
buildPlaceholderImage,
|
|
6
|
+
ecrImageExists,
|
|
7
|
+
seedPlaceholderImage,
|
|
8
|
+
ensureLambdaSeedImage,
|
|
9
|
+
PLACEHOLDER_TAG,
|
|
10
|
+
} from '../src/utils/lambda-ecr.js';
|
|
11
|
+
|
|
12
|
+
function okRun(stdout = '{}') {
|
|
13
|
+
return vi.fn(() => ({ status: 0, stdout, stderr: '' }));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('lambda-ecr: placeholder image', () => {
|
|
17
|
+
it('builds a self-consistent schema-2 manifest with runtime digests', () => {
|
|
18
|
+
const image = buildPlaceholderImage();
|
|
19
|
+
const manifest = JSON.parse(image.manifestJson);
|
|
20
|
+
|
|
21
|
+
expect(manifest.schemaVersion).toBe(2);
|
|
22
|
+
expect(manifest.config.digest).toBe(image.configDigest);
|
|
23
|
+
expect(manifest.config.size).toBe(image.configBlob.length);
|
|
24
|
+
expect(manifest.layers).toHaveLength(1);
|
|
25
|
+
expect(manifest.layers[0].digest).toBe(image.layerDigest);
|
|
26
|
+
expect(manifest.layers[0].size).toBe(image.layerBlob.length);
|
|
27
|
+
|
|
28
|
+
// Digests recompute from the blobs (no memorized constants).
|
|
29
|
+
const configDigest = `sha256:${crypto.createHash('sha256').update(image.configBlob).digest('hex')}`;
|
|
30
|
+
const layerDigest = `sha256:${crypto.createHash('sha256').update(image.layerBlob).digest('hex')}`;
|
|
31
|
+
expect(image.configDigest).toBe(configDigest);
|
|
32
|
+
expect(image.layerDigest).toBe(layerDigest);
|
|
33
|
+
|
|
34
|
+
// The layer is a valid gzip of an empty tar; the config targets amd64/linux.
|
|
35
|
+
expect(zlib.gunzipSync(image.layerBlob).equals(Buffer.alloc(1024))).toBe(true);
|
|
36
|
+
const config = JSON.parse(image.configBlob.toString('utf8'));
|
|
37
|
+
expect(config.architecture).toBe('amd64');
|
|
38
|
+
expect(config.os).toBe('linux');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('defaults to the latest tag', () => {
|
|
42
|
+
expect(PLACEHOLDER_TAG).toBe('latest');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('lambda-ecr: ecrImageExists', () => {
|
|
47
|
+
const base = { repository: 'myapp-repo', region: 'us-east-2' };
|
|
48
|
+
|
|
49
|
+
it('returns exists=true when the tag is present', () => {
|
|
50
|
+
const run = okRun(JSON.stringify({ imageDetails: [{ imageTags: ['latest'] }] }));
|
|
51
|
+
expect(ecrImageExists({ run, ...base })).toEqual({ ok: true, exists: true });
|
|
52
|
+
expect(run).toHaveBeenCalledWith(
|
|
53
|
+
'aws',
|
|
54
|
+
expect.arrayContaining(['ecr', 'describe-images', '--repository-name', 'myapp-repo']),
|
|
55
|
+
expect.anything()
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns exists=false on ImageNotFound and on empty details', () => {
|
|
60
|
+
const missing = vi.fn(() => ({
|
|
61
|
+
status: 254,
|
|
62
|
+
stdout: '',
|
|
63
|
+
stderr: 'An error occurred (ImageNotFoundException) when calling the DescribeImages operation',
|
|
64
|
+
}));
|
|
65
|
+
expect(ecrImageExists({ run: missing, ...base })).toEqual({ ok: true, exists: false });
|
|
66
|
+
expect(ecrImageExists({ run: okRun('{"imageDetails": []}'), ...base })).toEqual({ ok: true, exists: false });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('surfaces cli-missing and describe failures instead of throwing', () => {
|
|
70
|
+
const noCli = vi.fn(() => ({ error: { code: 'ENOENT' } }));
|
|
71
|
+
expect(ecrImageExists({ run: noCli, ...base })).toEqual({ ok: false, reason: 'cli-missing' });
|
|
72
|
+
|
|
73
|
+
const denied = vi.fn(() => ({ status: 254, stdout: '', stderr: 'AccessDeniedException' }));
|
|
74
|
+
expect(ecrImageExists({ run: denied, ...base })).toMatchObject({ ok: false, reason: 'describe-failed' });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('lambda-ecr: seedPlaceholderImage', () => {
|
|
79
|
+
const base = { repository: 'myapp-repo', region: 'us-east-2' };
|
|
80
|
+
|
|
81
|
+
it('uploads both blobs before put-image and tags latest', () => {
|
|
82
|
+
const calls = [];
|
|
83
|
+
const run = vi.fn((cmd, args) => {
|
|
84
|
+
calls.push(args);
|
|
85
|
+
if (args[1] === 'initiate-layer-upload') return { status: 0, stdout: '{"uploadId": "upload-1"}', stderr: '' };
|
|
86
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(seedPlaceholderImage({ run, ...base })).toEqual({ ok: true, seeded: true });
|
|
90
|
+
|
|
91
|
+
const verbs = calls.map((args) => args[1]);
|
|
92
|
+
expect(verbs).toEqual([
|
|
93
|
+
'initiate-layer-upload', 'upload-layer-part', 'complete-layer-upload',
|
|
94
|
+
'initiate-layer-upload', 'upload-layer-part', 'complete-layer-upload',
|
|
95
|
+
'put-image',
|
|
96
|
+
]);
|
|
97
|
+
const put = calls[calls.length - 1];
|
|
98
|
+
expect(put).toContain('--image-tag');
|
|
99
|
+
expect(put[put.indexOf('--image-tag') + 1]).toBe('latest');
|
|
100
|
+
const manifest = JSON.parse(put[put.indexOf('--image-manifest') + 1]);
|
|
101
|
+
expect(manifest.schemaVersion).toBe(2);
|
|
102
|
+
expect(manifest.layers).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('stops at the first failed upload step', () => {
|
|
106
|
+
const run = vi.fn((cmd, args) => {
|
|
107
|
+
if (args[1] === 'initiate-layer-upload') return { status: 0, stdout: '{"uploadId": "u"}', stderr: '' };
|
|
108
|
+
if (args[1] === 'upload-layer-part') return { status: 1, stdout: '', stderr: 'boom' };
|
|
109
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
110
|
+
});
|
|
111
|
+
expect(seedPlaceholderImage({ run, ...base })).toMatchObject({ ok: false });
|
|
112
|
+
expect(run.mock.calls.some(([, args]) => args[1] === 'put-image')).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('passes repository, region, and fileb:// blob paths on every call', () => {
|
|
116
|
+
const calls = [];
|
|
117
|
+
const run = vi.fn((cmd, args) => {
|
|
118
|
+
calls.push(args);
|
|
119
|
+
if (args[1] === 'initiate-layer-upload') return { status: 0, stdout: '{"uploadId": "upload-1"}', stderr: '' };
|
|
120
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
121
|
+
});
|
|
122
|
+
expect(seedPlaceholderImage({ run, ...base })).toEqual({ ok: true, seeded: true });
|
|
123
|
+
|
|
124
|
+
expect(calls).toHaveLength(7);
|
|
125
|
+
for (const args of calls) {
|
|
126
|
+
expect(args[0]).toBe('ecr');
|
|
127
|
+
expect(args).toContain('--repository-name');
|
|
128
|
+
expect(args[args.indexOf('--repository-name') + 1]).toBe('myapp-repo');
|
|
129
|
+
expect(args).toContain('--region');
|
|
130
|
+
expect(args[args.indexOf('--region') + 1]).toBe('us-east-2');
|
|
131
|
+
}
|
|
132
|
+
const parts = calls.filter((args) => args[1] === 'upload-layer-part');
|
|
133
|
+
expect(parts).toHaveLength(2);
|
|
134
|
+
for (const args of parts) {
|
|
135
|
+
expect(args).not.toContain('--body');
|
|
136
|
+
const blob = args[args.indexOf('--layer-part-blob') + 1];
|
|
137
|
+
expect(blob.startsWith('fileb://')).toBe(true);
|
|
138
|
+
expect(args).toContain('--upload-id');
|
|
139
|
+
expect(args).toContain('--part-first-byte');
|
|
140
|
+
expect(args).toContain('--part-last-byte');
|
|
141
|
+
}
|
|
142
|
+
const completes = calls.filter((args) => args[1] === 'complete-layer-upload');
|
|
143
|
+
expect(completes).toHaveLength(2);
|
|
144
|
+
for (const args of completes) {
|
|
145
|
+
expect(args).toContain('--upload-id');
|
|
146
|
+
expect(args).toContain('--layer-digests');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('names the failing operation in seed errors', () => {
|
|
151
|
+
const run = vi.fn((cmd, args) => {
|
|
152
|
+
if (args[1] === 'initiate-layer-upload') return { status: 0, stdout: '{"uploadId": "u"}', stderr: '' };
|
|
153
|
+
if (args[1] === 'upload-layer-part') return { status: 1, stdout: '', stderr: 'boom' };
|
|
154
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
155
|
+
});
|
|
156
|
+
expect(seedPlaceholderImage({ run, ...base })).toEqual({
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: 'aws-failed',
|
|
159
|
+
operation: 'upload-layer-part',
|
|
160
|
+
detail: 'upload-layer-part: boom',
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('lambda-ecr: ensureLambdaSeedImage', () => {
|
|
166
|
+
const base = { repository: 'myapp-repo', region: 'us-east-2' };
|
|
167
|
+
|
|
168
|
+
it('skips seeding when the tag already exists', () => {
|
|
169
|
+
const run = okRun(JSON.stringify({ imageDetails: [{ imageTags: ['latest'] }] }));
|
|
170
|
+
expect(ensureLambdaSeedImage({ run, ...base })).toEqual({ ok: true, seeded: false });
|
|
171
|
+
expect(run).toHaveBeenCalledTimes(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('seeds when the tag is missing and propagates check failures', () => {
|
|
175
|
+
const run = vi.fn((cmd, args) => {
|
|
176
|
+
if (args[1] === 'describe-images') return { status: 0, stdout: '{"imageDetails": []}', stderr: '' };
|
|
177
|
+
if (args[1] === 'initiate-layer-upload') return { status: 0, stdout: '{"uploadId": "u"}', stderr: '' };
|
|
178
|
+
return { status: 0, stdout: '{}', stderr: '' };
|
|
179
|
+
});
|
|
180
|
+
expect(ensureLambdaSeedImage({ run, ...base })).toEqual({ ok: true, seeded: true });
|
|
181
|
+
|
|
182
|
+
const noCli = vi.fn(() => ({ error: { code: 'ENOENT' } }));
|
|
183
|
+
expect(ensureLambdaSeedImage({ run: noCli, ...base })).toEqual({ ok: false, reason: 'cli-missing' });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,447 @@
|
|
|
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 {
|
|
6
|
+
runLogs,
|
|
7
|
+
parseSinceDuration,
|
|
8
|
+
parseSince,
|
|
9
|
+
isErrorLine,
|
|
10
|
+
matchesErrorFilter,
|
|
11
|
+
formatLogLine,
|
|
12
|
+
formatLogEvent,
|
|
13
|
+
parseLogsArgs,
|
|
14
|
+
normalizeTailLines,
|
|
15
|
+
buildLogStreamName,
|
|
16
|
+
isNotFoundError,
|
|
17
|
+
DEFAULT_TAIL_LINES,
|
|
18
|
+
} from '../src/commands/logs.js';
|
|
19
|
+
import { resolveRegion, resolveLogGroup } from '../src/utils/resolvers.js';
|
|
20
|
+
import { trackEvent } from '../src/core/telemetry.js';
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
mockLogsSend,
|
|
24
|
+
MockLogsClient,
|
|
25
|
+
MockFilterLogEventsCommand,
|
|
26
|
+
MockDescribeLogStreamsCommand,
|
|
27
|
+
} = vi.hoisted(() => {
|
|
28
|
+
const send = vi.fn();
|
|
29
|
+
return {
|
|
30
|
+
mockLogsSend: send,
|
|
31
|
+
MockLogsClient: vi.fn(function () {
|
|
32
|
+
this.send = send;
|
|
33
|
+
}),
|
|
34
|
+
MockFilterLogEventsCommand: vi.fn(function (input) {
|
|
35
|
+
Object.assign(this, input);
|
|
36
|
+
}),
|
|
37
|
+
MockDescribeLogStreamsCommand: vi.fn(function (input) {
|
|
38
|
+
Object.assign(this, input);
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({
|
|
44
|
+
CloudWatchLogsClient: MockLogsClient,
|
|
45
|
+
FilterLogEventsCommand: MockFilterLogEventsCommand,
|
|
46
|
+
DescribeLogStreamsCommand: MockDescribeLogStreamsCommand,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock('../src/core/telemetry.js', () => {
|
|
50
|
+
const trackEvent = vi.fn();
|
|
51
|
+
const flushTelemetry = vi.fn(() => Promise.resolve());
|
|
52
|
+
// Mirrors the real trackSuccess delegation so success-path assertions
|
|
53
|
+
// keep observing trackEvent (the real helper is unit-tested separately).
|
|
54
|
+
const trackSuccess = vi.fn(async (event, properties) => {
|
|
55
|
+
trackEvent(event, { ...properties, success: true });
|
|
56
|
+
await flushTelemetry();
|
|
57
|
+
});
|
|
58
|
+
const trackFailure = vi.fn(async (event, properties) => {
|
|
59
|
+
trackEvent(event, { ...properties, success: false });
|
|
60
|
+
await flushTelemetry();
|
|
61
|
+
});
|
|
62
|
+
return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function makeEvents(count, prefix = 'log line') {
|
|
66
|
+
const base = Date.now() - count * 1000;
|
|
67
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
68
|
+
eventId: `event-${i}`,
|
|
69
|
+
timestamp: base + i * 1000,
|
|
70
|
+
message: `${prefix} ${i + 1}`,
|
|
71
|
+
logStreamName: `ecs-app/abc123${i}`,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('logs: region resolution precedence', () => {
|
|
76
|
+
const savedEnv = process.env.AWS_REGION;
|
|
77
|
+
let tmpDir;
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
delete process.env.AWS_REGION;
|
|
81
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logs-region-'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
if (savedEnv === undefined) delete process.env.AWS_REGION;
|
|
86
|
+
else process.env.AWS_REGION = savedEnv;
|
|
87
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function writeMainTf(region) {
|
|
91
|
+
fs.mkdirSync(path.join(tmpDir, 'terraform'), { recursive: true });
|
|
92
|
+
fs.writeFileSync(path.join(tmpDir, 'terraform', 'main.tf'), `provider "aws" {\n region = "${region}"\n}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('explicit --region flag wins over env and terraform', () => {
|
|
96
|
+
process.env.AWS_REGION = 'eu-west-1';
|
|
97
|
+
writeMainTf('ap-south-1');
|
|
98
|
+
expect(resolveRegion({ region: 'us-west-2' }, tmpDir)).toBe('us-west-2');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('AWS_REGION env wins over terraform/main.tf', () => {
|
|
102
|
+
process.env.AWS_REGION = 'eu-west-1';
|
|
103
|
+
writeMainTf('ap-south-1');
|
|
104
|
+
expect(resolveRegion({}, tmpDir)).toBe('eu-west-1');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('falls back to terraform/main.tf region', () => {
|
|
108
|
+
writeMainTf('ap-south-1');
|
|
109
|
+
expect(resolveRegion({}, tmpDir)).toBe('ap-south-1');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('falls back to us-east-2 when nothing is configured', () => {
|
|
113
|
+
expect(resolveRegion({}, tmpDir)).toBe('us-east-2');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('logs: --since parsing', () => {
|
|
118
|
+
it('parses minute/hour/day shorthands to milliseconds', () => {
|
|
119
|
+
expect(parseSinceDuration('5m')).toBe(5 * 60 * 1000);
|
|
120
|
+
expect(parseSinceDuration('1h')).toBe(60 * 60 * 1000);
|
|
121
|
+
expect(parseSinceDuration('1d')).toBe(24 * 60 * 60 * 1000);
|
|
122
|
+
expect(parseSinceDuration('30s')).toBe(30 * 1000);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('defaults to 1h for missing or invalid input', () => {
|
|
126
|
+
expect(parseSinceDuration(undefined)).toBe(60 * 60 * 1000);
|
|
127
|
+
expect(parseSince(undefined)).toBe(60 * 60 * 1000);
|
|
128
|
+
expect(parseSinceDuration('not-a-duration')).toBe(60 * 60 * 1000);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('logs: error filtering helpers', () => {
|
|
133
|
+
it('matches every documented failure keyword', () => {
|
|
134
|
+
for (const keyword of ['ERROR boom', 'FATAL exit', 'Value Exception raised', 'request fail ed', 'status 500', 'bad gateway 502']) {
|
|
135
|
+
expect(isErrorLine(keyword)).toBe(true);
|
|
136
|
+
expect(matchesErrorFilter(keyword)).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('leaves healthy lines alone', () => {
|
|
141
|
+
expect(isErrorLine('INFO request completed in 12ms')).toBe(false);
|
|
142
|
+
expect(isErrorLine('GET /health 200 OK')).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('formats lines with dimmed ISO timestamp and highlights errors', () => {
|
|
146
|
+
const line = formatLogLine({ timestamp: 1700000000000, message: 'ERROR disk full', logStreamName: 'ecs-app/task123' });
|
|
147
|
+
expect(formatLogEvent({ timestamp: 1700000000000, message: 'ok', logStreamName: 'ecs-app/task123' })).toContain('ok');
|
|
148
|
+
expect(line).toContain(new Date(1700000000000).toISOString());
|
|
149
|
+
expect(line).toContain('task123');
|
|
150
|
+
expect(line).toContain('ERROR disk full');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('clamps invalid tail values to the default', () => {
|
|
154
|
+
expect(normalizeTailLines(10)).toBe(10);
|
|
155
|
+
expect(normalizeTailLines('25')).toBe(25);
|
|
156
|
+
expect(normalizeTailLines(0)).toBe(DEFAULT_TAIL_LINES);
|
|
157
|
+
expect(normalizeTailLines('junk')).toBe(DEFAULT_TAIL_LINES);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('parses CLI args for service and flags', () => {
|
|
161
|
+
expect(parseLogsArgs(['logs', 'api', '--tail', '10', '--follow', '--error', '--since', '5m', '--region', 'eu-west-1'])).toEqual({
|
|
162
|
+
service: 'api',
|
|
163
|
+
tail: 10,
|
|
164
|
+
follow: true,
|
|
165
|
+
error: true,
|
|
166
|
+
since: '5m',
|
|
167
|
+
region: 'eu-west-1',
|
|
168
|
+
});
|
|
169
|
+
expect(parseLogsArgs(['logs', '-f', '--tail=5'])).toEqual({ follow: true, tail: 5 });
|
|
170
|
+
expect(parseLogsArgs(['logs'])).toEqual({});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('Command: logs (mocked CloudWatch Logs)', () => {
|
|
175
|
+
const savedEnv = process.env.AWS_REGION;
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
vi.clearAllMocks();
|
|
179
|
+
mockLogsSend.mockReset();
|
|
180
|
+
delete process.env.AWS_REGION;
|
|
181
|
+
// Log-group verification succeeds by default.
|
|
182
|
+
mockLogsSend.mockImplementation((cmd) => {
|
|
183
|
+
if (cmd instanceof MockDescribeLogStreamsCommand) return Promise.resolve({ logStreams: [] });
|
|
184
|
+
return Promise.resolve({ events: [], nextToken: undefined });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
afterEach(() => {
|
|
189
|
+
if (savedEnv === undefined) delete process.env.AWS_REGION;
|
|
190
|
+
else process.env.AWS_REGION = savedEnv;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('truncates output to the requested tail line count', async () => {
|
|
194
|
+
mockLogsSend.mockImplementation((cmd) => {
|
|
195
|
+
if (cmd instanceof MockDescribeLogStreamsCommand) return Promise.resolve({ logStreams: [] });
|
|
196
|
+
return Promise.resolve({ events: makeEvents(80) });
|
|
197
|
+
});
|
|
198
|
+
const output = [];
|
|
199
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
200
|
+
output.push(args.join(' '));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const result = await runLogs({ service: 'api', tail: 10, region: 'us-east-2' });
|
|
205
|
+
expect(result.logs).toHaveLength(10);
|
|
206
|
+
expect(result.logs[0]).toContain('log line 71');
|
|
207
|
+
expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(expect.objectContaining({ limit: 10 }));
|
|
208
|
+
expect(output).toHaveLength(10);
|
|
209
|
+
} finally {
|
|
210
|
+
consoleSpy.mockRestore();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('filters to failure keywords when --error is set', async () => {
|
|
215
|
+
const events = [
|
|
216
|
+
{ eventId: '1', timestamp: Date.now(), message: 'INFO all good', logStreamName: 'ecs-app/t1' },
|
|
217
|
+
{ eventId: '2', timestamp: Date.now(), message: 'ERROR disk full', logStreamName: 'ecs-app/t1' },
|
|
218
|
+
{ eventId: '3', timestamp: Date.now(), message: 'WARN slow query', logStreamName: 'ecs-app/t1' },
|
|
219
|
+
{ eventId: '4', timestamp: Date.now(), message: 'upstream returned 502', logStreamName: 'ecs-app/t1' },
|
|
220
|
+
];
|
|
221
|
+
mockLogsSend.mockImplementation((cmd) => {
|
|
222
|
+
if (cmd instanceof MockDescribeLogStreamsCommand) return Promise.resolve({ logStreams: [] });
|
|
223
|
+
return Promise.resolve({ events });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
227
|
+
try {
|
|
228
|
+
const result = await runLogs({ tail: 50, error: true, region: 'us-east-2' });
|
|
229
|
+
expect(result.logs).toHaveLength(2);
|
|
230
|
+
expect(result.logs.join('\n')).toContain('ERROR disk full');
|
|
231
|
+
expect(result.logs.join('\n')).toContain('502');
|
|
232
|
+
} finally {
|
|
233
|
+
consoleSpy.mockRestore();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('exits gracefully with guidance when the log group is missing', async () => {
|
|
238
|
+
const missing = new Error('The specified log group does not exist.');
|
|
239
|
+
missing.name = 'ResourceNotFoundException';
|
|
240
|
+
mockLogsSend.mockRejectedValue(missing);
|
|
241
|
+
|
|
242
|
+
const output = [];
|
|
243
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
244
|
+
output.push(args.join(' '));
|
|
245
|
+
});
|
|
246
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const result = await runLogs({ tail: 5, region: 'us-east-2' });
|
|
250
|
+
expect(result.logs).toEqual([]);
|
|
251
|
+
expect(output.join('\n')).toMatch(/No log group found/);
|
|
252
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
253
|
+
} finally {
|
|
254
|
+
consoleSpy.mockRestore();
|
|
255
|
+
exitSpy.mockRestore();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('prints SSO recovery guidance and exits 1 on expired sessions', async () => {
|
|
260
|
+
const expired = new Error('Token expired');
|
|
261
|
+
expired.name = 'ExpiredTokenException';
|
|
262
|
+
// Verification probe passes; the fetch itself expires.
|
|
263
|
+
mockLogsSend.mockImplementation((cmd) => {
|
|
264
|
+
if (cmd instanceof MockDescribeLogStreamsCommand) return Promise.resolve({ logStreams: [] });
|
|
265
|
+
return Promise.reject(expired);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const output = [];
|
|
269
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
270
|
+
output.push(args.join(' '));
|
|
271
|
+
});
|
|
272
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await runLogs({ tail: 5, region: 'us-east-2', spawnSyncImpl: () => ({ status: 0 }) });
|
|
276
|
+
expect(output.join('\n')).toContain('aws sso login');
|
|
277
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
278
|
+
} finally {
|
|
279
|
+
consoleSpy.mockRestore();
|
|
280
|
+
exitSpy.mockRestore();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('resolves the default log group from the project name', async () => {
|
|
285
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
286
|
+
try {
|
|
287
|
+
const result = await runLogs({ projectName: 'myapp', region: 'us-east-2' });
|
|
288
|
+
expect(result.logGroup).toBe('/ecs/myapp');
|
|
289
|
+
expect(resolveLogGroup({ logGroup: '/custom/group' })).toBe('/custom/group');
|
|
290
|
+
expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(
|
|
291
|
+
expect.objectContaining({ logGroupName: '/ecs/myapp' })
|
|
292
|
+
);
|
|
293
|
+
} finally {
|
|
294
|
+
consoleSpy.mockRestore();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('follow mode terminates after maxPolls without hanging', async () => {
|
|
299
|
+
let calls = 0;
|
|
300
|
+
mockLogsSend.mockImplementation((cmd) => {
|
|
301
|
+
if (cmd instanceof MockDescribeLogStreamsCommand) return Promise.resolve({ logStreams: [] });
|
|
302
|
+
calls += 1;
|
|
303
|
+
return Promise.resolve({ events: makeEvents(2, `poll-${calls}`) });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
307
|
+
try {
|
|
308
|
+
const result = await runLogs({ follow: true, maxPolls: 2, pollIntervalMs: 0, region: 'us-east-2' });
|
|
309
|
+
expect(calls).toBe(2);
|
|
310
|
+
expect(result.logs.length).toBeGreaterThan(0);
|
|
311
|
+
} finally {
|
|
312
|
+
consoleSpy.mockRestore();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('is wired into bin/cli.js', () => {
|
|
317
|
+
const cliPath = path.resolve(__dirname, '../bin/cli.js');
|
|
318
|
+
const content = fs.readFileSync(cliPath, 'utf8');
|
|
319
|
+
expect(content).toContain('runLogs');
|
|
320
|
+
expect(content).toContain("'logs'");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('prints a friendly waiting message when the log group exists but is empty', async () => {
|
|
324
|
+
const output = [];
|
|
325
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
326
|
+
output.push(args.join(' '));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// The beforeEach block already mocks an empty events array for us!
|
|
330
|
+
await runLogs({ tail: 10, region: 'us-east-2' });
|
|
331
|
+
|
|
332
|
+
const fullOutput = output.join('\n');
|
|
333
|
+
expect(fullOutput).toContain('Waiting for logs...');
|
|
334
|
+
expect(fullOutput).toContain('no application logs have been written yet');
|
|
335
|
+
|
|
336
|
+
consoleSpy.mockRestore();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('log stream helpers', () => {
|
|
341
|
+
it('buildLogStreamName mirrors the awslogs stream-prefix convention', () => {
|
|
342
|
+
expect(buildLogStreamName('myapp-container', 'abc123')).toBe('ecs/myapp-container/abc123');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('buildLogStreamName honors a custom stream prefix from the task definition', () => {
|
|
346
|
+
expect(buildLogStreamName('myapp-container', 'abc123', 'custom')).toBe('custom/myapp-container/abc123');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('isNotFoundError matches SDK names and missing-group messages', () => {
|
|
350
|
+
expect(isNotFoundError({ name: 'ResourceNotFoundException' })).toBe(true);
|
|
351
|
+
expect(isNotFoundError({ name: 'Other', message: 'The log group "/ecs/myapp" does not exist.' })).toBe(true);
|
|
352
|
+
expect(isNotFoundError({ name: 'AccessDenied', message: 'nope' })).toBe(false);
|
|
353
|
+
expect(isNotFoundError(null)).toBeFalsy();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('logs: fuzzer hardening', () => {
|
|
358
|
+
let exitSpy;
|
|
359
|
+
let consoleSpy;
|
|
360
|
+
|
|
361
|
+
beforeEach(() => {
|
|
362
|
+
vi.clearAllMocks();
|
|
363
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
364
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
afterEach(() => {
|
|
368
|
+
exitSpy.mockRestore();
|
|
369
|
+
consoleSpy.mockRestore();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
|
|
373
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
374
|
+
try {
|
|
375
|
+
const result = await runLogs(null);
|
|
376
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
377
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
378
|
+
expect(trackEvent).toHaveBeenCalledWith('logs_streamed', expect.objectContaining({
|
|
379
|
+
success: false,
|
|
380
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
381
|
+
}));
|
|
382
|
+
} finally {
|
|
383
|
+
cwdSpy.mockRestore();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it.each([null, 42, true, { port: 'string' }])('parseLogsArgs(%s) returns defaults', (bad) => {
|
|
388
|
+
expect(parseLogsArgs(bad)).toEqual({});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('Command: logs on --target lambda projects', () => {
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
vi.clearAllMocks();
|
|
395
|
+
mockLogsSend.mockReset();
|
|
396
|
+
mockLogsSend.mockImplementation((cmd) => {
|
|
397
|
+
if (cmd instanceof MockDescribeLogStreamsCommand) return Promise.resolve({ logStreams: [] });
|
|
398
|
+
return Promise.resolve({ events: [], nextToken: undefined });
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
function lambdaDir() {
|
|
403
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'logs-lambda-test-'));
|
|
404
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
405
|
+
fs.writeFileSync(
|
|
406
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
407
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
|
|
408
|
+
);
|
|
409
|
+
return dir;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
it('defaults to the function log group and skips the stream-prefix filter', async () => {
|
|
413
|
+
const dir = lambdaDir();
|
|
414
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
415
|
+
try {
|
|
416
|
+
const result = await runLogs({ cwd: dir, service: 'myapp', tail: 5, region: 'us-east-2' });
|
|
417
|
+
expect(result.logGroup).toBe('/aws/lambda/myapp-fn');
|
|
418
|
+
expect(MockFilterLogEventsCommand).toHaveBeenCalledWith(expect.objectContaining({
|
|
419
|
+
logGroupName: '/aws/lambda/myapp-fn',
|
|
420
|
+
}));
|
|
421
|
+
for (const call of MockFilterLogEventsCommand.mock.calls) {
|
|
422
|
+
expect(call[0]).not.toHaveProperty('logStreamNamePrefix');
|
|
423
|
+
}
|
|
424
|
+
} finally {
|
|
425
|
+
consoleSpy.mockRestore();
|
|
426
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('points missing-group guidance at the lambda prefix', async () => {
|
|
431
|
+
const dir = lambdaDir();
|
|
432
|
+
const missing = new Error('The specified log group does not exist.');
|
|
433
|
+
missing.name = 'ResourceNotFoundException';
|
|
434
|
+
mockLogsSend.mockRejectedValue(missing);
|
|
435
|
+
const output = [];
|
|
436
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
437
|
+
output.push(args.join(' '));
|
|
438
|
+
});
|
|
439
|
+
try {
|
|
440
|
+
await runLogs({ cwd: dir, tail: 5, region: 'us-east-2' });
|
|
441
|
+
expect(output.join('\n')).toContain('--log-group-name-prefix "/aws/lambda/"');
|
|
442
|
+
} finally {
|
|
443
|
+
consoleSpy.mockRestore();
|
|
444
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|