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
package/tests/gc.test.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
runGc,
|
|
4
|
+
parseGcArgs,
|
|
5
|
+
filterUntaggedImages,
|
|
6
|
+
filterUnattachedAddresses,
|
|
7
|
+
discoverOrphanedResources,
|
|
8
|
+
deleteDiscoveredResources,
|
|
9
|
+
chunkArray,
|
|
10
|
+
ECR_BATCH_DELETE_LIMIT,
|
|
11
|
+
CONFIRM_MESSAGE,
|
|
12
|
+
} from '../src/commands/gc.js';
|
|
13
|
+
import { trackEvent } from '../src/core/telemetry.js';
|
|
14
|
+
|
|
15
|
+
// --- Mock AWS SDK: ECR ---
|
|
16
|
+
const { mockEcrSend, MockECRClient, MockDescribeRepositoriesCommand, MockDescribeImagesCommand, MockBatchDeleteImageCommand } = vi.hoisted(() => {
|
|
17
|
+
const send = vi.fn();
|
|
18
|
+
const makeCommand = (name) =>
|
|
19
|
+
vi.fn(function (input) {
|
|
20
|
+
this.commandName = name;
|
|
21
|
+
Object.assign(this, input);
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
mockEcrSend: send,
|
|
25
|
+
MockECRClient: vi.fn(function () {
|
|
26
|
+
this.send = send;
|
|
27
|
+
}),
|
|
28
|
+
MockDescribeRepositoriesCommand: makeCommand('DescribeRepositoriesCommand'),
|
|
29
|
+
MockDescribeImagesCommand: makeCommand('DescribeImagesCommand'),
|
|
30
|
+
MockBatchDeleteImageCommand: makeCommand('BatchDeleteImageCommand'),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
vi.mock('@aws-sdk/client-ecr', () => ({
|
|
35
|
+
ECRClient: MockECRClient,
|
|
36
|
+
DescribeRepositoriesCommand: MockDescribeRepositoriesCommand,
|
|
37
|
+
DescribeImagesCommand: MockDescribeImagesCommand,
|
|
38
|
+
BatchDeleteImageCommand: MockBatchDeleteImageCommand,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// --- Mock AWS SDK: CloudWatch Logs ---
|
|
42
|
+
const { mockLogsSend, MockLogsClient, MockDescribeLogGroupsCommand, MockDeleteLogGroupCommand } = vi.hoisted(() => {
|
|
43
|
+
const send = vi.fn();
|
|
44
|
+
const makeCommand = (name) =>
|
|
45
|
+
vi.fn(function (input) {
|
|
46
|
+
this.commandName = name;
|
|
47
|
+
Object.assign(this, input);
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
mockLogsSend: send,
|
|
51
|
+
MockLogsClient: vi.fn(function () {
|
|
52
|
+
this.send = send;
|
|
53
|
+
}),
|
|
54
|
+
MockDescribeLogGroupsCommand: makeCommand('DescribeLogGroupsCommand'),
|
|
55
|
+
MockDeleteLogGroupCommand: makeCommand('DeleteLogGroupCommand'),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({
|
|
60
|
+
CloudWatchLogsClient: MockLogsClient,
|
|
61
|
+
DescribeLogGroupsCommand: MockDescribeLogGroupsCommand,
|
|
62
|
+
DeleteLogGroupCommand: MockDeleteLogGroupCommand,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// --- Mock AWS SDK: EC2 ---
|
|
66
|
+
const { mockEc2Send, MockEC2Client, MockDescribeAddressesCommand, MockReleaseAddressCommand } = vi.hoisted(() => {
|
|
67
|
+
const send = vi.fn();
|
|
68
|
+
const makeCommand = (name) =>
|
|
69
|
+
vi.fn(function (input) {
|
|
70
|
+
this.commandName = name;
|
|
71
|
+
Object.assign(this, input);
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
mockEc2Send: send,
|
|
75
|
+
MockEC2Client: vi.fn(function () {
|
|
76
|
+
this.send = send;
|
|
77
|
+
}),
|
|
78
|
+
MockDescribeAddressesCommand: makeCommand('DescribeAddressesCommand'),
|
|
79
|
+
MockReleaseAddressCommand: makeCommand('ReleaseAddressCommand'),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
vi.mock('@aws-sdk/client-ec2', () => ({
|
|
84
|
+
EC2Client: MockEC2Client,
|
|
85
|
+
DescribeAddressesCommand: MockDescribeAddressesCommand,
|
|
86
|
+
ReleaseAddressCommand: MockReleaseAddressCommand,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
// --- Mock interactive prompts (controllable confirm) ---
|
|
90
|
+
const clack = vi.hoisted(() => ({
|
|
91
|
+
mockIntro: vi.fn(),
|
|
92
|
+
mockOutro: vi.fn(),
|
|
93
|
+
mockConfirm: vi.fn(),
|
|
94
|
+
mockCancel: vi.fn(),
|
|
95
|
+
mockSpinnerStart: vi.fn(),
|
|
96
|
+
mockSpinnerStop: vi.fn(),
|
|
97
|
+
mockSpinnerMessage: vi.fn(),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
vi.mock('@clack/prompts', () => ({
|
|
101
|
+
intro: clack.mockIntro,
|
|
102
|
+
outro: clack.mockOutro,
|
|
103
|
+
confirm: clack.mockConfirm,
|
|
104
|
+
cancel: clack.mockCancel,
|
|
105
|
+
spinner: vi.fn(() => ({
|
|
106
|
+
start: clack.mockSpinnerStart,
|
|
107
|
+
stop: clack.mockSpinnerStop,
|
|
108
|
+
message: clack.mockSpinnerMessage,
|
|
109
|
+
})),
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
vi.mock('../src/core/telemetry.js', () => {
|
|
113
|
+
const trackEvent = vi.fn();
|
|
114
|
+
const flushTelemetry = vi.fn(() => Promise.resolve());
|
|
115
|
+
// Mirrors the real trackSuccess delegation so success-path assertions
|
|
116
|
+
// keep observing trackEvent (the real helper is unit-tested separately).
|
|
117
|
+
const trackSuccess = vi.fn(async (event, properties) => {
|
|
118
|
+
trackEvent(event, { ...properties, success: true });
|
|
119
|
+
await flushTelemetry();
|
|
120
|
+
});
|
|
121
|
+
const trackFailure = vi.fn(async (event, properties) => {
|
|
122
|
+
trackEvent(event, { ...properties, success: false });
|
|
123
|
+
await flushTelemetry();
|
|
124
|
+
});
|
|
125
|
+
return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const PROJECT = 'myapp';
|
|
129
|
+
|
|
130
|
+
function setupMixedDiscoveryFixtures() {
|
|
131
|
+
mockEcrSend.mockImplementation(async (cmd) => {
|
|
132
|
+
if (cmd instanceof MockDescribeRepositoriesCommand) {
|
|
133
|
+
return {
|
|
134
|
+
repositories: [
|
|
135
|
+
{ repositoryName: `${PROJECT}-web` },
|
|
136
|
+
{ repositoryName: `${PROJECT}-worker` },
|
|
137
|
+
{ repositoryName: 'unrelated-repo' },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (cmd instanceof MockDescribeImagesCommand) {
|
|
142
|
+
if (cmd.repositoryName === `${PROJECT}-web`) {
|
|
143
|
+
return {
|
|
144
|
+
imageDetails: [
|
|
145
|
+
{ imageDigest: 'sha256:tagged', imageTags: ['latest'] },
|
|
146
|
+
{ imageDigest: 'sha256:orphan1' },
|
|
147
|
+
{ imageDigest: 'sha256:orphan2', imageTags: [] },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { imageDetails: [{ imageDigest: 'sha256:worker-tagged', imageTags: ['v1'] }] };
|
|
152
|
+
}
|
|
153
|
+
if (cmd instanceof MockBatchDeleteImageCommand) return { imageIds: cmd.imageIds };
|
|
154
|
+
throw new Error(`Unexpected ECR command: ${cmd?.commandName}`);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
mockLogsSend.mockImplementation(async (cmd) => {
|
|
158
|
+
if (cmd instanceof MockDescribeLogGroupsCommand) {
|
|
159
|
+
expect(cmd.logGroupNamePrefix).toBe(`/ecs/${PROJECT}-`);
|
|
160
|
+
return {
|
|
161
|
+
logGroups: [
|
|
162
|
+
{ logGroupName: `/ecs/${PROJECT}-pr-123` },
|
|
163
|
+
{ logGroupName: `/ecs/${PROJECT}-pr-456` },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (cmd instanceof MockDeleteLogGroupCommand) return {};
|
|
168
|
+
throw new Error(`Unexpected Logs command: ${cmd?.commandName}`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
mockEc2Send.mockImplementation(async (cmd) => {
|
|
172
|
+
if (cmd instanceof MockDescribeAddressesCommand) {
|
|
173
|
+
return {
|
|
174
|
+
Addresses: [
|
|
175
|
+
{ PublicIp: '1.2.3.4', AllocationId: 'eipalloc-attached', AssociationId: 'eipassoc-1', InstanceId: 'i-123' },
|
|
176
|
+
{ PublicIp: '5.6.7.8', AllocationId: 'eipalloc-orphan' },
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (cmd instanceof MockReleaseAddressCommand) return {};
|
|
181
|
+
throw new Error(`Unexpected EC2 command: ${cmd?.commandName}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function deleteCommandCalls() {
|
|
186
|
+
const batchDeletes = MockBatchDeleteImageCommand.mock.calls.length;
|
|
187
|
+
const logDeletes = MockDeleteLogGroupCommand.mock.calls.length;
|
|
188
|
+
const releases = MockReleaseAddressCommand.mock.calls.length;
|
|
189
|
+
return batchDeletes + logDeletes + releases;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
vi.clearAllMocks();
|
|
194
|
+
setupMixedDiscoveryFixtures();
|
|
195
|
+
delete process.env.AWS_REGION;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
vi.clearAllMocks();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('gc: CLI args', () => {
|
|
203
|
+
it('parses --region and --project-name', () => {
|
|
204
|
+
expect(parseGcArgs(['gc', '--region', 'eu-west-1'])).toEqual({ region: 'eu-west-1' });
|
|
205
|
+
expect(parseGcArgs(['gc', '--region=us-west-2'])).toEqual({ region: 'us-west-2' });
|
|
206
|
+
expect(parseGcArgs(['gc'])).toEqual({});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('offers no --yes bypass flag', () => {
|
|
210
|
+
expect(parseGcArgs(['gc', '--yes'])).toEqual({});
|
|
211
|
+
expect(parseGcArgs(['gc', '--yes', '--region', 'us-east-1'])).toEqual({ region: 'us-east-1' });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('uses the spec confirmation wording', () => {
|
|
215
|
+
expect(CONFIRM_MESSAGE).toContain('Are you sure you want to permanently delete these orphaned resources? (y/N)');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('gc: orphan filters', () => {
|
|
220
|
+
it('counts only untagged ECR images', () => {
|
|
221
|
+
const images = [
|
|
222
|
+
{ imageDigest: 'sha256:a', imageTags: ['latest'] },
|
|
223
|
+
{ imageDigest: 'sha256:b' },
|
|
224
|
+
{ imageDigest: 'sha256:c', imageTags: [] },
|
|
225
|
+
];
|
|
226
|
+
expect(filterUntaggedImages(images).map((i) => i.imageDigest)).toEqual(['sha256:b', 'sha256:c']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('counts only unattached Elastic IPs', () => {
|
|
230
|
+
const addresses = [
|
|
231
|
+
{ PublicIp: '1.2.3.4', AssociationId: 'eipassoc-1' },
|
|
232
|
+
{ PublicIp: '5.6.7.8', AllocationId: 'eipalloc-2' },
|
|
233
|
+
];
|
|
234
|
+
expect(filterUnattachedAddresses(addresses)).toHaveLength(1);
|
|
235
|
+
expect(filterUnattachedAddresses(addresses)[0].PublicIp).toBe('5.6.7.8');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('gc: dry-run discovery', () => {
|
|
240
|
+
it('counts only orphaned resources across all three targets', async () => {
|
|
241
|
+
clack.mockConfirm.mockResolvedValue(false);
|
|
242
|
+
const output = [];
|
|
243
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
244
|
+
output.push(args.join(' '));
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
const result = await runGc({ projectName: PROJECT, region: 'us-east-2' });
|
|
248
|
+
expect(result.untaggedImages).toHaveLength(2);
|
|
249
|
+
expect(result.orphanedLogGroups).toHaveLength(2);
|
|
250
|
+
expect(result.unattachedEips).toHaveLength(1);
|
|
251
|
+
expect(result.totalCount).toBe(5);
|
|
252
|
+
expect(result.deleted).toBe(false);
|
|
253
|
+
const text = output.join('\n').replace(/\x1B\[[0-9;]*m/g, '');
|
|
254
|
+
expect(text).toContain('Untagged ECR images: 2');
|
|
255
|
+
expect(text).toContain('Orphaned log groups: 2');
|
|
256
|
+
expect(text).toContain('Unattached Elastic IPs: 1');
|
|
257
|
+
} finally {
|
|
258
|
+
consoleSpy.mockRestore();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('ignores repos outside the project prefix', async () => {
|
|
263
|
+
const ecrClient = { send: mockEcrSend };
|
|
264
|
+
const logsClient = { send: mockLogsSend };
|
|
265
|
+
const ec2Client = { send: mockEc2Send };
|
|
266
|
+
const result = await discoverOrphanedResources({ ecrClient, logsClient, ec2Client, projectName: PROJECT });
|
|
267
|
+
expect(result.untaggedImages.every((i) => i.repositoryName.startsWith(`${PROJECT}-`))).toBe(true);
|
|
268
|
+
expect(result.untaggedImages).toHaveLength(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('follows nextToken through paginated ECR responses', async () => {
|
|
272
|
+
const ecrSend = vi.fn(async (cmd) => {
|
|
273
|
+
if (cmd instanceof MockDescribeRepositoriesCommand) {
|
|
274
|
+
if (cmd.nextToken === undefined) {
|
|
275
|
+
return { repositories: [{ repositoryName: `${PROJECT}-web` }], nextToken: 'repos-page-2' };
|
|
276
|
+
}
|
|
277
|
+
return { repositories: [{ repositoryName: `${PROJECT}-worker` }] };
|
|
278
|
+
}
|
|
279
|
+
if (cmd instanceof MockDescribeImagesCommand) {
|
|
280
|
+
if (cmd.nextToken === undefined) {
|
|
281
|
+
return { imageDetails: [{ imageDigest: 'sha256:page1-orphan' }], nextToken: 'images-page-2' };
|
|
282
|
+
}
|
|
283
|
+
return { imageDetails: [{ imageDigest: 'sha256:page2-orphan' }] };
|
|
284
|
+
}
|
|
285
|
+
throw new Error('unexpected');
|
|
286
|
+
});
|
|
287
|
+
const result = await discoverOrphanedResources({
|
|
288
|
+
ecrClient: { send: ecrSend },
|
|
289
|
+
logsClient: { send: mockLogsSend },
|
|
290
|
+
ec2Client: { send: mockEc2Send },
|
|
291
|
+
projectName: PROJECT,
|
|
292
|
+
});
|
|
293
|
+
expect(result.untaggedImages).toHaveLength(4);
|
|
294
|
+
const repoCalls = ecrSend.mock.calls.filter((call) => call[0] instanceof MockDescribeRepositoriesCommand);
|
|
295
|
+
expect(repoCalls).toHaveLength(2);
|
|
296
|
+
const imageCalls = ecrSend.mock.calls.filter((call) => call[0] instanceof MockDescribeImagesCommand);
|
|
297
|
+
expect(imageCalls).toHaveLength(4);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('gc: safety guards', () => {
|
|
302
|
+
it('never fires Delete commands when confirmation is rejected', async () => {
|
|
303
|
+
clack.mockConfirm.mockResolvedValue(false);
|
|
304
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
305
|
+
try {
|
|
306
|
+
const result = await runGc({ projectName: PROJECT, region: 'us-east-2' });
|
|
307
|
+
expect(clack.mockConfirm).toHaveBeenCalledTimes(1);
|
|
308
|
+
expect(result.deleted).toBe(false);
|
|
309
|
+
expect(deleteCommandCalls()).toBe(0);
|
|
310
|
+
expect(clack.mockCancel).toHaveBeenCalled();
|
|
311
|
+
} finally {
|
|
312
|
+
consoleSpy.mockRestore();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('treats a cancelled prompt as rejection', async () => {
|
|
317
|
+
clack.mockConfirm.mockResolvedValue(Symbol.for('clack:cancel'));
|
|
318
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
319
|
+
try {
|
|
320
|
+
const result = await runGc({ projectName: PROJECT, region: 'us-east-2' });
|
|
321
|
+
expect(result.deleted).toBe(false);
|
|
322
|
+
expect(deleteCommandCalls()).toBe(0);
|
|
323
|
+
} finally {
|
|
324
|
+
consoleSpy.mockRestore();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('deletes all discovered resources only after explicit confirmation', async () => {
|
|
329
|
+
clack.mockConfirm.mockResolvedValue(true);
|
|
330
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
331
|
+
try {
|
|
332
|
+
const result = await runGc({ projectName: PROJECT, region: 'us-east-2' });
|
|
333
|
+
expect(result.deleted).toBe(true);
|
|
334
|
+
expect(result.deletedImages).toBe(2);
|
|
335
|
+
expect(result.deletedLogGroups).toBe(2);
|
|
336
|
+
expect(result.releasedEips).toBe(1);
|
|
337
|
+
expect(MockBatchDeleteImageCommand).toHaveBeenCalledWith(
|
|
338
|
+
expect.objectContaining({ repositoryName: `${PROJECT}-web` })
|
|
339
|
+
);
|
|
340
|
+
expect(MockDeleteLogGroupCommand).toHaveBeenCalledWith(
|
|
341
|
+
expect.objectContaining({ logGroupName: `/ecs/${PROJECT}-pr-123` })
|
|
342
|
+
);
|
|
343
|
+
expect(MockReleaseAddressCommand).toHaveBeenCalledWith(
|
|
344
|
+
expect.objectContaining({ AllocationId: 'eipalloc-orphan' })
|
|
345
|
+
);
|
|
346
|
+
// The attached EIP must never be released.
|
|
347
|
+
const releasedIds = MockReleaseAddressCommand.mock.calls.map((c) => c[0].AllocationId);
|
|
348
|
+
expect(releasedIds).not.toContain('eipalloc-attached');
|
|
349
|
+
} finally {
|
|
350
|
+
consoleSpy.mockRestore();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('reports cleanly when nothing is orphaned without prompting', async () => {
|
|
355
|
+
mockEcrSend.mockImplementation(async (cmd) => {
|
|
356
|
+
if (cmd instanceof MockDescribeRepositoriesCommand) return { repositories: [] };
|
|
357
|
+
throw new Error('unexpected');
|
|
358
|
+
});
|
|
359
|
+
mockLogsSend.mockImplementation(async (cmd) => {
|
|
360
|
+
if (cmd instanceof MockDescribeLogGroupsCommand) return { logGroups: [] };
|
|
361
|
+
throw new Error('unexpected');
|
|
362
|
+
});
|
|
363
|
+
mockEc2Send.mockImplementation(async (cmd) => {
|
|
364
|
+
if (cmd instanceof MockDescribeAddressesCommand) return { Addresses: [] };
|
|
365
|
+
throw new Error('unexpected');
|
|
366
|
+
});
|
|
367
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
368
|
+
try {
|
|
369
|
+
const result = await runGc({ projectName: PROJECT, region: 'us-east-2' });
|
|
370
|
+
expect(result.totalCount).toBe(0);
|
|
371
|
+
expect(result.deleted).toBe(false);
|
|
372
|
+
expect(clack.mockConfirm).not.toHaveBeenCalled();
|
|
373
|
+
expect(deleteCommandCalls()).toBe(0);
|
|
374
|
+
} finally {
|
|
375
|
+
consoleSpy.mockRestore();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('gc: ECR batch-delete chunking', () => {
|
|
381
|
+
it('exposes the 100-image AWS limit', () => {
|
|
382
|
+
expect(ECR_BATCH_DELETE_LIMIT).toBe(100);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('chunks arrays into batches of at most 100', () => {
|
|
386
|
+
expect(chunkArray([1, 2, 3], 2)).toEqual([[1, 2], [3]]);
|
|
387
|
+
expect(chunkArray([], 100)).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('splits more than 100 untagged images into multiple BatchDelete requests', async () => {
|
|
391
|
+
const untaggedImages = Array.from({ length: 250 }, (_, i) => ({
|
|
392
|
+
repositoryName: `${PROJECT}-web`,
|
|
393
|
+
imageDigest: `sha256:orphan-${i}`,
|
|
394
|
+
}));
|
|
395
|
+
const ecrSend = vi.fn().mockResolvedValue({});
|
|
396
|
+
const logsSend = vi.fn().mockResolvedValue({});
|
|
397
|
+
const ec2Send = vi.fn().mockResolvedValue({});
|
|
398
|
+
|
|
399
|
+
const summary = await deleteDiscoveredResources(
|
|
400
|
+
{ ecrClient: { send: ecrSend }, logsClient: { send: logsSend }, ec2Client: { send: ec2Send } },
|
|
401
|
+
{ untaggedImages, orphanedLogGroups: [], unattachedEips: [] }
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
expect(summary.deletedImages).toBe(250);
|
|
405
|
+
expect(ecrSend).toHaveBeenCalledTimes(3);
|
|
406
|
+
const batchSizes = ecrSend.mock.calls.map((call) => call[0].imageIds.length);
|
|
407
|
+
expect(batchSizes).toEqual([100, 100, 50]);
|
|
408
|
+
for (const call of ecrSend.mock.calls) {
|
|
409
|
+
expect(call[0].imageIds.length).toBeLessThanOrEqual(100);
|
|
410
|
+
expect(call[0].repositoryName).toBe(`${PROJECT}-web`);
|
|
411
|
+
}
|
|
412
|
+
const sentDigests = ecrSend.mock.calls.flatMap((call) => call[0].imageIds.map((id) => id.imageDigest));
|
|
413
|
+
expect(sentDigests).toHaveLength(250);
|
|
414
|
+
expect(new Set(sentDigests).size).toBe(250);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('sends exactly one request for 100 images', async () => {
|
|
418
|
+
const untaggedImages = Array.from({ length: 100 }, (_, i) => ({
|
|
419
|
+
repositoryName: `${PROJECT}-web`,
|
|
420
|
+
imageDigest: `sha256:orphan-${i}`,
|
|
421
|
+
}));
|
|
422
|
+
const ecrSend = vi.fn().mockResolvedValue({});
|
|
423
|
+
|
|
424
|
+
const summary = await deleteDiscoveredResources(
|
|
425
|
+
{ ecrClient: { send: ecrSend }, logsClient: { send: vi.fn() }, ec2Client: { send: vi.fn() } },
|
|
426
|
+
{ untaggedImages, orphanedLogGroups: [], unattachedEips: [] }
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
expect(summary.deletedImages).toBe(100);
|
|
430
|
+
expect(ecrSend).toHaveBeenCalledTimes(1);
|
|
431
|
+
expect(ecrSend.mock.calls[0][0].imageIds).toHaveLength(100);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe('gc: fuzzer hardening', () => {
|
|
436
|
+
let exitSpy;
|
|
437
|
+
let consoleSpy;
|
|
438
|
+
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
vi.clearAllMocks();
|
|
441
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
442
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
afterEach(() => {
|
|
446
|
+
exitSpy.mockRestore();
|
|
447
|
+
consoleSpy.mockRestore();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
|
|
451
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
452
|
+
try {
|
|
453
|
+
const result = await runGc(null);
|
|
454
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
455
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
456
|
+
expect(trackEvent).toHaveBeenCalledWith('gc_run', expect.objectContaining({
|
|
457
|
+
success: false,
|
|
458
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
459
|
+
}));
|
|
460
|
+
} finally {
|
|
461
|
+
cwdSpy.mockRestore();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it.each([null, 42, true, { port: 'string' }])('parseGcArgs(%s) returns defaults', (bad) => {
|
|
466
|
+
expect(parseGcArgs(bad)).toEqual({});
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('gc: lambda log group discovery', () => {
|
|
471
|
+
it('scans the lambda prefix and never proposes the live function group', async () => {
|
|
472
|
+
const seenPrefixes = [];
|
|
473
|
+
const logsSend = vi.fn(async (cmd) => {
|
|
474
|
+
if (cmd instanceof MockDescribeLogGroupsCommand) {
|
|
475
|
+
seenPrefixes.push(cmd.logGroupNamePrefix);
|
|
476
|
+
return {
|
|
477
|
+
logGroups: [
|
|
478
|
+
{ logGroupName: `/aws/lambda/${PROJECT}-fn` },
|
|
479
|
+
{ logGroupName: `/aws/lambda/${PROJECT}-pr-123-fn` },
|
|
480
|
+
],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
throw new Error('unexpected');
|
|
484
|
+
});
|
|
485
|
+
const result = await discoverOrphanedResources({
|
|
486
|
+
ecrClient: { send: mockEcrSend },
|
|
487
|
+
logsClient: { send: logsSend },
|
|
488
|
+
ec2Client: { send: mockEc2Send },
|
|
489
|
+
projectName: PROJECT,
|
|
490
|
+
logGroupPrefix: `/aws/lambda/${PROJECT}-`,
|
|
491
|
+
excludeLogGroup: `/aws/lambda/${PROJECT}-fn`,
|
|
492
|
+
});
|
|
493
|
+
expect(seenPrefixes).toEqual([`/aws/lambda/${PROJECT}-`]);
|
|
494
|
+
expect(result.orphanedLogGroups.map((g) => g.logGroupName)).toEqual([`/aws/lambda/${PROJECT}-pr-123-fn`]);
|
|
495
|
+
});
|
|
496
|
+
});
|