grada-run 0.0.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- package/index.js +0 -2
|
@@ -0,0 +1,2018 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// NOTE: helper imports must stay above src imports: vi.mock factories run
|
|
3
|
+
// during module evaluation and need the factories initialized.
|
|
4
|
+
import { clackPromptsMockFactory } from './helpers/clack.js';
|
|
5
|
+
import { telemetryMockFactory } from './helpers/telemetry.js';
|
|
6
|
+
import { createTmpDirTracker } from './helpers/tmpdir.js';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
11
|
+
import {
|
|
12
|
+
runAdd,
|
|
13
|
+
parseAddArgs,
|
|
14
|
+
validateAddonFlags,
|
|
15
|
+
resolveAddonOptions,
|
|
16
|
+
sanitizeCronName,
|
|
17
|
+
injectContainerEnvVars,
|
|
18
|
+
injectLambdaEnvVars,
|
|
19
|
+
ensureLambdaVpcConfig,
|
|
20
|
+
ensureWorkerDesiredCountLifecycle,
|
|
21
|
+
formatCatalogListing,
|
|
22
|
+
ADDON_REGISTRY,
|
|
23
|
+
DEFAULT_PARTITION_KEY,
|
|
24
|
+
DEFAULT_BEDROCK_MODEL,
|
|
25
|
+
CUSTOM_MODEL_VALUE,
|
|
26
|
+
DEFAULT_CRON_SCHEDULE,
|
|
27
|
+
DEFAULT_CRON_COMMAND,
|
|
28
|
+
} from '../src/commands/add.js';
|
|
29
|
+
import {
|
|
30
|
+
FALLBACK_BEDROCK_MODEL,
|
|
31
|
+
FALLBACK_CATALOG,
|
|
32
|
+
GENERIC_MODEL_HINT,
|
|
33
|
+
cleanDisplayName,
|
|
34
|
+
dedupeProviderModels,
|
|
35
|
+
findCatalogEntry,
|
|
36
|
+
generateHintForModel,
|
|
37
|
+
isValidCatalog,
|
|
38
|
+
loadBedrockCatalog,
|
|
39
|
+
normalizeProviderName,
|
|
40
|
+
parseModelScope,
|
|
41
|
+
refreshBedrockCatalog,
|
|
42
|
+
resolveModelIdForRegion,
|
|
43
|
+
validateCatalogHints,
|
|
44
|
+
} from '../src/utils/bedrock-catalog.js';
|
|
45
|
+
import { syncDocCostEstimate, estimateMonthlyCost, parseTerraformConfig, COST_ESTIMATE_MARKER, LEGACY_COST_ESTIMATE_MARKER } from '../src/utils/visualizer.js';
|
|
46
|
+
import { select, text, log } from '@clack/prompts';
|
|
47
|
+
import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
|
|
48
|
+
|
|
49
|
+
const S3_ENV = [
|
|
50
|
+
{ name: 'S3_BUCKET_NAME', value: '${aws_s3_bucket.storage.id}' },
|
|
51
|
+
{ name: 'S3_CDN_URL', value: 'https://${aws_cloudfront_distribution.storage_cdn.domain_name}' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
vi.mock('@clack/prompts', () => clackPromptsMockFactory());
|
|
55
|
+
|
|
56
|
+
vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
|
|
57
|
+
|
|
58
|
+
const tmp = createTmpDirTracker();
|
|
59
|
+
let exitSpy;
|
|
60
|
+
|
|
61
|
+
function makeTmp() {
|
|
62
|
+
return tmp.makeTmp('add-test-');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeWorkerTf(dir, { lifecycle = false } = {}) {
|
|
66
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
path.join(dir, 'terraform', 'worker.tf'),
|
|
69
|
+
[
|
|
70
|
+
'resource "aws_ecs_task_definition" "worker" {',
|
|
71
|
+
' family = "myapp-worker-task"',
|
|
72
|
+
' container_definitions = jsonencode([',
|
|
73
|
+
' {',
|
|
74
|
+
' name = "myapp-worker-container"',
|
|
75
|
+
' essential = true',
|
|
76
|
+
'',
|
|
77
|
+
' environment = [',
|
|
78
|
+
' { "name": "NODE_ENV", "value": "production" }',
|
|
79
|
+
' ]',
|
|
80
|
+
' }',
|
|
81
|
+
' ])',
|
|
82
|
+
'}',
|
|
83
|
+
'',
|
|
84
|
+
'resource "aws_ecs_service" "worker" {',
|
|
85
|
+
' name = "myapp-worker-service"',
|
|
86
|
+
' cluster = aws_ecs_cluster.main.id',
|
|
87
|
+
' task_definition = aws_ecs_task_definition.worker.arn',
|
|
88
|
+
' desired_count = 1',
|
|
89
|
+
...(lifecycle ? [' lifecycle {', ' ignore_changes = [desired_count]', ' }'] : []),
|
|
90
|
+
'}',
|
|
91
|
+
'',
|
|
92
|
+
].join('\n')
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeMainTf(dir, environment = '') {
|
|
97
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
path.join(dir, 'terraform', 'main.tf'),
|
|
100
|
+
[
|
|
101
|
+
'locals {',
|
|
102
|
+
' app_name = "myapp${local.env_suffix}"',
|
|
103
|
+
'}',
|
|
104
|
+
'',
|
|
105
|
+
'resource "aws_ecs_task_definition" "app" {',
|
|
106
|
+
' family = "myapp-task"',
|
|
107
|
+
' container_definitions = jsonencode([',
|
|
108
|
+
' {',
|
|
109
|
+
' name = "myapp-container"',
|
|
110
|
+
' essential = true',
|
|
111
|
+
'',
|
|
112
|
+
' environment = [',
|
|
113
|
+
` ${environment}`,
|
|
114
|
+
' ]',
|
|
115
|
+
' },',
|
|
116
|
+
' {',
|
|
117
|
+
' name = "sidecar"',
|
|
118
|
+
' essential = true',
|
|
119
|
+
' environment = [',
|
|
120
|
+
' { "name": "SIDECAR_VAR", "value": "1" }',
|
|
121
|
+
' ]',
|
|
122
|
+
' }',
|
|
123
|
+
' ])',
|
|
124
|
+
'}',
|
|
125
|
+
'',
|
|
126
|
+
].join('\n')
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
tmp.reset();
|
|
132
|
+
vi.clearAllMocks();
|
|
133
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
exitSpy.mockRestore();
|
|
138
|
+
tmp.cleanup();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('parseAddArgs', () => {
|
|
142
|
+
it('parses the capability positional', () => {
|
|
143
|
+
expect(parseAddArgs(['add', 'storage:s3']).capability).toBe('storage:s3');
|
|
144
|
+
expect(parseAddArgs(['add', 'db:dynamodb']).capability).toBe('db:dynamodb');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('defaults the partition key to id and force to false', () => {
|
|
148
|
+
const options = parseAddArgs(['add', 'db:dynamodb']);
|
|
149
|
+
expect(options.partitionKey).toBe(DEFAULT_PARTITION_KEY);
|
|
150
|
+
expect(options.force).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('supports --flag value and --flag=value forms', () => {
|
|
154
|
+
const spaced = parseAddArgs(['add', 'db:dynamodb', '--region', 'eu-west-1', '--project-name', 'shop', '--partition-key', 'userId']);
|
|
155
|
+
expect(spaced).toMatchObject({ region: 'eu-west-1', projectName: 'shop', partitionKey: 'userId' });
|
|
156
|
+
const joined = parseAddArgs(['add', 'db:dynamodb', '--region=eu-west-1', '--project-name=shop', '--partition-key=userId']);
|
|
157
|
+
expect(joined).toMatchObject({ region: 'eu-west-1', projectName: 'shop', partitionKey: 'userId' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('supports bare --force as well as --force=true and --force=false', () => {
|
|
161
|
+
expect(parseAddArgs(['add', 'storage:s3', '--force']).force).toBe(true);
|
|
162
|
+
expect(parseAddArgs(['add', 'storage:s3', '--force=true']).force).toBe(true);
|
|
163
|
+
expect(parseAddArgs(['add', 'storage:s3', '--force=false']).force).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('precondition guards', () => {
|
|
168
|
+
it('fails with TERRAFORM_NOT_INITIALIZED when terraform/main.tf is missing', async () => {
|
|
169
|
+
const result = await runAdd({ cwd: makeTmp(), capability: 'storage:s3' });
|
|
170
|
+
expect(result.ok).toBe(false);
|
|
171
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
172
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
173
|
+
capability: 'storage:s3',
|
|
174
|
+
success: false,
|
|
175
|
+
error_code: 'TERRAFORM_NOT_INITIALIZED',
|
|
176
|
+
});
|
|
177
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('fails with UNSUPPORTED_CAPABILITY for unknown or missing capabilities', async () => {
|
|
181
|
+
const dir = makeTmp();
|
|
182
|
+
writeMainTf(dir);
|
|
183
|
+
await runAdd({ cwd: dir, capability: 'unknown:foo' });
|
|
184
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
185
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
186
|
+
capability: 'unknown:foo',
|
|
187
|
+
success: false,
|
|
188
|
+
error_code: 'UNSUPPORTED_CAPABILITY',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
vi.clearAllMocks();
|
|
192
|
+
await runAdd({ cwd: dir });
|
|
193
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
194
|
+
capability: 'none',
|
|
195
|
+
success: false,
|
|
196
|
+
error_code: 'UNSUPPORTED_CAPABILITY',
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects an invalid --partition-key with INVALID_PARTITION_KEY telemetry', async () => {
|
|
201
|
+
const dir = makeTmp();
|
|
202
|
+
writeMainTf(dir);
|
|
203
|
+
const result = await runAdd({ cwd: dir, capability: 'db:dynamodb', partitionKey: 'bad key!' });
|
|
204
|
+
expect(result.ok).toBe(false);
|
|
205
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
206
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
207
|
+
projectName: 'myapp',
|
|
208
|
+
capability: 'db:dynamodb',
|
|
209
|
+
success: false,
|
|
210
|
+
error_code: 'INVALID_PARTITION_KEY',
|
|
211
|
+
});
|
|
212
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
213
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'dynamodb.tf'))).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('refuses to overwrite an existing addon file without --force', async () => {
|
|
217
|
+
const dir = makeTmp();
|
|
218
|
+
writeMainTf(dir);
|
|
219
|
+
const target = path.join(dir, 'terraform', 's3.tf');
|
|
220
|
+
fs.writeFileSync(target, '# user edits — do not clobber\n');
|
|
221
|
+
const result = await runAdd({ cwd: dir, capability: 'storage:s3' });
|
|
222
|
+
expect(result.ok).toBe(false);
|
|
223
|
+
expect(result.reason).toBe('addon-already-exists');
|
|
224
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
225
|
+
expect(fs.readFileSync(target, 'utf-8')).toBe('# user edits — do not clobber\n');
|
|
226
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
227
|
+
projectName: 'myapp',
|
|
228
|
+
capability: 'storage:s3',
|
|
229
|
+
success: false,
|
|
230
|
+
error_code: 'ADDON_ALREADY_EXISTS',
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('overwrites the addon file when --force is passed', async () => {
|
|
235
|
+
const dir = makeTmp();
|
|
236
|
+
writeMainTf(dir);
|
|
237
|
+
const target = path.join(dir, 'terraform', 's3.tf');
|
|
238
|
+
fs.writeFileSync(target, '# stale\n');
|
|
239
|
+
const result = await runAdd({ cwd: dir, capability: 'storage:s3', force: true });
|
|
240
|
+
expect(result.ok).toBe(true);
|
|
241
|
+
expect(fs.readFileSync(target, 'utf-8')).toContain('aws_s3_bucket');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('injectContainerEnvVars', () => {
|
|
246
|
+
it('injects into the primary container only and stays idempotent', () => {
|
|
247
|
+
const dir = makeTmp();
|
|
248
|
+
writeMainTf(dir, '{ "name": "EXISTING", "value": "1" },');
|
|
249
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
250
|
+
const once = injectContainerEnvVars(before, S3_ENV);
|
|
251
|
+
expect(once).toContain('S3_BUCKET_NAME');
|
|
252
|
+
expect(once).toContain('S3_CDN_URL');
|
|
253
|
+
// Sidecar block is untouched.
|
|
254
|
+
expect(once.match(/SIDECAR_VAR/g)).toHaveLength(1);
|
|
255
|
+
const twice = injectContainerEnvVars(once, S3_ENV);
|
|
256
|
+
expect(twice).toBe(once);
|
|
257
|
+
expect(once.match(/S3_BUCKET_NAME/g)).toHaveLength(1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('skips keys that already exist in either HCL or JSON form', () => {
|
|
261
|
+
const dir = makeTmp();
|
|
262
|
+
writeMainTf(dir, '{ name = "S3_BUCKET_NAME", value = "custom" },');
|
|
263
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
264
|
+
const after = injectContainerEnvVars(before, S3_ENV);
|
|
265
|
+
expect(after).toContain('S3_CDN_URL');
|
|
266
|
+
expect(after.match(/S3_BUCKET_NAME/g)).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns content unchanged when the task definition is missing', () => {
|
|
270
|
+
expect(injectContainerEnvVars('provider "aws" {}\n', [{ name: 'X', value: 'Y' }])).toBe('provider "aws" {}\n');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('renders single-expression ${...} values as bare HCL references', () => {
|
|
274
|
+
const dir = makeTmp();
|
|
275
|
+
writeMainTf(dir, '{ "name": "EXISTING", "value": "1" },');
|
|
276
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
277
|
+
const entries = [
|
|
278
|
+
{ name: 'S3_BUCKET_NAME', value: '${aws_s3_bucket.storage.id}' },
|
|
279
|
+
{ name: 'DYNAMODB_TABLE_NAME', value: '${aws_dynamodb_table.main.name}' },
|
|
280
|
+
{ name: 'SQS_QUEUE_URL', value: '${aws_sqs_queue.main.id}' },
|
|
281
|
+
{ name: 'SQS_DLQ_URL', value: '${aws_sqs_queue.dlq.id}' },
|
|
282
|
+
];
|
|
283
|
+
const after = injectContainerEnvVars(before, entries);
|
|
284
|
+
// Bare references avoid tflint terraform_deprecated_interpolation.
|
|
285
|
+
expect(after).toContain('{ name = "S3_BUCKET_NAME", value = aws_s3_bucket.storage.id }');
|
|
286
|
+
expect(after).toContain('{ name = "DYNAMODB_TABLE_NAME", value = aws_dynamodb_table.main.name }');
|
|
287
|
+
expect(after).toContain('{ name = "SQS_QUEUE_URL", value = aws_sqs_queue.main.id }');
|
|
288
|
+
expect(after).toContain('{ name = "SQS_DLQ_URL", value = aws_sqs_queue.dlq.id }');
|
|
289
|
+
expect(after).not.toContain('"${aws_s3_bucket.storage.id}"');
|
|
290
|
+
// Idempotent reruns.
|
|
291
|
+
expect(injectContainerEnvVars(after, entries)).toBe(after);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('keeps literals and multi-part interpolations quoted', () => {
|
|
295
|
+
const dir = makeTmp();
|
|
296
|
+
writeMainTf(dir);
|
|
297
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
298
|
+
const after = injectContainerEnvVars(before, [
|
|
299
|
+
{ name: 'BEDROCK_MODEL_ID', value: 'anthropic.claude-3' },
|
|
300
|
+
{ name: 'S3_CDN_URL', value: 'https://${aws_cloudfront_distribution.storage_cdn.domain_name}' },
|
|
301
|
+
{ name: 'REDIS_URL', value: 'redis://${aws_elasticache_replication_group.redis.primary_endpoint_address}:${aws_elasticache_replication_group.redis.port}' },
|
|
302
|
+
]);
|
|
303
|
+
expect(after).toContain('{ name = "BEDROCK_MODEL_ID", value = "anthropic.claude-3" }');
|
|
304
|
+
expect(after).toContain('{ name = "S3_CDN_URL", value = "https://${aws_cloudfront_distribution.storage_cdn.domain_name}" }');
|
|
305
|
+
expect(after).toContain('value = "redis://${aws_elasticache_replication_group.redis.primary_endpoint_address}:${aws_elasticache_replication_group.redis.port}"');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('upserts bare references over legacy quoted interpolations', () => {
|
|
309
|
+
const dir = makeTmp();
|
|
310
|
+
writeMainTf(dir, '{ name = "SQS_QUEUE_URL", value = "${aws_sqs_queue.main.id}" },');
|
|
311
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
312
|
+
const after = injectContainerEnvVars(
|
|
313
|
+
before,
|
|
314
|
+
[{ name: 'SQS_QUEUE_URL', value: '${aws_sqs_queue.main.id}' }],
|
|
315
|
+
'app',
|
|
316
|
+
{ upsertKeys: ['SQS_QUEUE_URL'] }
|
|
317
|
+
);
|
|
318
|
+
expect(after).toContain('{ name = "SQS_QUEUE_URL", value = aws_sqs_queue.main.id }');
|
|
319
|
+
// Identical bare values leave the text untouched.
|
|
320
|
+
expect(injectContainerEnvVars(
|
|
321
|
+
after,
|
|
322
|
+
[{ name: 'SQS_QUEUE_URL', value: '${aws_sqs_queue.main.id}' }],
|
|
323
|
+
'app',
|
|
324
|
+
{ upsertKeys: ['SQS_QUEUE_URL'] }
|
|
325
|
+
)).toBe(after);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('generated terraform', () => {
|
|
330
|
+
it('renders s3.tf with the sanitized bucket expression, OAC, AES256, CORS, and task role wiring', async () => {
|
|
331
|
+
const dir = makeTmp();
|
|
332
|
+
writeMainTf(dir);
|
|
333
|
+
const result = await runAdd({ cwd: dir, capability: 'storage:s3' });
|
|
334
|
+
expect(result.ok).toBe(true);
|
|
335
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 's3.tf'), 'utf-8');
|
|
336
|
+
expect(rendered).toContain(
|
|
337
|
+
'"${trimsuffix(substr(replace(lower(local.app_name), "_", "-"), 0, 42), "-")}-storage-${data.aws_caller_identity.current.account_id}"'
|
|
338
|
+
);
|
|
339
|
+
expect(rendered).toContain('aws_cloudfront_origin_access_control');
|
|
340
|
+
expect(rendered).toContain('sse_algorithm = "AES256"');
|
|
341
|
+
expect(rendered).toContain('aws_s3_bucket_cors_configuration');
|
|
342
|
+
expect(rendered).toContain('role = aws_iam_role.task_role.id');
|
|
343
|
+
expect(rendered).toContain('output "s3_bucket_name"');
|
|
344
|
+
expect(rendered).toContain('output "s3_cdn_domain"');
|
|
345
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
346
|
+
expect(mainTf).toContain('S3_BUCKET_NAME');
|
|
347
|
+
expect(mainTf).toContain('S3_CDN_URL');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('renders dynamodb.tf with PAY_PER_REQUEST, PITR, gateway endpoint, and task role wiring', async () => {
|
|
351
|
+
const dir = makeTmp();
|
|
352
|
+
writeMainTf(dir);
|
|
353
|
+
const result = await runAdd({ cwd: dir, capability: 'db:dynamodb', region: 'eu-west-1', partitionKey: 'userId' });
|
|
354
|
+
expect(result.ok).toBe(true);
|
|
355
|
+
expect(result.region).toBe('eu-west-1');
|
|
356
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'dynamodb.tf'), 'utf-8');
|
|
357
|
+
expect(rendered).toContain('billing_mode = "PAY_PER_REQUEST"');
|
|
358
|
+
expect(rendered).toContain('point_in_time_recovery');
|
|
359
|
+
expect(rendered).toContain('vpc_endpoint_type = "Gateway"');
|
|
360
|
+
expect(rendered).toContain('com.amazonaws.eu-west-1.dynamodb');
|
|
361
|
+
expect(rendered).toContain('[aws_route_table.public.id]');
|
|
362
|
+
expect(rendered).toContain('role = aws_iam_role.task_role.id');
|
|
363
|
+
expect(rendered).toContain('hash_key = "userId"');
|
|
364
|
+
expect(rendered).not.toContain('{{PARTITION_KEY}}');
|
|
365
|
+
expect(rendered).not.toContain('{{REGION}}');
|
|
366
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
367
|
+
expect(mainTf).toContain('DYNAMODB_TABLE_NAME');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('defaults the partition key to id', async () => {
|
|
371
|
+
const dir = makeTmp();
|
|
372
|
+
writeMainTf(dir);
|
|
373
|
+
await runAdd({ cwd: dir, capability: 'db:dynamodb' });
|
|
374
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'dynamodb.tf'), 'utf-8');
|
|
375
|
+
expect(rendered).toContain('hash_key = "id"');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('emits a success telemetry event', async () => {
|
|
379
|
+
const dir = makeTmp();
|
|
380
|
+
writeMainTf(dir);
|
|
381
|
+
await runAdd({ cwd: dir, capability: 'storage:s3' });
|
|
382
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
383
|
+
projectName: 'myapp',
|
|
384
|
+
capability: 'storage:s3',
|
|
385
|
+
success: true,
|
|
386
|
+
});
|
|
387
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('cost transparency', () => {
|
|
392
|
+
let logSpy;
|
|
393
|
+
|
|
394
|
+
beforeEach(() => {
|
|
395
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
logSpy.mockRestore();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('re-exports ADDON_REGISTRY from the shared addons module', async () => {
|
|
403
|
+
const shared = await import('../src/utils/addons.js');
|
|
404
|
+
expect(ADDON_REGISTRY).toBe(shared.ADDON_REGISTRY);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('prints the 💰 Cost Impact line with the registry summary', async () => {
|
|
408
|
+
const dir = makeTmp();
|
|
409
|
+
writeMainTf(dir);
|
|
410
|
+
await runAdd({ cwd: dir, capability: 'storage:s3' });
|
|
411
|
+
const output = logSpy.mock.calls.map((args) => String(args[0])).join('\n');
|
|
412
|
+
expect(output).toContain('💰 Cost Impact:');
|
|
413
|
+
expect(output).toContain(ADDON_REGISTRY['storage:s3'].cost.summary);
|
|
414
|
+
|
|
415
|
+
vi.clearAllMocks();
|
|
416
|
+
const dir2 = makeTmp();
|
|
417
|
+
writeMainTf(dir2);
|
|
418
|
+
await runAdd({ cwd: dir2, capability: 'db:dynamodb' });
|
|
419
|
+
const output2 = logSpy.mock.calls.map((args) => String(args[0])).join('\n');
|
|
420
|
+
expect(output2).toContain('💰 Cost Impact:');
|
|
421
|
+
expect(output2).toContain(ADDON_REGISTRY['db:dynamodb'].cost.summary);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('syncs the cost baseline into README.md and lists active addons', async () => {
|
|
425
|
+
const dir = makeTmp();
|
|
426
|
+
writeMainTf(dir);
|
|
427
|
+
fs.writeFileSync(
|
|
428
|
+
path.join(dir, 'README.md'),
|
|
429
|
+
`# myapp\n\n* **${LEGACY_COST_ESTIMATE_MARKER}** ~$9.99 / month\n\n## Usage\n`
|
|
430
|
+
);
|
|
431
|
+
await runAdd({ cwd: dir, capability: 'storage:s3' });
|
|
432
|
+
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
|
|
433
|
+
expect(readme).not.toContain('~$9.99');
|
|
434
|
+
expect(readme).toContain(COST_ESTIMATE_MARKER);
|
|
435
|
+
expect(readme).toContain('### Active Addons (Usage-Based)');
|
|
436
|
+
expect(readme).toContain('`storage:s3`');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('prefers DEPLOYMENT.md over README.md and replaces reruns idempotently', async () => {
|
|
440
|
+
const dir = makeTmp();
|
|
441
|
+
writeMainTf(dir);
|
|
442
|
+
fs.writeFileSync(path.join(dir, 'README.md'), `# myapp\n\n* **${LEGACY_COST_ESTIMATE_MARKER}** ~$9.99 / month\n`);
|
|
443
|
+
fs.writeFileSync(path.join(dir, 'DEPLOYMENT.md'), `# deploy\n\n* **${LEGACY_COST_ESTIMATE_MARKER}** ~$9.99 / month\n`);
|
|
444
|
+
await runAdd({ cwd: dir, capability: 'storage:s3', force: true });
|
|
445
|
+
await runAdd({ cwd: dir, capability: 'db:dynamodb', force: true });
|
|
446
|
+
// README.md untouched; DEPLOYMENT.md updated once per addon, no duplicates.
|
|
447
|
+
expect(fs.readFileSync(path.join(dir, 'README.md'), 'utf-8')).toContain('~$9.99');
|
|
448
|
+
const deployment = fs.readFileSync(path.join(dir, 'DEPLOYMENT.md'), 'utf-8');
|
|
449
|
+
expect(deployment).not.toContain('~$9.99');
|
|
450
|
+
expect(deployment.match(/### Active Addons \(Usage-Based\)/g)).toHaveLength(1);
|
|
451
|
+
expect(deployment).toContain('`storage:s3`');
|
|
452
|
+
expect(deployment).toContain('`db:dynamodb`');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('syncDocCostEstimate no-ops without a marker or terraform project', () => {
|
|
456
|
+
const dir = makeTmp();
|
|
457
|
+
writeMainTf(dir);
|
|
458
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# myapp\n\nNo cost section here.\n');
|
|
459
|
+
expect(syncDocCostEstimate(dir)).toBeNull();
|
|
460
|
+
expect(syncDocCostEstimate(makeTmp())).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('--model flag', () => {
|
|
465
|
+
it('defaults to the Bedrock inference profile', () => {
|
|
466
|
+
expect(parseAddArgs(['add', 'ai:bedrock']).model).toBe(DEFAULT_BEDROCK_MODEL);
|
|
467
|
+
expect(DEFAULT_BEDROCK_MODEL).toBe('us.anthropic.claude-sonnet-4-6');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('supports --model value and --model=value forms', () => {
|
|
471
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--model', 'custom.model']).model).toBe('custom.model');
|
|
472
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--model=custom.model']).model).toBe('custom.model');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('rejects an invalid --model with INVALID_MODEL_ID telemetry', async () => {
|
|
476
|
+
const dir = makeTmp();
|
|
477
|
+
writeMainTf(dir);
|
|
478
|
+
const result = await runAdd({ cwd: dir, capability: 'ai:bedrock', model: 'bad model!' });
|
|
479
|
+
expect(result.ok).toBe(false);
|
|
480
|
+
expect(result.reason).toBe('invalid-model-id');
|
|
481
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
482
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', {
|
|
483
|
+
projectName: 'myapp',
|
|
484
|
+
capability: 'ai:bedrock',
|
|
485
|
+
success: false,
|
|
486
|
+
error_code: 'INVALID_MODEL_ID',
|
|
487
|
+
});
|
|
488
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
489
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'bedrock.tf'))).toBe(false);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('silently ignores --model on non-bedrock capabilities', async () => {
|
|
493
|
+
const dir = makeTmp();
|
|
494
|
+
writeMainTf(dir);
|
|
495
|
+
const result = await runAdd({ cwd: dir, capability: 'storage:s3', model: 'not a model!!!' });
|
|
496
|
+
expect(result.ok).toBe(true);
|
|
497
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('validates --model before the terraform guard, like --partition-key', async () => {
|
|
501
|
+
const result = await runAdd({ cwd: makeTmp(), capability: 'ai:bedrock', model: 'bad model!' });
|
|
502
|
+
expect(result.ok).toBe(false);
|
|
503
|
+
expect(result.reason).toBe('invalid-model-id');
|
|
504
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
505
|
+
error_code: 'INVALID_MODEL_ID',
|
|
506
|
+
}));
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe('db:redis generated terraform', () => {
|
|
511
|
+
it('renders redis.tf with Valkey, t4g.micro, md5-suffixed replication_group_id, and ECS-locked ingress', async () => {
|
|
512
|
+
const dir = makeTmp();
|
|
513
|
+
writeMainTf(dir);
|
|
514
|
+
const result = await runAdd({ cwd: dir, capability: 'db:redis' });
|
|
515
|
+
expect(result.ok).toBe(true);
|
|
516
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'redis.tf'), 'utf-8');
|
|
517
|
+
expect(rendered).toContain('resource "aws_elasticache_replication_group" "redis"');
|
|
518
|
+
expect(rendered).toContain('engine = "valkey"');
|
|
519
|
+
expect(rendered).toContain('engine_version = "8.0"');
|
|
520
|
+
expect(rendered).toContain('node_type = "cache.t4g.micro"');
|
|
521
|
+
expect(rendered).toContain('num_cache_clusters = 1');
|
|
522
|
+
expect(rendered).toContain('parameter_group_name = "default.valkey8"');
|
|
523
|
+
expect(rendered).toContain('automatic_failover_enabled = false');
|
|
524
|
+
expect(rendered).not.toContain('resource "aws_elasticache_cluster"');
|
|
525
|
+
expect(rendered).not.toContain('elasticache_cluster.redis');
|
|
526
|
+
expect(rendered).not.toContain('cache_nodes');
|
|
527
|
+
expect(rendered).toContain('subnet_ids = aws_subnet.public[*].id');
|
|
528
|
+
expect(rendered).toContain('from_port = 6379');
|
|
529
|
+
expect(rendered).toContain('security_groups = [aws_security_group.ecs_tasks.id]');
|
|
530
|
+
expect(rendered).toContain('trivy:ignore:AVD-AWS-0104');
|
|
531
|
+
expect(rendered).toContain('substr(md5(local.app_name), 0, 5)');
|
|
532
|
+
expect(rendered).toContain('output "redis_endpoint"');
|
|
533
|
+
expect(rendered).toContain('output "redis_port"');
|
|
534
|
+
expect(rendered).toContain('primary_endpoint_address');
|
|
535
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
536
|
+
expect(mainTf).toContain('REDIS_URL');
|
|
537
|
+
expect(mainTf).toContain('redis://${aws_elasticache_replication_group.redis.primary_endpoint_address}:${aws_elasticache_replication_group.redis.port}');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('updates an existing REDIS_URL value in place when --force is passed', async () => {
|
|
541
|
+
const dir = makeTmp();
|
|
542
|
+
writeMainTf(dir, '{ "name": "REDIS_URL", "value": "redis://${aws_elasticache_cluster.redis.cache_nodes[0].address}:6379" }');
|
|
543
|
+
const result = await runAdd({ cwd: dir, capability: 'db:redis', force: true });
|
|
544
|
+
expect(result.ok).toBe(true);
|
|
545
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
546
|
+
expect(mainTf).toContain('redis://${aws_elasticache_replication_group.redis.primary_endpoint_address}:${aws_elasticache_replication_group.redis.port}');
|
|
547
|
+
expect(mainTf).not.toContain('aws_elasticache_cluster');
|
|
548
|
+
expect(mainTf.match(/"name": "REDIS_URL"/g)).toHaveLength(1);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('bumps the fixed baseline by $9.49/mo in estimate and README', async () => {
|
|
552
|
+
const dir = makeTmp();
|
|
553
|
+
writeMainTf(dir);
|
|
554
|
+
const before = estimateMonthlyCost(parseTerraformConfig(path.join(dir, 'terraform')));
|
|
555
|
+
fs.writeFileSync(
|
|
556
|
+
path.join(dir, 'README.md'),
|
|
557
|
+
`# myapp\n\n* **${COST_ESTIMATE_MARKER}** ~$${before.totalMonthly}/month\n`
|
|
558
|
+
);
|
|
559
|
+
await runAdd({ cwd: dir, capability: 'db:redis' });
|
|
560
|
+
const after = estimateMonthlyCost(parseTerraformConfig(path.join(dir, 'terraform')));
|
|
561
|
+
expect(Number(after.totalMonthly) - Number(before.totalMonthly)).toBeCloseTo(9.49, 2);
|
|
562
|
+
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf-8');
|
|
563
|
+
expect(readme).toContain(`~$${after.totalMonthly}/month`);
|
|
564
|
+
expect(readme).toContain('`db:redis`');
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe('queue:sqs generated terraform', () => {
|
|
569
|
+
it('renders sqs.tf with long polling, redrive policy, and task role wiring', async () => {
|
|
570
|
+
const dir = makeTmp();
|
|
571
|
+
writeMainTf(dir);
|
|
572
|
+
const result = await runAdd({ cwd: dir, capability: 'queue:sqs' });
|
|
573
|
+
expect(result.ok).toBe(true);
|
|
574
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'sqs.tf'), 'utf-8');
|
|
575
|
+
expect(rendered).toContain('resource "aws_sqs_queue" "dlq"');
|
|
576
|
+
expect(rendered).toContain('message_retention_seconds = 1209600');
|
|
577
|
+
expect(rendered).toContain('resource "aws_sqs_queue" "main"');
|
|
578
|
+
expect(rendered).toContain('visibility_timeout_seconds = 30');
|
|
579
|
+
expect(rendered).toContain('receive_wait_time_seconds = 20');
|
|
580
|
+
expect(rendered).toContain('sqs_managed_sse_enabled = true');
|
|
581
|
+
expect(rendered).toContain('deadLetterTargetArn = aws_sqs_queue.dlq.arn');
|
|
582
|
+
expect(rendered).toContain('maxReceiveCount = 3');
|
|
583
|
+
expect(rendered).toContain('resource "aws_iam_role_policy" "queue_sqs_access"');
|
|
584
|
+
expect(rendered).toContain('name = "${local.app_name}-queue-sqs-access"');
|
|
585
|
+
expect(rendered).toContain('role = aws_iam_role.task_role.id');
|
|
586
|
+
expect(rendered).toContain('"sqs:SendMessage"');
|
|
587
|
+
expect(rendered).not.toContain('{{WORKER_AUTOSCALING_BLOCK}}');
|
|
588
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
589
|
+
expect(mainTf).toContain('SQS_QUEUE_URL');
|
|
590
|
+
expect(mainTf).toContain('SQS_DLQ_URL');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('renders active auto-scaling when worker.tf exists', async () => {
|
|
594
|
+
const dir = makeTmp();
|
|
595
|
+
writeMainTf(dir);
|
|
596
|
+
writeWorkerTf(dir);
|
|
597
|
+
await runAdd({ cwd: dir, capability: 'queue:sqs' });
|
|
598
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'sqs.tf'), 'utf-8');
|
|
599
|
+
expect(rendered).toContain('resource "aws_appautoscaling_target" "worker_scale"');
|
|
600
|
+
expect(rendered).toContain('min_capacity = 0');
|
|
601
|
+
expect(rendered).toContain('max_capacity = 5');
|
|
602
|
+
expect(rendered).toContain('resource "aws_appautoscaling_policy" "worker_scale_out"');
|
|
603
|
+
expect(rendered).toContain('resource "aws_appautoscaling_policy" "worker_scale_in"');
|
|
604
|
+
expect(rendered).toContain('resource "aws_cloudwatch_metric_alarm" "sqs_queue_high"');
|
|
605
|
+
expect(rendered).toContain('resource "aws_cloudwatch_metric_alarm" "sqs_queue_empty"');
|
|
606
|
+
expect(rendered).toContain('scaling_adjustment = 1');
|
|
607
|
+
expect(rendered).toContain('scaling_adjustment = -5');
|
|
608
|
+
expect(rendered).not.toMatch(/^# resource "aws_appautoscaling_target"/m);
|
|
609
|
+
// Worker container receives the queue URLs.
|
|
610
|
+
const workerTf = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
611
|
+
expect(workerTf).toContain('SQS_QUEUE_URL');
|
|
612
|
+
expect(workerTf).toContain('SQS_DLQ_URL');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('renders commented auto-scaling when worker.tf is absent', async () => {
|
|
616
|
+
const dir = makeTmp();
|
|
617
|
+
writeMainTf(dir);
|
|
618
|
+
await runAdd({ cwd: dir, capability: 'queue:sqs' });
|
|
619
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'sqs.tf'), 'utf-8');
|
|
620
|
+
expect(rendered).not.toMatch(/^resource "aws_appautoscaling_target"/m);
|
|
621
|
+
expect(rendered).not.toMatch(/^resource "aws_cloudwatch_metric_alarm" "sqs_queue/m);
|
|
622
|
+
expect(rendered).toContain('# resource "aws_appautoscaling_target" "worker_scale"');
|
|
623
|
+
expect(rendered).toContain('# resource "aws_cloudwatch_metric_alarm" "sqs_queue_high"');
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe('ai:bedrock generated terraform', () => {
|
|
628
|
+
it('renders bedrock.tf with the default model', async () => {
|
|
629
|
+
const dir = makeTmp();
|
|
630
|
+
writeMainTf(dir);
|
|
631
|
+
const savedRegion = process.env.AWS_REGION;
|
|
632
|
+
delete process.env.AWS_REGION;
|
|
633
|
+
let result;
|
|
634
|
+
try {
|
|
635
|
+
result = await runAdd({ cwd: dir, capability: 'ai:bedrock' });
|
|
636
|
+
} finally {
|
|
637
|
+
if (savedRegion !== undefined) process.env.AWS_REGION = savedRegion;
|
|
638
|
+
}
|
|
639
|
+
expect(result.ok).toBe(true);
|
|
640
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8');
|
|
641
|
+
expect(rendered).toContain('resource "aws_iam_role_policy" "ai_bedrock_access"');
|
|
642
|
+
expect(rendered).toContain('name = "${local.app_name}-ai-bedrock-access"');
|
|
643
|
+
expect(rendered).toContain('role = aws_iam_role.task_role.id');
|
|
644
|
+
expect(rendered).toContain('"bedrock:InvokeModel"');
|
|
645
|
+
expect(rendered).toContain('"bedrock:InvokeModelWithResponseStream"');
|
|
646
|
+
expect(rendered).toContain('"arn:aws:bedrock:*::foundation-model/*"');
|
|
647
|
+
expect(rendered).toContain('"arn:aws:bedrock:*:*:inference-profile/*"');
|
|
648
|
+
expect(rendered).toContain(`value = "${DEFAULT_BEDROCK_MODEL}"`);
|
|
649
|
+
expect(rendered).not.toContain('{{BEDROCK_MODEL_ID}}');
|
|
650
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
651
|
+
expect(mainTf).toContain('BEDROCK_MODEL_ID');
|
|
652
|
+
expect(mainTf).toContain(DEFAULT_BEDROCK_MODEL);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('honors a custom --model override in both bedrock.tf and container env', async () => {
|
|
656
|
+
const dir = makeTmp();
|
|
657
|
+
writeMainTf(dir);
|
|
658
|
+
const custom = 'us.anthropic.claude-haiku-4-5-20251001-v1:0';
|
|
659
|
+
await runAdd({ cwd: dir, capability: 'ai:bedrock', model: custom });
|
|
660
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8');
|
|
661
|
+
expect(rendered).toContain(`value = "${custom}"`);
|
|
662
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
663
|
+
expect(mainTf).toContain(custom);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('aligns the default model ID to the project region', async () => {
|
|
667
|
+
const dir = makeTmp();
|
|
668
|
+
writeMainTf(dir);
|
|
669
|
+
await runAdd({ cwd: dir, capability: 'ai:bedrock', region: 'eu-west-1' });
|
|
670
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8');
|
|
671
|
+
const expected = resolveModelIdForRegion(DEFAULT_BEDROCK_MODEL, 'eu-west-1');
|
|
672
|
+
expect(rendered).toContain(`value = "${expected}"`);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('keeps an explicit --model verbatim even in non-us regions', async () => {
|
|
676
|
+
const dir = makeTmp();
|
|
677
|
+
writeMainTf(dir);
|
|
678
|
+
const custom = 'us.anthropic.claude-haiku-4-5-20251001-v1:0';
|
|
679
|
+
await runAdd({ cwd: dir, capability: 'ai:bedrock', region: 'eu-west-1', model: custom, modelProvided: true });
|
|
680
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8');
|
|
681
|
+
expect(rendered).toContain(`value = "${custom}"`);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe('worker.tf handling', () => {
|
|
686
|
+
it('injects addon env vars into worker.tf for all capabilities', async () => {
|
|
687
|
+
const dir = makeTmp();
|
|
688
|
+
writeMainTf(dir);
|
|
689
|
+
writeWorkerTf(dir);
|
|
690
|
+
await runAdd({ cwd: dir, capability: 'storage:s3', force: true });
|
|
691
|
+
const workerTf = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
692
|
+
expect(workerTf).toContain('S3_BUCKET_NAME');
|
|
693
|
+
expect(workerTf).toContain('S3_CDN_URL');
|
|
694
|
+
expect(workerTf.match(/S3_BUCKET_NAME/g)).toHaveLength(1);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('targets the worker task definition without touching the app definition', () => {
|
|
698
|
+
const dir = makeTmp();
|
|
699
|
+
writeWorkerTf(dir);
|
|
700
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
701
|
+
// Default 'app' anchor leaves worker.tf untouched.
|
|
702
|
+
expect(injectContainerEnvVars(before, [{ name: 'X', value: 'Y' }])).toBe(before);
|
|
703
|
+
const after = injectContainerEnvVars(before, [{ name: 'X', value: 'Y' }], 'worker');
|
|
704
|
+
expect(after).toContain('{ name = "X", value = "Y" }');
|
|
705
|
+
expect(after.match(/"name": "NODE_ENV"/g)).toHaveLength(1);
|
|
706
|
+
// Idempotent reruns.
|
|
707
|
+
expect(injectContainerEnvVars(after, [{ name: 'X', value: 'Y' }], 'worker')).toBe(after);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('injects lifecycle ignore_changes into existing worker.tf on queue:sqs', async () => {
|
|
711
|
+
const dir = makeTmp();
|
|
712
|
+
writeMainTf(dir);
|
|
713
|
+
writeWorkerTf(dir);
|
|
714
|
+
await runAdd({ cwd: dir, capability: 'queue:sqs' });
|
|
715
|
+
const workerTf = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
716
|
+
expect(workerTf).toContain('ignore_changes = [desired_count]');
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('leaves worker.tf lifecycle untouched when already present or for other addons', async () => {
|
|
720
|
+
const dir = makeTmp();
|
|
721
|
+
writeMainTf(dir);
|
|
722
|
+
writeWorkerTf(dir, { lifecycle: true });
|
|
723
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
724
|
+
await runAdd({ cwd: dir, capability: 'queue:sqs' });
|
|
725
|
+
const afterSqs = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
726
|
+
expect(afterSqs.match(/ignore_changes/g)).toHaveLength(1);
|
|
727
|
+
|
|
728
|
+
const dir2 = makeTmp();
|
|
729
|
+
writeMainTf(dir2);
|
|
730
|
+
writeWorkerTf(dir2);
|
|
731
|
+
const beforeOther = fs.readFileSync(path.join(dir2, 'terraform', 'worker.tf'), 'utf-8');
|
|
732
|
+
await runAdd({ cwd: dir2, capability: 'db:redis' });
|
|
733
|
+
const afterOther = fs.readFileSync(path.join(dir2, 'terraform', 'worker.tf'), 'utf-8');
|
|
734
|
+
expect(afterOther).not.toContain('ignore_changes');
|
|
735
|
+
expect(afterOther).not.toBe(beforeOther); // env vars still injected
|
|
736
|
+
expect(before).toContain('ignore_changes');
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('injects into a hasDb:false worker.tf without emitting a double comma', () => {
|
|
740
|
+
// Real template rendered the way the generator leaves it when
|
|
741
|
+
// NEEDS_DATABASE is false: {{DB_ENV_VARS}} expands to empty,
|
|
742
|
+
// leaving a dangling comma + blank line after NODE_ENV.
|
|
743
|
+
const template = fs.readFileSync(
|
|
744
|
+
new URL('../templates/terraform/worker.tf', import.meta.url),
|
|
745
|
+
'utf-8'
|
|
746
|
+
);
|
|
747
|
+
const rendered = template
|
|
748
|
+
.replace('{{DB_ENV_VARS}}', '')
|
|
749
|
+
.replace('{{WORKER_COMMAND}}', 'command = ["npm", "run", "worker"]');
|
|
750
|
+
// Sanity: the fixture really carries the dangling comma.
|
|
751
|
+
expect(rendered).toMatch(/}\s*,\s*\n\s*\n\s*\]/);
|
|
752
|
+
const after = injectContainerEnvVars(rendered, [{ name: 'REDIS_URL', value: 'redis://x' }], 'worker');
|
|
753
|
+
expect(after).toContain('{ name = "REDIS_URL", value = "redis://x" }');
|
|
754
|
+
expect(after).not.toMatch(/}\s*,\s*,/);
|
|
755
|
+
// The worker image must use the count-safe local, matching main.tf.
|
|
756
|
+
expect(after).toContain('${local.ecr_url}');
|
|
757
|
+
expect(after).not.toContain('aws_ecr_repository.app.repository_url');
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
describe('ensureWorkerDesiredCountLifecycle', () => {
|
|
762
|
+
it('adds the lifecycle block inside the worker service resource', () => {
|
|
763
|
+
const dir = makeTmp();
|
|
764
|
+
writeWorkerTf(dir);
|
|
765
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
766
|
+
const after = ensureWorkerDesiredCountLifecycle(before);
|
|
767
|
+
expect(after).toContain('lifecycle {');
|
|
768
|
+
expect(after).toContain('ignore_changes = [desired_count]');
|
|
769
|
+
expect(ensureWorkerDesiredCountLifecycle(after)).toBe(after);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('returns content unchanged when the worker service is missing', () => {
|
|
773
|
+
expect(ensureWorkerDesiredCountLifecycle('provider "aws" {}\n')).toBe('provider "aws" {}\n');
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
describe('catalog flag parsing', () => {
|
|
778
|
+
it('parses --list-models, --refresh, and --headless forms', () => {
|
|
779
|
+
const defaults = parseAddArgs(['add', 'ai:bedrock']);
|
|
780
|
+
expect(defaults).toMatchObject({ listModels: false, refresh: false, isHeadless: false, modelProvided: false });
|
|
781
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--list-models'])).toMatchObject({ listModels: true });
|
|
782
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--refresh'])).toMatchObject({ refresh: true });
|
|
783
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--headless'])).toMatchObject({ isHeadless: true });
|
|
784
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--headless=true'])).toMatchObject({ isHeadless: true });
|
|
785
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--headless=false'])).toMatchObject({ isHeadless: false });
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('tracks explicit --model separately from the default', () => {
|
|
789
|
+
expect(parseAddArgs(['add', 'ai:bedrock']).modelProvided).toBe(false);
|
|
790
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--model', 'x.y']).modelProvided).toBe(true);
|
|
791
|
+
expect(parseAddArgs(['add', 'ai:bedrock', '--model=x.y']).modelProvided).toBe(true);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe('bedrock model catalog', () => {
|
|
796
|
+
it('matches DEFAULT_BEDROCK_MODEL to the bundled catalog default', () => {
|
|
797
|
+
const catalog = loadBedrockCatalog();
|
|
798
|
+
expect(catalog.defaultModelId).toBe('us.anthropic.claude-sonnet-4-6');
|
|
799
|
+
expect(DEFAULT_BEDROCK_MODEL).toBe(catalog.defaultModelId);
|
|
800
|
+
expect(FALLBACK_BEDROCK_MODEL).toBe('us.anthropic.claude-sonnet-4-6');
|
|
801
|
+
expect(isValidCatalog(catalog)).toBe(true);
|
|
802
|
+
expect(isValidCatalog(FALLBACK_CATALOG)).toBe(true);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('rejects malformed catalogs', () => {
|
|
806
|
+
expect(isValidCatalog(null)).toBe(false);
|
|
807
|
+
expect(isValidCatalog({})).toBe(false);
|
|
808
|
+
expect(isValidCatalog({ ...FALLBACK_CATALOG, updatedAt: 'yesterday' })).toBe(false);
|
|
809
|
+
expect(isValidCatalog({ ...FALLBACK_CATALOG, providers: [] })).toBe(false);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('normalizes provider names from ARNs, profile IDs, and display names', () => {
|
|
813
|
+
expect(normalizeProviderName('Mistral')).toBe('Mistral AI');
|
|
814
|
+
expect(normalizeProviderName('mistral ai')).toBe('Mistral AI');
|
|
815
|
+
expect(normalizeProviderName('us.mistral.pixtral-large')).toBe('Mistral AI');
|
|
816
|
+
expect(normalizeProviderName('arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-x')).toBe('Anthropic');
|
|
817
|
+
expect(normalizeProviderName('us.anthropic.claude-sonnet-4-6')).toBe('Anthropic');
|
|
818
|
+
expect(normalizeProviderName('deepseek.v3.2')).toBe('DeepSeek');
|
|
819
|
+
expect(normalizeProviderName('moonshotai')).toBe('Moonshot AI');
|
|
820
|
+
expect(normalizeProviderName('writer')).toBe('Writer');
|
|
821
|
+
expect(normalizeProviderName('us.stability.stable-image-inpaint-v1:0')).toBe('Stability AI');
|
|
822
|
+
expect(normalizeProviderName('zai.glm-5')).toBe('Zhipu AI');
|
|
823
|
+
expect(normalizeProviderName('Some New Lab')).toBe('Some New Lab');
|
|
824
|
+
expect(normalizeProviderName('')).toBe('Other');
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('parses cross-region scopes and base model IDs', () => {
|
|
828
|
+
expect(parseModelScope('us.anthropic.claude-x')).toEqual({ scope: 'us', baseId: 'anthropic.claude-x' });
|
|
829
|
+
expect(parseModelScope('global.openai.gpt-x')).toEqual({ scope: 'global', baseId: 'openai.gpt-x' });
|
|
830
|
+
expect(parseModelScope('eu.mistral.pixtral-x')).toEqual({ scope: 'eu', baseId: 'mistral.pixtral-x' });
|
|
831
|
+
expect(parseModelScope('apac.amazon.nova-x')).toEqual({ scope: 'apac', baseId: 'amazon.nova-x' });
|
|
832
|
+
expect(parseModelScope('anthropic.claude-x')).toEqual({ scope: 'regional', baseId: 'anthropic.claude-x' });
|
|
833
|
+
expect(parseModelScope('european.acme-1')).toEqual({ scope: 'regional', baseId: 'european.acme-1' });
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('strips routing prefixes from display names', () => {
|
|
837
|
+
expect(cleanDisplayName('US OpenAI GPT-5.6 Luna')).toBe('OpenAI GPT-5.6 Luna');
|
|
838
|
+
expect(cleanDisplayName('GLOBAL Anthropic Claude X')).toBe('Anthropic Claude X');
|
|
839
|
+
expect(cleanDisplayName('Global Claude Sonnet 4.5')).toBe('Claude Sonnet 4.5');
|
|
840
|
+
expect(cleanDisplayName('Claude Sonnet 4.6')).toBe('Claude Sonnet 4.6');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('prefers an explicit cache path over the bundled catalog', () => {
|
|
844
|
+
const dir = makeTmp();
|
|
845
|
+
const cachePath = path.join(dir, 'cache.json');
|
|
846
|
+
const cached = {
|
|
847
|
+
...loadBedrockCatalog(),
|
|
848
|
+
updatedAt: '2999-01-01',
|
|
849
|
+
providers: [
|
|
850
|
+
{
|
|
851
|
+
provider: 'Anthropic',
|
|
852
|
+
models: [{ id: 'us.anthropic.future-model', name: 'Future Model', hint: 'cached' }],
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
};
|
|
856
|
+
fs.writeFileSync(cachePath, JSON.stringify(cached));
|
|
857
|
+
expect(loadBedrockCatalog({ cachePath }).providers).toHaveLength(1);
|
|
858
|
+
expect(loadBedrockCatalog().providers.length).toBeGreaterThan(1);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('migrates pre-dedup caches by collapsing cross-region variants in memory', () => {
|
|
862
|
+
const dir = makeTmp();
|
|
863
|
+
const cachePath = path.join(dir, 'cache.json');
|
|
864
|
+
const cached = {
|
|
865
|
+
...loadBedrockCatalog(),
|
|
866
|
+
updatedAt: '2999-01-01',
|
|
867
|
+
providers: [
|
|
868
|
+
{
|
|
869
|
+
provider: 'Anthropic',
|
|
870
|
+
models: [
|
|
871
|
+
{ id: 'us.anthropic.claude-x', name: 'US Anthropic Claude X', hint: 'h' },
|
|
872
|
+
{ id: 'global.anthropic.claude-x', name: 'GLOBAL Anthropic Claude X', hint: 'h' },
|
|
873
|
+
{ id: 'anthropic.claude-x', name: 'Anthropic Claude X', hint: 'h' },
|
|
874
|
+
],
|
|
875
|
+
},
|
|
876
|
+
],
|
|
877
|
+
};
|
|
878
|
+
fs.writeFileSync(cachePath, JSON.stringify(cached));
|
|
879
|
+
const loaded = loadBedrockCatalog({ cachePath });
|
|
880
|
+
expect(loaded.providers).toHaveLength(1);
|
|
881
|
+
expect(loaded.providers[0].models).toHaveLength(1);
|
|
882
|
+
expect(loaded.providers[0].models[0].id).toBe('us.anthropic.claude-x');
|
|
883
|
+
expect(loaded.providers[0].models[0].scopes).toEqual(['us', 'global']);
|
|
884
|
+
// The cache file itself is untouched; the next --refresh rewrites it.
|
|
885
|
+
expect(JSON.parse(fs.readFileSync(cachePath, 'utf-8')).providers[0].models).toHaveLength(3);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
describe('--list-models', () => {
|
|
890
|
+
it('lists the catalog without a terraform project', async () => {
|
|
891
|
+
const result = await runAdd({ cwd: makeTmp(), capability: 'ai:bedrock', listModels: true });
|
|
892
|
+
expect(result.ok).toBe(true);
|
|
893
|
+
expect(result.action).toBe('list-models');
|
|
894
|
+
expect(result.models).toEqual(expect.arrayContaining([expect.objectContaining({ provider: 'Anthropic' })]));
|
|
895
|
+
const logged = vi.mocked(log.info).mock.calls.map((args) => String(args[0])).join('\n');
|
|
896
|
+
expect(logged).toContain('Anthropic');
|
|
897
|
+
expect(logged).toContain('us.anthropic.claude-sonnet-4-6');
|
|
898
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
899
|
+
capability: 'ai:bedrock',
|
|
900
|
+
action: 'list_models',
|
|
901
|
+
success: true,
|
|
902
|
+
}));
|
|
903
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
904
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('rejects an invalid --model alongside --list-models', async () => {
|
|
908
|
+
const result = await runAdd({ cwd: makeTmp(), capability: 'ai:bedrock', listModels: true, model: 'bad model!' });
|
|
909
|
+
expect(result.ok).toBe(false);
|
|
910
|
+
expect(result.reason).toBe('invalid-model-id');
|
|
911
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('renders one bullet per model under a provider header', async () => {
|
|
915
|
+
await runAdd({ cwd: makeTmp(), capability: 'ai:bedrock', listModels: true });
|
|
916
|
+
const blocks = vi.mocked(log.info).mock.calls.map((args) => stripVTControlCharacters(String(args[0])));
|
|
917
|
+
const anthropic = blocks.find((block) => /Anthropic \(\d+\):/.test(block));
|
|
918
|
+
expect(anthropic).toBeDefined();
|
|
919
|
+
expect(anthropic).toContain('• us.anthropic.claude-sonnet-4-6');
|
|
920
|
+
expect(anthropic).toContain('Recommended default');
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
describe('formatCatalogListing', () => {
|
|
925
|
+
it('merges groups that normalize to the same provider', () => {
|
|
926
|
+
const lines = stripVTControlCharacters(formatCatalogListing({
|
|
927
|
+
providers: [
|
|
928
|
+
{ provider: 'writer', models: [{ id: 'us.writer.palmyra-x4-v1:0', hint: 'writing' }] },
|
|
929
|
+
{ provider: 'Writer', models: [{ id: 'writer.palmyra-vision-7b', hint: 'vision' }] },
|
|
930
|
+
],
|
|
931
|
+
}).join('\n'));
|
|
932
|
+
expect(lines).toContain('Writer (2):');
|
|
933
|
+
expect(lines).toContain('• us.writer.palmyra-x4-v1:0');
|
|
934
|
+
expect(lines).toContain('• writer.palmyra-vision-7b');
|
|
935
|
+
expect(lines).not.toContain('writer (1)');
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('falls back to the generic hint when a model has none', () => {
|
|
939
|
+
const lines = stripVTControlCharacters(formatCatalogListing({
|
|
940
|
+
providers: [{ provider: 'Acme', models: [{ id: 'acme.mystery-1' }] }],
|
|
941
|
+
}).join('\n'));
|
|
942
|
+
expect(lines).toContain('Acme (1):');
|
|
943
|
+
expect(lines).toContain('Live AWS Bedrock model');
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('tags multi-scope models with brackets and leaves single-scope models bare', () => {
|
|
947
|
+
const lines = stripVTControlCharacters(formatCatalogListing({
|
|
948
|
+
providers: [{
|
|
949
|
+
provider: 'Acme',
|
|
950
|
+
models: [
|
|
951
|
+
{ id: 'us.acme.routed-1', hint: 'fast', scopes: ['us', 'global'] },
|
|
952
|
+
{ id: 'acme.local-1', hint: 'local', scopes: ['regional'] },
|
|
953
|
+
{ id: 'acme.legacy-1', hint: 'legacy' },
|
|
954
|
+
],
|
|
955
|
+
}],
|
|
956
|
+
}).join('\n'));
|
|
957
|
+
expect(lines).toContain('• us.acme.routed-1 [us, global] —');
|
|
958
|
+
expect(lines).toContain('• acme.local-1 —');
|
|
959
|
+
expect(lines).toContain('• acme.legacy-1 —');
|
|
960
|
+
expect(lines).not.toContain('[regional]');
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
describe('cross-region model dedup', () => {
|
|
965
|
+
it('collapses scope variants into one canonical entry with ordered scopes', () => {
|
|
966
|
+
const models = dedupeProviderModels('Anthropic', [
|
|
967
|
+
{ id: 'global.anthropic.claude-x', name: 'GLOBAL Anthropic Claude X', hint: 'h1' },
|
|
968
|
+
{ id: 'anthropic.claude-x', name: 'Anthropic Claude X', hint: 'h2' },
|
|
969
|
+
{ id: 'us.anthropic.claude-x', name: 'US Anthropic Claude X', hint: 'h3', recommended: true },
|
|
970
|
+
], { defaultModelId: 'us.anthropic.claude-x' });
|
|
971
|
+
expect(models).toHaveLength(1);
|
|
972
|
+
expect(models[0]).toMatchObject({
|
|
973
|
+
id: 'us.anthropic.claude-x',
|
|
974
|
+
name: 'Anthropic Claude X',
|
|
975
|
+
scopes: ['us', 'global'],
|
|
976
|
+
recommended: true,
|
|
977
|
+
});
|
|
978
|
+
expect(models[0].hint).toMatch(/^Recommended default/);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('keeps regional-only models unscoped with their own id', () => {
|
|
982
|
+
const models = dedupeProviderModels('Acme', [
|
|
983
|
+
{ id: 'acme.local-1', name: 'Acme Local', hint: 'local' },
|
|
984
|
+
], {});
|
|
985
|
+
expect(models).toHaveLength(1);
|
|
986
|
+
expect(models[0]).toMatchObject({ id: 'acme.local-1', scopes: ['regional'] });
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('finds entries by exact id, then by base model', () => {
|
|
990
|
+
const catalog = loadBedrockCatalog();
|
|
991
|
+
expect(findCatalogEntry(catalog, 'us.anthropic.claude-sonnet-4-6')?.id)
|
|
992
|
+
.toBe('us.anthropic.claude-sonnet-4-6');
|
|
993
|
+
expect(findCatalogEntry(catalog, 'anthropic.claude-sonnet-4-6')?.id)
|
|
994
|
+
.toBe('us.anthropic.claude-sonnet-4-6');
|
|
995
|
+
expect(findCatalogEntry(catalog, 'no.such.model')).toBeNull();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('resolves the us chain for us regions', () => {
|
|
999
|
+
const entry = { id: 'us.acme.widget-1', scopes: ['us', 'global'] };
|
|
1000
|
+
expect(resolveModelIdForRegion(entry, 'us-east-2')).toBe('us.acme.widget-1');
|
|
1001
|
+
expect(resolveModelIdForRegion(entry, 'us-west-2')).toBe('us.acme.widget-1');
|
|
1002
|
+
expect(resolveModelIdForRegion({ id: 'global.acme.widget-2', scopes: ['global'] }, 'us-east-1'))
|
|
1003
|
+
.toBe('global.acme.widget-2');
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('prefers eu, then global, then us for eu regions', () => {
|
|
1007
|
+
expect(resolveModelIdForRegion({ id: 'us.acme.w', scopes: ['us', 'global', 'eu'] }, 'eu-west-1'))
|
|
1008
|
+
.toBe('eu.acme.w');
|
|
1009
|
+
expect(resolveModelIdForRegion({ id: 'us.acme.w', scopes: ['us', 'global'] }, 'eu-central-1'))
|
|
1010
|
+
.toBe('global.acme.w');
|
|
1011
|
+
expect(resolveModelIdForRegion({ id: 'us.acme.w', scopes: ['us'] }, 'eu-west-1'))
|
|
1012
|
+
.toBe('us.acme.w');
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('prefers apac, then global, then us for ap regions', () => {
|
|
1016
|
+
expect(resolveModelIdForRegion({ id: 'global.acme.w', scopes: ['global', 'apac'] }, 'ap-southeast-2'))
|
|
1017
|
+
.toBe('apac.acme.w');
|
|
1018
|
+
expect(resolveModelIdForRegion({ id: 'us.acme.w', scopes: ['us', 'global'] }, 'ap-northeast-1'))
|
|
1019
|
+
.toBe('global.acme.w');
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('falls back to the bare base id and to verbatim unknowns', () => {
|
|
1023
|
+
expect(resolveModelIdForRegion({ id: 'acme.local-1', scopes: ['regional'] }, 'us-east-2'))
|
|
1024
|
+
.toBe('acme.local-1');
|
|
1025
|
+
expect(resolveModelIdForRegion({ id: 'us.acme.w4' }, 'eu-west-1')).toBe('us.acme.w4');
|
|
1026
|
+
expect(resolveModelIdForRegion('us.anthropic.claude-sonnet-4-6', 'us-east-2'))
|
|
1027
|
+
.toBe('us.anthropic.claude-sonnet-4-6');
|
|
1028
|
+
expect(resolveModelIdForRegion('no.such.model-1', 'eu-west-1')).toBe('no.such.model-1');
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe('--refresh catalog merge', () => {
|
|
1033
|
+
function mockBedrockClient({ profilesPages = [[]], foundationModels = [] } = {}) {
|
|
1034
|
+
return {
|
|
1035
|
+
send: vi.fn(async (command) => {
|
|
1036
|
+
if (command.constructor.name === 'ListInferenceProfilesCommand') {
|
|
1037
|
+
const page = command.input.nextToken ? profilesPages[1] || [] : profilesPages[0];
|
|
1038
|
+
return {
|
|
1039
|
+
inferenceProfileSummaries: page,
|
|
1040
|
+
nextToken: !command.input.nextToken && profilesPages.length > 1 ? 'token-1' : undefined,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
return { modelSummaries: foundationModels };
|
|
1044
|
+
}),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function withCacheEnv(cachePath, fn) {
|
|
1049
|
+
const saved = process.env.DEPLOY_STACK_BEDROCK_CACHE_PATH;
|
|
1050
|
+
process.env.DEPLOY_STACK_BEDROCK_CACHE_PATH = cachePath;
|
|
1051
|
+
try {
|
|
1052
|
+
return fn();
|
|
1053
|
+
} finally {
|
|
1054
|
+
if (saved === undefined) delete process.env.DEPLOY_STACK_BEDROCK_CACHE_PATH;
|
|
1055
|
+
else process.env.DEPLOY_STACK_BEDROCK_CACHE_PATH = saved;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
it('merges paginated live models without dropping bundled entries', async () => {
|
|
1060
|
+
const dir = makeTmp();
|
|
1061
|
+
const cachePath = path.join(dir, 'bedrock-models-cache.json');
|
|
1062
|
+
const bedrockClient = mockBedrockClient({
|
|
1063
|
+
profilesPages: [
|
|
1064
|
+
[
|
|
1065
|
+
{
|
|
1066
|
+
status: 'ACTIVE',
|
|
1067
|
+
inferenceProfileId: 'us.mistral.pixtral-new',
|
|
1068
|
+
inferenceProfileName: 'Pixtral New',
|
|
1069
|
+
models: [{ modelArn: 'arn:aws:bedrock:us-east-1::foundation-model/mistral.pixtral-new' }],
|
|
1070
|
+
},
|
|
1071
|
+
{ status: 'DELETED', inferenceProfileId: 'us.gone.old-model' },
|
|
1072
|
+
{ status: 'ACTIVE', inferenceProfileId: 'not a valid id!' },
|
|
1073
|
+
],
|
|
1074
|
+
[
|
|
1075
|
+
{
|
|
1076
|
+
status: 'ACTIVE',
|
|
1077
|
+
inferenceProfileId: 'us.amazon.nova-live-1',
|
|
1078
|
+
models: [],
|
|
1079
|
+
},
|
|
1080
|
+
],
|
|
1081
|
+
],
|
|
1082
|
+
foundationModels: [
|
|
1083
|
+
{
|
|
1084
|
+
modelLifecycle: { status: 'ACTIVE' },
|
|
1085
|
+
modelId: 'live.newco-model-1',
|
|
1086
|
+
modelName: 'NewCo Model',
|
|
1087
|
+
providerName: 'NewCo',
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
modelLifecycle: { status: 'ACTIVE' },
|
|
1091
|
+
modelId: 'acme.unknown-model-v1',
|
|
1092
|
+
modelName: 'Unknown Model',
|
|
1093
|
+
providerName: 'Acme',
|
|
1094
|
+
},
|
|
1095
|
+
],
|
|
1096
|
+
});
|
|
1097
|
+
const merged = await withCacheEnv(cachePath, () =>
|
|
1098
|
+
refreshBedrockCatalog({ bedrockClient })
|
|
1099
|
+
);
|
|
1100
|
+
expect(bedrockClient.send).toHaveBeenCalledTimes(3);
|
|
1101
|
+
// Bundled entries survive (additive merge, pruneMissing defaults false).
|
|
1102
|
+
expect(JSON.stringify(merged)).toContain('us.anthropic.claude-sonnet-4-6');
|
|
1103
|
+
const mistral = merged.providers.find((group) => group.provider === 'Mistral AI');
|
|
1104
|
+
expect(mistral.models.map((entry) => entry.id)).toContain('us.mistral.pixtral-new');
|
|
1105
|
+
expect(mistral.models.find((entry) => entry.id === 'us.mistral.pixtral-new').hint).toBe(
|
|
1106
|
+
'Multimodal vision & document understanding'
|
|
1107
|
+
);
|
|
1108
|
+
const acme = merged.providers.find((group) => group.provider === 'Acme');
|
|
1109
|
+
expect(acme.models.find((entry) => entry.id === 'acme.unknown-model-v1').hint).toBe(GENERIC_MODEL_HINT);
|
|
1110
|
+
expect(JSON.stringify(merged)).toContain('us.amazon.nova-live-1');
|
|
1111
|
+
expect(merged.providers.find((group) => group.provider === 'NewCo')).toBeDefined();
|
|
1112
|
+
expect(JSON.stringify(merged)).not.toContain('us.gone.old-model');
|
|
1113
|
+
expect(fs.existsSync(cachePath)).toBe(true);
|
|
1114
|
+
expect(loadBedrockCatalog({ cachePath }).updatedAt).toBe(merged.updatedAt);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('dedupes us, global, and unprefixed variants into one scoped entry', async () => {
|
|
1118
|
+
const dir = makeTmp();
|
|
1119
|
+
const cachePath = path.join(dir, 'cache.json');
|
|
1120
|
+
const bedrockClient = mockBedrockClient({
|
|
1121
|
+
profilesPages: [[
|
|
1122
|
+
{
|
|
1123
|
+
status: 'ACTIVE',
|
|
1124
|
+
inferenceProfileId: 'us.zeta.haiku-dedup',
|
|
1125
|
+
inferenceProfileName: 'US Zeta Haiku Dedup',
|
|
1126
|
+
models: [{ modelArn: 'arn:aws:bedrock:us-east-1::foundation-model/zeta.haiku-dedup' }],
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
status: 'ACTIVE',
|
|
1130
|
+
inferenceProfileId: 'global.zeta.haiku-dedup',
|
|
1131
|
+
inferenceProfileName: 'GLOBAL Zeta Haiku Dedup',
|
|
1132
|
+
models: [{ modelArn: 'arn:aws:bedrock:us-east-1::foundation-model/zeta.haiku-dedup' }],
|
|
1133
|
+
},
|
|
1134
|
+
]],
|
|
1135
|
+
foundationModels: [
|
|
1136
|
+
{
|
|
1137
|
+
modelLifecycle: { status: 'ACTIVE' },
|
|
1138
|
+
modelId: 'zeta.haiku-dedup',
|
|
1139
|
+
modelName: 'Zeta Haiku Dedup',
|
|
1140
|
+
providerName: 'zeta',
|
|
1141
|
+
},
|
|
1142
|
+
],
|
|
1143
|
+
});
|
|
1144
|
+
const merged = await withCacheEnv(cachePath, () =>
|
|
1145
|
+
refreshBedrockCatalog({ bedrockClient })
|
|
1146
|
+
);
|
|
1147
|
+
const zeta = merged.providers.find((group) => group.provider === 'zeta');
|
|
1148
|
+
expect(zeta.models).toHaveLength(1);
|
|
1149
|
+
expect(zeta.models[0]).toMatchObject({
|
|
1150
|
+
id: 'us.zeta.haiku-dedup',
|
|
1151
|
+
name: 'Zeta Haiku Dedup',
|
|
1152
|
+
scopes: ['us', 'global'],
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
it('prunes absent entries when pruneMissing is true but keeps the default', async () => {
|
|
1157
|
+
const dir = makeTmp();
|
|
1158
|
+
const cachePath = path.join(dir, 'cache.json');
|
|
1159
|
+
const bedrockClient = mockBedrockClient({
|
|
1160
|
+
profilesPages: [[
|
|
1161
|
+
{
|
|
1162
|
+
status: 'ACTIVE',
|
|
1163
|
+
inferenceProfileId: 'us.live.only-model',
|
|
1164
|
+
inferenceProfileName: 'Only Live',
|
|
1165
|
+
models: [],
|
|
1166
|
+
},
|
|
1167
|
+
]],
|
|
1168
|
+
});
|
|
1169
|
+
const merged = await refreshBedrockCatalog({ bedrockClient, cachePath, pruneMissing: true });
|
|
1170
|
+
const ids = merged.providers.flatMap((group) => group.models.map((entry) => entry.id));
|
|
1171
|
+
expect(ids).toContain('us.live.only-model');
|
|
1172
|
+
expect(ids).toContain('us.anthropic.claude-sonnet-4-6');
|
|
1173
|
+
expect(ids).not.toContain('us.amazon.nova-pro-v1:0');
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it('falls back to the bundled catalog when AWS throws', async () => {
|
|
1177
|
+
const dir = makeTmp();
|
|
1178
|
+
const bedrockClient = { send: vi.fn().mockRejectedValue(new Error('ExpiredToken')) };
|
|
1179
|
+
const merged = await refreshBedrockCatalog({ bedrockClient, cachePath: path.join(dir, 'cache.json') });
|
|
1180
|
+
expect(merged).toEqual(loadBedrockCatalog());
|
|
1181
|
+
expect(vi.mocked(log.warn)).toHaveBeenCalled();
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('warns when pruning retains the default only via the safety guard', async () => {
|
|
1185
|
+
const dir = makeTmp();
|
|
1186
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1187
|
+
try {
|
|
1188
|
+
const bedrockClient = mockBedrockClient({
|
|
1189
|
+
profilesPages: [[
|
|
1190
|
+
{
|
|
1191
|
+
status: 'ACTIVE',
|
|
1192
|
+
inferenceProfileId: 'us.live.only-model',
|
|
1193
|
+
inferenceProfileName: 'Only Live',
|
|
1194
|
+
models: [],
|
|
1195
|
+
},
|
|
1196
|
+
]],
|
|
1197
|
+
});
|
|
1198
|
+
await refreshBedrockCatalog({
|
|
1199
|
+
bedrockClient,
|
|
1200
|
+
cachePath: path.join(dir, 'cache.json'),
|
|
1201
|
+
pruneMissing: true,
|
|
1202
|
+
});
|
|
1203
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(
|
|
1204
|
+
'::warning file=src/data/bedrock-models.json::Default model "us.anthropic.claude-sonnet-4-6"'
|
|
1205
|
+
));
|
|
1206
|
+
} finally {
|
|
1207
|
+
warnSpy.mockRestore();
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
describe('family hint generation', () => {
|
|
1213
|
+
it('returns durable tier hints without Frontier/Latest buzzwords', () => {
|
|
1214
|
+
expect(generateHintForModel('Anthropic', 'us.anthropic.claude-sonnet-4-6')).toBe(
|
|
1215
|
+
'Recommended default — standard temperature/top_p SDK compatibility'
|
|
1216
|
+
);
|
|
1217
|
+
expect(generateHintForModel('Anthropic', 'us.anthropic.claude-sonnet-5')).toBe(
|
|
1218
|
+
'Sonnet-tier coding & agents (adaptive thinking on; omit temperature)'
|
|
1219
|
+
);
|
|
1220
|
+
expect(generateHintForModel('Anthropic', 'us.anthropic.claude-fable-5-1')).toBe(
|
|
1221
|
+
'Mythos-tier autonomous workflows & agents (adaptive thinking on; omit temperature)'
|
|
1222
|
+
);
|
|
1223
|
+
expect(generateHintForModel('OpenAI', 'us.openai.gpt-6-astra')).toBe(
|
|
1224
|
+
'Flagship multi-step reasoning & agentic workflows (Converse API)'
|
|
1225
|
+
);
|
|
1226
|
+
expect(generateHintForModel('OpenAI', 'openai.gpt-5.6-sol')).toBe(
|
|
1227
|
+
'High-depth reasoning & coding (Responses API)'
|
|
1228
|
+
);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
it('covers the live-sync provider families with specific hints', () => {
|
|
1232
|
+
expect(generateHintForModel('OpenAI', 'openai.gpt-oss-120b-1:0')).toBe(
|
|
1233
|
+
'Open-weights GPT model for self-hosted or fine-tuned inference'
|
|
1234
|
+
);
|
|
1235
|
+
expect(generateHintForModel('OpenAI', 'openai.gpt-oss-safeguard-20b')).toBe(
|
|
1236
|
+
'Safety moderation & content classification'
|
|
1237
|
+
);
|
|
1238
|
+
expect(generateHintForModel('Amazon', 'us.amazon.nova-2-lite-v1:0')).toBe(
|
|
1239
|
+
'Multimodal reasoning, coding & cost-efficient generation'
|
|
1240
|
+
);
|
|
1241
|
+
// Known nova tiers keep their specific hints ahead of the catch-all.
|
|
1242
|
+
expect(generateHintForModel('Amazon', 'us.amazon.nova-pro-v1:0')).toBe(
|
|
1243
|
+
'Multimodal reasoning, coding & agentic workflows (no FTU form required)'
|
|
1244
|
+
);
|
|
1245
|
+
// Family beats size: voxtral-small is speech, not a small text model.
|
|
1246
|
+
expect(generateHintForModel('Mistral AI', 'mistral.voxtral-small-24b-2507')).toBe(
|
|
1247
|
+
'Audio & speech-to-text multimodal understanding'
|
|
1248
|
+
);
|
|
1249
|
+
expect(generateHintForModel('Mistral AI', 'mistral.devstral-2-123b')).toBe(
|
|
1250
|
+
'Specialized software engineering & code reasoning'
|
|
1251
|
+
);
|
|
1252
|
+
expect(generateHintForModel('stability', 'us.stability.stable-image-inpaint-v1:0')).toBe(
|
|
1253
|
+
'Image generation, upscaling & editing'
|
|
1254
|
+
);
|
|
1255
|
+
expect(generateHintForModel('twelvelabs', 'us.twelvelabs.pegasus-1-2-v1:0')).toBe(
|
|
1256
|
+
'Video understanding, search & narrative extraction'
|
|
1257
|
+
);
|
|
1258
|
+
expect(generateHintForModel('writer', 'us.writer.palmyra-x5-v1:0')).toBe(
|
|
1259
|
+
'Enterprise-focused writing, document analysis & vision'
|
|
1260
|
+
);
|
|
1261
|
+
expect(generateHintForModel('NVIDIA', 'nvidia.nemotron-super-3-120b')).toBe(
|
|
1262
|
+
'High-performance enterprise reasoning & synthetic data generation'
|
|
1263
|
+
);
|
|
1264
|
+
expect(generateHintForModel('Qwen', 'qwen.qwen3-coder-480b-a35b-v1:0')).toBe(
|
|
1265
|
+
'Multilingual reasoning, coding & vision understanding'
|
|
1266
|
+
);
|
|
1267
|
+
expect(generateHintForModel('MiniMax', 'minimax.minimax-m2.1')).toBe(
|
|
1268
|
+
'Long-context multilingual generation & agentic workflows'
|
|
1269
|
+
);
|
|
1270
|
+
expect(generateHintForModel('Zhipu AI', 'zai.glm-4.7-flash')).toBe(
|
|
1271
|
+
'Bilingual Chinese-English reasoning, coding & tool use'
|
|
1272
|
+
);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it('falls back to the generic hint for unknown families', () => {
|
|
1276
|
+
expect(generateHintForModel('Anthropic', 'anthropic.claude-nebula-1')).toBe(GENERIC_MODEL_HINT);
|
|
1277
|
+
expect(generateHintForModel('Acme', 'acme.mystery-1')).toBe(GENERIC_MODEL_HINT);
|
|
1278
|
+
expect(GENERIC_MODEL_HINT).toBe('Live AWS Bedrock model');
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it('gives every bundled model a specific, buzzword-free hint', () => {
|
|
1282
|
+
const catalog = loadBedrockCatalog();
|
|
1283
|
+
for (const group of catalog.providers) {
|
|
1284
|
+
for (const model of group.models) {
|
|
1285
|
+
expect(model.hint).not.toBe(GENERIC_MODEL_HINT);
|
|
1286
|
+
expect(model.hint).not.toMatch(/frontier/i);
|
|
1287
|
+
expect(model.hint).not.toMatch(/latest/i);
|
|
1288
|
+
expect(model.hint).toBe(generateHintForModel(group.provider, model.id));
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
expect(validateCatalogHints(catalog).ok).toBe(true);
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
describe('validateCatalogHints', () => {
|
|
1296
|
+
function catalogWith(modelsByProvider) {
|
|
1297
|
+
const defaultModelId = 'us.anthropic.claude-sonnet-4-6';
|
|
1298
|
+
return {
|
|
1299
|
+
updatedAt: '2026-09-27',
|
|
1300
|
+
defaultModelId,
|
|
1301
|
+
providers: Object.entries(modelsByProvider).map(([provider, models]) => ({
|
|
1302
|
+
provider,
|
|
1303
|
+
models: models.map((id) => ({
|
|
1304
|
+
id,
|
|
1305
|
+
name: id,
|
|
1306
|
+
hint: generateHintForModel(provider, id, { defaultModelId }),
|
|
1307
|
+
})),
|
|
1308
|
+
})),
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
it('fails on any unrecognized flagship Anthropic/OpenAI family', () => {
|
|
1313
|
+
const result = validateCatalogHints(catalogWith({
|
|
1314
|
+
Anthropic: ['anthropic.claude-nebula-1'],
|
|
1315
|
+
Cohere: ['cohere.command-r-plus-v1:0'],
|
|
1316
|
+
}));
|
|
1317
|
+
expect(result.ok).toBe(false);
|
|
1318
|
+
expect(result.unmatchedModels.map((entry) => entry.id)).toContain('anthropic.claude-nebula-1');
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it('fails when generic hints exceed maxGenericRatio', () => {
|
|
1322
|
+
const many = Array.from({ length: 10 }, (_, index) => `acme.unknown-${index}`);
|
|
1323
|
+
const over = validateCatalogHints(catalogWith({ Acme: many }));
|
|
1324
|
+
expect(over.ok).toBe(false);
|
|
1325
|
+
expect(over.unmatchedRatio).toBeGreaterThan(0.15);
|
|
1326
|
+
const under = validateCatalogHints(
|
|
1327
|
+
catalogWith({
|
|
1328
|
+
Acme: ['acme.unknown-0'],
|
|
1329
|
+
Cohere: Array.from({ length: 9 }, () => 'cohere.command-r-plus-v1:0'),
|
|
1330
|
+
})
|
|
1331
|
+
);
|
|
1332
|
+
expect(under.ok).toBe(true);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
it('flags stale generic hints even when rules now match', () => {
|
|
1336
|
+
const catalog = {
|
|
1337
|
+
updatedAt: '2026-09-27',
|
|
1338
|
+
defaultModelId: 'us.anthropic.claude-sonnet-4-6',
|
|
1339
|
+
providers: [{
|
|
1340
|
+
provider: 'Cohere',
|
|
1341
|
+
models: [{ id: 'cohere.command-r-plus-v1:0', name: 'Command R+', hint: GENERIC_MODEL_HINT }],
|
|
1342
|
+
}],
|
|
1343
|
+
};
|
|
1344
|
+
const result = validateCatalogHints(catalog);
|
|
1345
|
+
expect(result.ok).toBe(false);
|
|
1346
|
+
expect(result.unmatchedModels).toHaveLength(1);
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
describe('interactive model selector', () => {
|
|
1351
|
+
beforeEach(() => {
|
|
1352
|
+
vi.mocked(select).mockReset();
|
|
1353
|
+
vi.mocked(text).mockReset();
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it('writes the provider -> model selection to bedrock.tf, main.tf, and worker.tf', async () => {
|
|
1357
|
+
const dir = makeTmp();
|
|
1358
|
+
writeMainTf(dir);
|
|
1359
|
+
writeWorkerTf(dir);
|
|
1360
|
+
vi.mocked(select)
|
|
1361
|
+
.mockResolvedValueOnce('Anthropic')
|
|
1362
|
+
.mockResolvedValueOnce('us.anthropic.claude-haiku-4-5-20251001-v1:0');
|
|
1363
|
+
const result = await runAdd({ cwd: dir, capability: 'ai:bedrock', interactive: true });
|
|
1364
|
+
expect(result.ok).toBe(true);
|
|
1365
|
+
const chosen = 'us.anthropic.claude-haiku-4-5-20251001-v1:0';
|
|
1366
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8')).toContain(`value = "${chosen}"`);
|
|
1367
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8')).toContain(chosen);
|
|
1368
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8')).toContain(chosen);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it('accepts a custom model ID and trims whitespace', async () => {
|
|
1372
|
+
const dir = makeTmp();
|
|
1373
|
+
writeMainTf(dir);
|
|
1374
|
+
vi.mocked(select).mockResolvedValueOnce(CUSTOM_MODEL_VALUE);
|
|
1375
|
+
vi.mocked(text).mockResolvedValueOnce(' us.custom.trimmed-model ');
|
|
1376
|
+
const result = await runAdd({ cwd: dir, capability: 'ai:bedrock', interactive: true });
|
|
1377
|
+
expect(result.ok).toBe(true);
|
|
1378
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8')).toContain('value = "us.custom.trimmed-model"');
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it('rejects an invalid custom model ID with INVALID_MODEL_ID', async () => {
|
|
1382
|
+
const dir = makeTmp();
|
|
1383
|
+
writeMainTf(dir);
|
|
1384
|
+
vi.mocked(select).mockResolvedValueOnce('Anthropic').mockResolvedValueOnce(CUSTOM_MODEL_VALUE);
|
|
1385
|
+
vi.mocked(text).mockResolvedValueOnce('bad model!');
|
|
1386
|
+
const result = await runAdd({ cwd: dir, capability: 'ai:bedrock', interactive: true });
|
|
1387
|
+
expect(result.ok).toBe(false);
|
|
1388
|
+
expect(result.reason).toBe('invalid-model-id');
|
|
1389
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1390
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'bedrock.tf'))).toBe(false);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('returns cancelled cleanly when a prompt is dismissed', async () => {
|
|
1394
|
+
const dir = makeTmp();
|
|
1395
|
+
writeMainTf(dir);
|
|
1396
|
+
vi.mocked(select).mockResolvedValueOnce(Symbol('clack:cancel'));
|
|
1397
|
+
const result = await runAdd({ cwd: dir, capability: 'ai:bedrock', interactive: true });
|
|
1398
|
+
expect(result).toEqual({ ok: false, reason: 'cancelled' });
|
|
1399
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
1400
|
+
capability: 'ai:bedrock',
|
|
1401
|
+
success: false,
|
|
1402
|
+
reason: 'cancelled',
|
|
1403
|
+
}));
|
|
1404
|
+
expect(flushTelemetry).toHaveBeenCalled();
|
|
1405
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
1406
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'bedrock.tf'))).toBe(false);
|
|
1407
|
+
});
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
describe('Day-2 model switching', () => {
|
|
1411
|
+
it('switches models without --force and upserts in place without duplication', async () => {
|
|
1412
|
+
const dir = makeTmp();
|
|
1413
|
+
writeMainTf(dir);
|
|
1414
|
+
writeWorkerTf(dir);
|
|
1415
|
+
await runAdd({ cwd: dir, capability: 'ai:bedrock', model: 'model.a-first', modelProvided: true });
|
|
1416
|
+
const switched = await runAdd({ cwd: dir, capability: 'ai:bedrock', model: 'model.b-second', modelProvided: true });
|
|
1417
|
+
expect(switched.ok).toBe(true);
|
|
1418
|
+
const bedrockTf = fs.readFileSync(path.join(dir, 'terraform', 'bedrock.tf'), 'utf-8');
|
|
1419
|
+
expect(bedrockTf).toContain('value = "model.b-second"');
|
|
1420
|
+
expect(bedrockTf).not.toContain('model.a-first');
|
|
1421
|
+
for (const file of ['main.tf', 'worker.tf']) {
|
|
1422
|
+
const content = fs.readFileSync(path.join(dir, 'terraform', file), 'utf-8');
|
|
1423
|
+
expect(content).toContain('model.b-second');
|
|
1424
|
+
expect(content).not.toContain('model.a-first');
|
|
1425
|
+
expect(content.match(/BEDROCK_MODEL_ID/g)).toHaveLength(1);
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it('still guards non-explicit bedrock reruns with ADDON_ALREADY_EXISTS', async () => {
|
|
1430
|
+
const dir = makeTmp();
|
|
1431
|
+
writeMainTf(dir);
|
|
1432
|
+
await runAdd({ cwd: dir, capability: 'ai:bedrock', model: 'model.a-first', modelProvided: true });
|
|
1433
|
+
const rerun = await runAdd({ cwd: dir, capability: 'ai:bedrock' });
|
|
1434
|
+
expect(rerun.ok).toBe(false);
|
|
1435
|
+
expect(rerun.reason).toBe('addon-already-exists');
|
|
1436
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
it('upserts BEDROCK_MODEL_ID in place only for opt-in keys', () => {
|
|
1440
|
+
const dir = makeTmp();
|
|
1441
|
+
writeMainTf(dir, '{ name = "BEDROCK_MODEL_ID", value = "model.a-first" },');
|
|
1442
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
1443
|
+
// Default callers keep skip-if-present behavior.
|
|
1444
|
+
expect(injectContainerEnvVars(before, [{ name: 'BEDROCK_MODEL_ID', value: 'model.b-second' }])).toBe(before);
|
|
1445
|
+
// Opt-in callers replace the value in place.
|
|
1446
|
+
const after = injectContainerEnvVars(
|
|
1447
|
+
before,
|
|
1448
|
+
[{ name: 'BEDROCK_MODEL_ID', value: 'model.b-second' }],
|
|
1449
|
+
'app',
|
|
1450
|
+
{ upsertKeys: ['BEDROCK_MODEL_ID'] }
|
|
1451
|
+
);
|
|
1452
|
+
expect(after).toContain('model.b-second');
|
|
1453
|
+
expect(after).not.toContain('model.a-first');
|
|
1454
|
+
expect(after.match(/BEDROCK_MODEL_ID/g)).toHaveLength(1);
|
|
1455
|
+
// Identical values leave the text untouched.
|
|
1456
|
+
expect(injectContainerEnvVars(
|
|
1457
|
+
after,
|
|
1458
|
+
[{ name: 'BEDROCK_MODEL_ID', value: 'model.b-second' }],
|
|
1459
|
+
'app',
|
|
1460
|
+
{ upsertKeys: ['BEDROCK_MODEL_ID'] }
|
|
1461
|
+
)).toBe(after);
|
|
1462
|
+
// JSON spelling and the options-object third argument also work.
|
|
1463
|
+
const jsonEnv = before.replace('{ name = "BEDROCK_MODEL_ID", value = "model.a-first" }', '{ "name": "BEDROCK_MODEL_ID", "value": "model.a-first" }');
|
|
1464
|
+
const jsonAfter = injectContainerEnvVars(
|
|
1465
|
+
jsonEnv,
|
|
1466
|
+
[{ name: 'BEDROCK_MODEL_ID', value: 'model.b-second' }],
|
|
1467
|
+
{ upsertKeys: ['BEDROCK_MODEL_ID'] }
|
|
1468
|
+
);
|
|
1469
|
+
expect(jsonAfter).toContain('"value": "model.b-second"');
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
it('upserts values containing terraform interpolations in place', () => {
|
|
1473
|
+
const dir = makeTmp();
|
|
1474
|
+
writeMainTf(dir, '{ "name": "REDIS_URL", "value": "redis://${aws_elasticache_cluster.redis.cache_nodes[0].address}:6379" },');
|
|
1475
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
1476
|
+
const after = injectContainerEnvVars(
|
|
1477
|
+
before,
|
|
1478
|
+
[{ name: 'REDIS_URL', value: 'redis://${aws_elasticache_replication_group.redis.primary_endpoint_address}:${aws_elasticache_replication_group.redis.port}' }],
|
|
1479
|
+
'app',
|
|
1480
|
+
{ upsertKeys: ['REDIS_URL'] }
|
|
1481
|
+
);
|
|
1482
|
+
expect(after).toContain('aws_elasticache_replication_group');
|
|
1483
|
+
expect(after).not.toContain('aws_elasticache_cluster');
|
|
1484
|
+
expect(after.match(/REDIS_URL/g)).toHaveLength(1);
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
describe('add: fuzzer hardening', () => {
|
|
1489
|
+
let exitSpy;
|
|
1490
|
+
let consoleSpy;
|
|
1491
|
+
|
|
1492
|
+
beforeEach(() => {
|
|
1493
|
+
vi.clearAllMocks();
|
|
1494
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
1495
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
afterEach(() => {
|
|
1499
|
+
exitSpy.mockRestore();
|
|
1500
|
+
consoleSpy.mockRestore();
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('routes unresolvable projects through PROJECT_NOT_INITIALIZED', async () => {
|
|
1504
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
1505
|
+
try {
|
|
1506
|
+
const result = await runAdd(null);
|
|
1507
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
1508
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1509
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
1510
|
+
success: false,
|
|
1511
|
+
error_code: 'PROJECT_NOT_INITIALIZED',
|
|
1512
|
+
}));
|
|
1513
|
+
} finally {
|
|
1514
|
+
cwdSpy.mockRestore();
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it.each([null, 42, true, { port: 'string' }])('parseAddArgs(%s) returns defaults', (bad) => {
|
|
1519
|
+
expect(parseAddArgs(bad)).toEqual({
|
|
1520
|
+
partitionKey: DEFAULT_PARTITION_KEY,
|
|
1521
|
+
model: DEFAULT_BEDROCK_MODEL,
|
|
1522
|
+
modelProvided: false,
|
|
1523
|
+
listModels: false,
|
|
1524
|
+
refresh: false,
|
|
1525
|
+
isHeadless: false,
|
|
1526
|
+
force: false,
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
describe('email:ses flag parsing', () => {
|
|
1532
|
+
it('parses --domain, --from-email, and --zone-id in both forms', () => {
|
|
1533
|
+
const spaced = parseAddArgs(['add', 'email:ses', '--domain', 'example.com', '--from-email', 'hi@example.com', '--zone-id', 'Z1']);
|
|
1534
|
+
expect(spaced).toMatchObject({ capability: 'email:ses', domain: 'example.com', fromEmail: 'hi@example.com', zoneId: 'Z1' });
|
|
1535
|
+
const joined = parseAddArgs(['add', 'email:ses', '--domain=example.com', '--from-email=hi@example.com', '--zone-id=Z1']);
|
|
1536
|
+
expect(joined).toMatchObject({ capability: 'email:ses', domain: 'example.com', fromEmail: 'hi@example.com', zoneId: 'Z1' });
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it('silently ignores SES flags on other capabilities', async () => {
|
|
1540
|
+
const dir = makeTmp();
|
|
1541
|
+
writeMainTf(dir);
|
|
1542
|
+
const result = await runAdd({ cwd: dir, capability: 'storage:s3', domain: 'not a domain!!!', zoneId: 'bogus' });
|
|
1543
|
+
expect(result.ok).toBe(true);
|
|
1544
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
1545
|
+
});
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
describe('email:ses pre-guard validation', () => {
|
|
1549
|
+
it('rejects an invalid --domain before the terraform guard', async () => {
|
|
1550
|
+
const result = await runAdd({ cwd: makeTmp(), capability: 'email:ses', domain: 'not a domain' });
|
|
1551
|
+
expect(result.ok).toBe(false);
|
|
1552
|
+
expect(result.reason).toBe('invalid-domain');
|
|
1553
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1554
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
1555
|
+
error_code: 'INVALID_DOMAIN',
|
|
1556
|
+
}));
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
it('rejects an invalid --zone-id before the terraform guard', async () => {
|
|
1560
|
+
const result = await runAdd({ cwd: makeTmp(), capability: 'email:ses', domain: 'example.com', zoneId: 'bogus' });
|
|
1561
|
+
expect(result.ok).toBe(false);
|
|
1562
|
+
expect(result.reason).toBe('invalid-zone-id');
|
|
1563
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
1564
|
+
error_code: 'INVALID_ZONE_ID',
|
|
1565
|
+
}));
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it('rejects a --from-email outside the explicit --domain', async () => {
|
|
1569
|
+
const dir = makeTmp();
|
|
1570
|
+
writeMainTf(dir);
|
|
1571
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses', domain: 'example.com', fromEmail: 'hi@other.com' });
|
|
1572
|
+
expect(result.ok).toBe(false);
|
|
1573
|
+
expect(result.reason).toBe('invalid-from-email');
|
|
1574
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'ses.tf'))).toBe(false);
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('defers --from-email validation until the domain resolves', async () => {
|
|
1578
|
+
const dir = makeTmp();
|
|
1579
|
+
writeMainTf(dir);
|
|
1580
|
+
// No explicit --domain and no domain.tf: the missing domain (not the
|
|
1581
|
+
// sender) is reported in headless mode.
|
|
1582
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses', fromEmail: 'hi@other.com' });
|
|
1583
|
+
expect(result.ok).toBe(false);
|
|
1584
|
+
expect(result.reason).toBe('missing-ses-domain');
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it('fails headless without a domain as MISSING_SES_DOMAIN', async () => {
|
|
1588
|
+
const dir = makeTmp();
|
|
1589
|
+
writeMainTf(dir);
|
|
1590
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses' });
|
|
1591
|
+
expect(result.ok).toBe(false);
|
|
1592
|
+
expect(result.reason).toBe('missing-ses-domain');
|
|
1593
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1594
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
1595
|
+
error_code: 'MISSING_SES_DOMAIN',
|
|
1596
|
+
}));
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it('rejects wildcard domains for SES identities', async () => {
|
|
1600
|
+
const dir = makeTmp();
|
|
1601
|
+
writeMainTf(dir);
|
|
1602
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses', domain: '*.example.com' });
|
|
1603
|
+
expect(result.ok).toBe(false);
|
|
1604
|
+
expect(result.reason).toBe('invalid-domain');
|
|
1605
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'ses.tf'))).toBe(false);
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
describe('email:ses generated terraform', () => {
|
|
1610
|
+
it('renders ses.tf with identity, DKIM, MAIL FROM, task role wiring, and outputs', async () => {
|
|
1611
|
+
const dir = makeTmp();
|
|
1612
|
+
writeMainTf(dir);
|
|
1613
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses', domain: 'Example.COM', region: 'eu-west-1' });
|
|
1614
|
+
expect(result.ok).toBe(true);
|
|
1615
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8');
|
|
1616
|
+
expect(rendered).toContain('resource "aws_ses_domain_identity" "ses"');
|
|
1617
|
+
expect(rendered).toContain('domain = "example.com"');
|
|
1618
|
+
expect(rendered).toContain('resource "aws_ses_domain_dkim" "ses"');
|
|
1619
|
+
expect(rendered).toContain('resource "aws_ses_domain_mail_from" "ses"');
|
|
1620
|
+
expect(rendered).toContain('mail_from_domain = "mail.example.com"');
|
|
1621
|
+
expect(rendered).toContain('role = aws_iam_role.task_role.id');
|
|
1622
|
+
expect(rendered).toContain('"ses:SendEmail"');
|
|
1623
|
+
expect(rendered).toContain('"ses:SendRawEmail"');
|
|
1624
|
+
expect(rendered).toContain('"ses:FromAddress"');
|
|
1625
|
+
expect(rendered).toContain('"*@example.com"');
|
|
1626
|
+
expect(rendered).toContain('"noreply@example.com"');
|
|
1627
|
+
expect(rendered).toContain('output "ses_domain_identity_arn"');
|
|
1628
|
+
expect(rendered).toContain('output "ses_verification_token"');
|
|
1629
|
+
expect(rendered).toContain('output "ses_dkim_tokens"');
|
|
1630
|
+
expect(rendered).not.toContain('aws_caller_identity');
|
|
1631
|
+
expect(rendered).not.toContain('{{SES_DOMAIN}}');
|
|
1632
|
+
expect(rendered).not.toContain('{{SES_FROM_EMAIL}}');
|
|
1633
|
+
expect(rendered).not.toContain('{{SES_ROUTE53_RECORDS_BLOCK}}');
|
|
1634
|
+
expect(rendered).not.toContain('{{REGION}}');
|
|
1635
|
+
// External-DNS mode: no Route 53 records.
|
|
1636
|
+
expect(rendered).not.toContain('aws_route53_record');
|
|
1637
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
1638
|
+
expect(mainTf).toContain('SES_FROM_EMAIL');
|
|
1639
|
+
expect(mainTf).toContain('noreply@example.com');
|
|
1640
|
+
expect(mainTf).toContain('SES_REGION');
|
|
1641
|
+
expect(mainTf).toContain('eu-west-1');
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it('renders Route 53 records with --zone-id and honors --from-email', async () => {
|
|
1645
|
+
const dir = makeTmp();
|
|
1646
|
+
writeMainTf(dir);
|
|
1647
|
+
writeWorkerTf(dir);
|
|
1648
|
+
const result = await runAdd({
|
|
1649
|
+
cwd: dir, capability: 'email:ses', domain: 'example.com',
|
|
1650
|
+
fromEmail: 'hello@example.com', zoneId: '/hostedzone/Z123', region: 'us-east-2',
|
|
1651
|
+
});
|
|
1652
|
+
expect(result.ok).toBe(true);
|
|
1653
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8');
|
|
1654
|
+
expect(rendered).toContain('resource "aws_route53_record" "ses_verification"');
|
|
1655
|
+
expect(rendered).toContain('resource "aws_route53_record" "ses_dkim"');
|
|
1656
|
+
expect(rendered).toContain('resource "aws_route53_record" "ses_mail_from_mx"');
|
|
1657
|
+
expect(rendered).toContain('resource "aws_route53_record" "ses_mail_from_spf"');
|
|
1658
|
+
expect(rendered).toContain('resource "aws_route53_record" "ses_dmarc"');
|
|
1659
|
+
expect(rendered).toContain('zone_id = "Z123"');
|
|
1660
|
+
expect(rendered).toContain('"10 feedback-smtp.us-east-2.amazonses.com"');
|
|
1661
|
+
expect(rendered).toContain('"hello@example.com"');
|
|
1662
|
+
const workerTf = fs.readFileSync(path.join(dir, 'terraform', 'worker.tf'), 'utf-8');
|
|
1663
|
+
expect(workerTf).toContain('SES_FROM_EMAIL');
|
|
1664
|
+
expect(workerTf).toContain('hello@example.com');
|
|
1665
|
+
expect(workerTf).toContain('SES_REGION');
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
it('auto-detects the domain and zone from terraform/domain.tf', async () => {
|
|
1669
|
+
const dir = makeTmp();
|
|
1670
|
+
writeMainTf(dir);
|
|
1671
|
+
fs.writeFileSync(
|
|
1672
|
+
path.join(dir, 'terraform', 'domain.tf'),
|
|
1673
|
+
[
|
|
1674
|
+
'# deploy-stack:domain-mode=route53',
|
|
1675
|
+
'# deploy-stack:zone-id=Z999',
|
|
1676
|
+
'resource "aws_acm_certificate" "domain" {',
|
|
1677
|
+
' domain_name = "shop.example.com"',
|
|
1678
|
+
'}',
|
|
1679
|
+
'',
|
|
1680
|
+
].join('\n')
|
|
1681
|
+
);
|
|
1682
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses', region: 'us-east-2' });
|
|
1683
|
+
expect(result.ok).toBe(true);
|
|
1684
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8');
|
|
1685
|
+
expect(rendered).toContain('domain = "shop.example.com"');
|
|
1686
|
+
expect(rendered).toContain('zone_id = "Z999"');
|
|
1687
|
+
expect(rendered).toContain('resource "aws_route53_record" "ses_dkim"');
|
|
1688
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8')).toContain('noreply@shop.example.com');
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it('prefers an explicit --zone-id over the domain.tf zone', async () => {
|
|
1692
|
+
const dir = makeTmp();
|
|
1693
|
+
writeMainTf(dir);
|
|
1694
|
+
fs.writeFileSync(
|
|
1695
|
+
path.join(dir, 'terraform', 'domain.tf'),
|
|
1696
|
+
'# deploy-stack:domain-mode=route53\n# deploy-stack:zone-id=Z999\nresource "aws_acm_certificate" "domain" {\n domain_name = "shop.example.com"\n}\n'
|
|
1697
|
+
);
|
|
1698
|
+
await runAdd({ cwd: dir, capability: 'email:ses', zoneId: 'Z111', region: 'us-east-2' });
|
|
1699
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8');
|
|
1700
|
+
expect(rendered).toContain('domain = "shop.example.com"');
|
|
1701
|
+
expect(rendered).toContain('zone_id = "Z111"');
|
|
1702
|
+
expect(rendered).not.toContain('Z999');
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
it('updates SES env values in place on --force reruns', async () => {
|
|
1706
|
+
const dir = makeTmp();
|
|
1707
|
+
writeMainTf(dir);
|
|
1708
|
+
await runAdd({ cwd: dir, capability: 'email:ses', domain: 'example.com', fromEmail: 'a@example.com', region: 'us-east-2' });
|
|
1709
|
+
const rerun = await runAdd({ cwd: dir, capability: 'email:ses', domain: 'example.com', fromEmail: 'b@example.com', region: 'eu-west-1', force: true });
|
|
1710
|
+
expect(rerun.ok).toBe(true);
|
|
1711
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
1712
|
+
expect(mainTf).toContain('b@example.com');
|
|
1713
|
+
expect(mainTf).not.toContain('a@example.com');
|
|
1714
|
+
expect(mainTf).toContain('eu-west-1');
|
|
1715
|
+
expect(mainTf.match(/SES_FROM_EMAIL/g)).toHaveLength(1);
|
|
1716
|
+
expect(mainTf.match(/SES_REGION/g)).toHaveLength(1);
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it('guards SES identity resources to the default workspace', async () => {
|
|
1720
|
+
const dir = makeTmp();
|
|
1721
|
+
writeMainTf(dir);
|
|
1722
|
+
await runAdd({ cwd: dir, capability: 'email:ses', domain: 'example.com', region: 'us-east-2' });
|
|
1723
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8');
|
|
1724
|
+
const guards = rendered.split('= terraform.workspace == "default" ? 1 : 0').length - 1;
|
|
1725
|
+
expect(guards).toBe(3);
|
|
1726
|
+
expect(rendered).toContain('aws_ses_domain_identity.ses[0].domain');
|
|
1727
|
+
expect(rendered).toContain('value = terraform.workspace == "default" ? aws_ses_domain_identity.ses[0].arn : null');
|
|
1728
|
+
expect(rendered).toContain('value = terraform.workspace == "default" ? aws_ses_domain_identity.ses[0].verification_token : null');
|
|
1729
|
+
expect(rendered).toContain('value = terraform.workspace == "default" ? aws_ses_domain_dkim.ses[0].dkim_tokens : []');
|
|
1730
|
+
// IAM policy stays unguarded: per-workspace role over the shared identity.
|
|
1731
|
+
const policyStart = rendered.indexOf('resource "aws_iam_role_policy" "ses_send"');
|
|
1732
|
+
const policyBlock = rendered.slice(policyStart, rendered.indexOf('\n}\n', policyStart));
|
|
1733
|
+
expect(policyBlock).not.toContain('terraform.workspace');
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it('guards SES Route 53 records to the default workspace', async () => {
|
|
1737
|
+
const dir = makeTmp();
|
|
1738
|
+
writeMainTf(dir);
|
|
1739
|
+
await runAdd({ cwd: dir, capability: 'email:ses', domain: 'example.com', zoneId: 'Z123', region: 'us-east-2' });
|
|
1740
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8');
|
|
1741
|
+
expect(rendered).toContain('count = terraform.workspace == "default" ? 3 : 0');
|
|
1742
|
+
expect(rendered).toContain('aws_ses_domain_dkim.ses[0].dkim_tokens[count.index]');
|
|
1743
|
+
expect(rendered).toContain('[aws_ses_domain_identity.ses[0].verification_token]');
|
|
1744
|
+
const guards = rendered.split('count = terraform.workspace == "default" ? 1 : 0').length - 1;
|
|
1745
|
+
expect(guards).toBe(4);
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
describe('email:ses interactive prompt', () => {
|
|
1750
|
+
beforeEach(() => {
|
|
1751
|
+
vi.mocked(text).mockReset();
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
it('prompts for the domain and honors cancellation with telemetry', async () => {
|
|
1755
|
+
const dir = makeTmp();
|
|
1756
|
+
writeMainTf(dir);
|
|
1757
|
+
vi.mocked(text).mockResolvedValueOnce('prompted.example.com');
|
|
1758
|
+
const result = await runAdd({ cwd: dir, capability: 'email:ses', interactive: true, region: 'us-east-2' });
|
|
1759
|
+
expect(result.ok).toBe(true);
|
|
1760
|
+
expect(vi.mocked(text)).toHaveBeenCalledWith(expect.objectContaining({
|
|
1761
|
+
message: expect.stringContaining('Amazon SES'),
|
|
1762
|
+
}));
|
|
1763
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'ses.tf'), 'utf-8'))
|
|
1764
|
+
.toContain('domain = "prompted.example.com"');
|
|
1765
|
+
|
|
1766
|
+
const dir2 = makeTmp();
|
|
1767
|
+
writeMainTf(dir2);
|
|
1768
|
+
vi.mocked(text).mockResolvedValueOnce(Symbol('clack:cancel'));
|
|
1769
|
+
const cancelled = await runAdd({ cwd: dir2, capability: 'email:ses', interactive: true });
|
|
1770
|
+
expect(cancelled).toEqual({ ok: false, reason: 'cancelled' });
|
|
1771
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({
|
|
1772
|
+
capability: 'email:ses', success: false, reason: 'cancelled',
|
|
1773
|
+
}));
|
|
1774
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
1775
|
+
expect(fs.existsSync(path.join(dir2, 'terraform', 'ses.tf'))).toBe(false);
|
|
1776
|
+
});
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
describe('cron flag parsing', () => {
|
|
1780
|
+
it('parses --schedule, --cmd/--cron-command, --name, and --timezone', () => {
|
|
1781
|
+
const options = parseAddArgs(['add', 'cron', '--schedule', 'rate(1 hour)', '--cmd', 'node scripts/cleanup.js', '--name', 'nightly', '--timezone', 'America/New_York']);
|
|
1782
|
+
expect(options).toMatchObject({
|
|
1783
|
+
capability: 'cron',
|
|
1784
|
+
schedule: 'rate(1 hour)',
|
|
1785
|
+
cronCommand: 'node scripts/cleanup.js',
|
|
1786
|
+
name: 'nightly',
|
|
1787
|
+
timezone: 'America/New_York',
|
|
1788
|
+
});
|
|
1789
|
+
expect(parseAddArgs(['add', 'cron', '--cron-command=node job.js']).cronCommand).toBe('node job.js');
|
|
1790
|
+
});
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
describe('cron pre-guard validation', () => {
|
|
1794
|
+
it('rejects bad schedules, timezones, and empty names', () => {
|
|
1795
|
+
expect(validateAddonFlags('cron', { schedule: 'every friday' }).errorCode).toBe('INVALID_CRON_SCHEDULE');
|
|
1796
|
+
expect(validateAddonFlags('cron', { schedule: 'cron(0 2 * * ? *)' })).toBeNull();
|
|
1797
|
+
expect(validateAddonFlags('cron', { timezone: 'Mars/Olympus?' }).errorCode).toBe('INVALID_TIMEZONE');
|
|
1798
|
+
expect(validateAddonFlags('cron', { timezone: 'UTC' })).toBeNull();
|
|
1799
|
+
expect(validateAddonFlags('cron', { name: '!!!' }).errorCode).toBe('INVALID_CRON_NAME');
|
|
1800
|
+
expect(validateAddonFlags('cron', {})).toBeNull();
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it('sanitizes job slugs to lowercase [a-z0-9-]', () => {
|
|
1804
|
+
expect(sanitizeCronName('Nightly_Cleanup!!')).toBe('nightly-cleanup');
|
|
1805
|
+
expect(sanitizeCronName('---')).toBe('');
|
|
1806
|
+
expect(sanitizeCronName('a'.repeat(40))).toHaveLength(32);
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
describe('cron generated terraform', () => {
|
|
1811
|
+
it('renders cron.tf with headless defaults and no placeholders', async () => {
|
|
1812
|
+
const dir = makeTmp();
|
|
1813
|
+
writeMainTf(dir);
|
|
1814
|
+
const result = await runAdd({ cwd: dir, capability: 'cron', region: 'us-east-2' });
|
|
1815
|
+
expect(result.ok).toBe(true);
|
|
1816
|
+
expect(result.file).toBe('terraform/cron.tf');
|
|
1817
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'cron.tf'), 'utf-8');
|
|
1818
|
+
expect(rendered).toMatch(new RegExp(`schedule_expression\\s+= "${DEFAULT_CRON_SCHEDULE.replace(/[()*?]/g, '\\$&')}"`));
|
|
1819
|
+
expect(rendered).toContain('schedule_expression_timezone = "UTC"');
|
|
1820
|
+
expect(rendered).toMatch(/name\s+= "\$\{local\.app_name\}-cron-daily-job"/);
|
|
1821
|
+
expect(rendered).toContain(`["sh", "-c", ${JSON.stringify(DEFAULT_CRON_COMMAND)}]`);
|
|
1822
|
+
expect(rendered).toContain('resource "aws_scheduler_schedule" "cron"');
|
|
1823
|
+
expect(rendered).toContain('resource "aws_iam_role" "scheduler_cron_role"');
|
|
1824
|
+
expect(rendered).not.toContain('{{');
|
|
1825
|
+
// Bare single references keep tflint clean (no "${...}" wrappers).
|
|
1826
|
+
expect(rendered).toContain('role = aws_iam_role.scheduler_cron_role.id');
|
|
1827
|
+
expect(rendered).toContain('arn = aws_ecs_cluster.main.arn');
|
|
1828
|
+
expect(rendered).toContain('task_definition_arn = aws_ecs_task_definition.app.arn');
|
|
1829
|
+
// Revision-proof RunTask grant: pinned ARN plus family wildcard.
|
|
1830
|
+
expect(rendered).toContain('aws_ecs_task_definition.app.arn,');
|
|
1831
|
+
expect(rendered).toContain('"${aws_ecs_task_definition.app.arn_without_revision}:*"');
|
|
1832
|
+
expect(trackEvent).toHaveBeenCalledWith('add_run', expect.objectContaining({ capability: 'cron', success: true }));
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
it('honors explicit flags and JSON-escapes the command', async () => {
|
|
1836
|
+
const dir = makeTmp();
|
|
1837
|
+
writeMainTf(dir);
|
|
1838
|
+
await runAdd({
|
|
1839
|
+
cwd: dir,
|
|
1840
|
+
capability: 'cron',
|
|
1841
|
+
region: 'us-east-2',
|
|
1842
|
+
schedule: 'rate(1 hour)',
|
|
1843
|
+
cronCommand: 'node -e "console.log(1)"',
|
|
1844
|
+
name: 'Hourly_Job',
|
|
1845
|
+
timezone: 'America/New_York',
|
|
1846
|
+
});
|
|
1847
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'cron.tf'), 'utf-8');
|
|
1848
|
+
expect(rendered).toMatch(/schedule_expression\s+= "rate\(1 hour\)"/);
|
|
1849
|
+
expect(rendered).toContain('"${local.app_name}-cron-hourly-job"');
|
|
1850
|
+
expect(rendered).toContain(`["sh", "-c", ${JSON.stringify('node -e "console.log(1)"')}]`);
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it('injects no container env vars and skips the env list in outro', async () => {
|
|
1854
|
+
const dir = makeTmp();
|
|
1855
|
+
writeMainTf(dir);
|
|
1856
|
+
const { outro } = await import('@clack/prompts');
|
|
1857
|
+
const result = await runAdd({ cwd: dir, capability: 'cron', region: 'us-east-2' });
|
|
1858
|
+
expect(result.envInjected).toBe(false);
|
|
1859
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8')).not.toContain('CRON');
|
|
1860
|
+
expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.not.stringContaining('Available in your container'));
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it('requires --force to replace the single schedule in place', async () => {
|
|
1864
|
+
const dir = makeTmp();
|
|
1865
|
+
writeMainTf(dir);
|
|
1866
|
+
await runAdd({ cwd: dir, capability: 'cron', region: 'us-east-2' });
|
|
1867
|
+
const second = await runAdd({ cwd: dir, capability: 'cron', region: 'us-east-2', schedule: 'rate(1 hour)' });
|
|
1868
|
+
expect(second).toMatchObject({ ok: false, reason: 'addon-already-exists' });
|
|
1869
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
1870
|
+
const forced = await runAdd({ cwd: dir, capability: 'cron', region: 'us-east-2', schedule: 'rate(1 hour)', force: true });
|
|
1871
|
+
expect(forced.ok).toBe(true);
|
|
1872
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'cron.tf'), 'utf-8')).toContain('rate(1 hour)');
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it('rejects schedule names over the 64-char Scheduler limit', async () => {
|
|
1876
|
+
const dir = makeTmp();
|
|
1877
|
+
writeMainTf(dir);
|
|
1878
|
+
const resolved = await resolveAddonOptions('cron', { projectName: 'a'.repeat(60), name: 'daily-job' }, { cwd: dir, region: 'us-east-2' });
|
|
1879
|
+
expect(resolved.ok).toBe(false);
|
|
1880
|
+
expect(resolved.errorCode).toBe('INVALID_CRON_NAME');
|
|
1881
|
+
});
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
describe('cron interactive prompt', () => {
|
|
1885
|
+
beforeEach(() => {
|
|
1886
|
+
vi.mocked(select).mockReset();
|
|
1887
|
+
vi.mocked(text).mockReset();
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
it('offers schedule presets and honors cancellation', async () => {
|
|
1891
|
+
const dir = makeTmp();
|
|
1892
|
+
writeMainTf(dir);
|
|
1893
|
+
vi.mocked(select).mockResolvedValueOnce('rate(15 minutes)');
|
|
1894
|
+
vi.mocked(text).mockResolvedValueOnce('node scripts/cleanup.js');
|
|
1895
|
+
const result = await runAdd({ cwd: dir, capability: 'cron', interactive: true, region: 'us-east-2' });
|
|
1896
|
+
expect(result.ok).toBe(true);
|
|
1897
|
+
expect(vi.mocked(select)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Choose a schedule:' }));
|
|
1898
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'cron.tf'), 'utf-8');
|
|
1899
|
+
expect(rendered).toContain('rate(15 minutes)');
|
|
1900
|
+
expect(rendered).toContain(JSON.stringify('node scripts/cleanup.js'));
|
|
1901
|
+
|
|
1902
|
+
const dir2 = makeTmp();
|
|
1903
|
+
writeMainTf(dir2);
|
|
1904
|
+
vi.mocked(select).mockResolvedValueOnce(Symbol('clack:cancel'));
|
|
1905
|
+
const cancelled = await runAdd({ cwd: dir2, capability: 'cron', interactive: true });
|
|
1906
|
+
expect(cancelled).toEqual({ ok: false, reason: 'cancelled' });
|
|
1907
|
+
expect(fs.existsSync(path.join(dir2, 'terraform', 'cron.tf'))).toBe(false);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it('validates custom expressions from the prompt', async () => {
|
|
1911
|
+
const dir = makeTmp();
|
|
1912
|
+
writeMainTf(dir);
|
|
1913
|
+
vi.mocked(select).mockResolvedValueOnce('__custom__');
|
|
1914
|
+
vi.mocked(text).mockResolvedValueOnce('whenever');
|
|
1915
|
+
const result = await runAdd({ cwd: dir, capability: 'cron', interactive: true, region: 'us-east-2' });
|
|
1916
|
+
expect(result.ok).toBe(false);
|
|
1917
|
+
expect(result.reason).toBe('invalid-cron-schedule');
|
|
1918
|
+
});
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
const LAMBDA_MAIN_TF = [
|
|
1922
|
+
'locals {',
|
|
1923
|
+
' app_name = "myapp${local.env_suffix}"',
|
|
1924
|
+
'}',
|
|
1925
|
+
'',
|
|
1926
|
+
'resource "aws_lambda_function" "app" {',
|
|
1927
|
+
' function_name = "${local.app_name}-fn"',
|
|
1928
|
+
' environment {',
|
|
1929
|
+
' variables = {',
|
|
1930
|
+
' PORT = "3000"',
|
|
1931
|
+
' AWS_LWA_PORT = "3000"',
|
|
1932
|
+
' APP_SECRETS_ARN = local.secret_arn',
|
|
1933
|
+
' }',
|
|
1934
|
+
' }',
|
|
1935
|
+
'}',
|
|
1936
|
+
'',
|
|
1937
|
+
].join('\n');
|
|
1938
|
+
|
|
1939
|
+
function writeLambdaMainTf(dir, mainTf = LAMBDA_MAIN_TF) {
|
|
1940
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
1941
|
+
fs.writeFileSync(path.join(dir, 'terraform', 'main.tf'), mainTf);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
describe('injectLambdaEnvVars', () => {
|
|
1945
|
+
it('inserts map entries and renders lone interpolations as bare references', () => {
|
|
1946
|
+
const updated = injectLambdaEnvVars(LAMBDA_MAIN_TF, S3_ENV);
|
|
1947
|
+
expect(updated).toContain('S3_BUCKET_NAME = aws_s3_bucket.storage.id');
|
|
1948
|
+
expect(updated).toContain('S3_CDN_URL = "https://${aws_cloudfront_distribution.storage_cdn.domain_name}"');
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
it('skips existing keys and replaces upsert keys in place', () => {
|
|
1952
|
+
const withVar = injectLambdaEnvVars(LAMBDA_MAIN_TF, [{ name: 'BEDROCK_MODEL_ID', value: 'old.model' }]);
|
|
1953
|
+
expect(withVar).toContain('BEDROCK_MODEL_ID = "old.model"');
|
|
1954
|
+
|
|
1955
|
+
const rerun = injectLambdaEnvVars(withVar, [{ name: 'BEDROCK_MODEL_ID', value: 'new.model' }]);
|
|
1956
|
+
expect(rerun).toBe(withVar);
|
|
1957
|
+
|
|
1958
|
+
const upserted = injectLambdaEnvVars(
|
|
1959
|
+
withVar,
|
|
1960
|
+
[{ name: 'BEDROCK_MODEL_ID', value: 'new.model' }],
|
|
1961
|
+
{ upsertKeys: ['BEDROCK_MODEL_ID'] }
|
|
1962
|
+
);
|
|
1963
|
+
expect(upserted).toContain('BEDROCK_MODEL_ID = "new.model"');
|
|
1964
|
+
expect(upserted).not.toContain('"old.model"');
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
it('returns ECS content and empty input unchanged', () => {
|
|
1968
|
+
const ecsMainTf = 'resource "aws_ecs_task_definition" "app" {}\n';
|
|
1969
|
+
expect(injectLambdaEnvVars(ecsMainTf, S3_ENV)).toBe(ecsMainTf);
|
|
1970
|
+
expect(injectLambdaEnvVars(LAMBDA_MAIN_TF, [])).toBe(LAMBDA_MAIN_TF);
|
|
1971
|
+
});
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
describe('ensureLambdaVpcConfig', () => {
|
|
1975
|
+
it('attaches vpc_config once and leaves ECS projects untouched', () => {
|
|
1976
|
+
const once = ensureLambdaVpcConfig(LAMBDA_MAIN_TF);
|
|
1977
|
+
expect(once).toContain('vpc_config {');
|
|
1978
|
+
expect(once).toContain('security_group_ids = [aws_security_group.ecs_tasks.id]');
|
|
1979
|
+
expect(ensureLambdaVpcConfig(once)).toBe(once);
|
|
1980
|
+
expect(ensureLambdaVpcConfig('resource "aws_ecs_service" "app" {}')).toBe('resource "aws_ecs_service" "app" {}');
|
|
1981
|
+
});
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
describe('lambda generated terraform', () => {
|
|
1985
|
+
it('injects addon env vars into the function variables map', async () => {
|
|
1986
|
+
const dir = makeTmp();
|
|
1987
|
+
writeLambdaMainTf(dir);
|
|
1988
|
+
const result = await runAdd({ cwd: dir, capability: 'storage:s3' });
|
|
1989
|
+
expect(result.ok).toBe(true);
|
|
1990
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
1991
|
+
expect(mainTf).toContain('S3_BUCKET_NAME = aws_s3_bucket.storage.id');
|
|
1992
|
+
expect(mainTf).toContain('S3_CDN_URL = "https://${aws_cloudfront_distribution.storage_cdn.domain_name}"');
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
it('scaffolds the Lambda-targeted cron schedule', async () => {
|
|
1996
|
+
const dir = makeTmp();
|
|
1997
|
+
writeLambdaMainTf(dir);
|
|
1998
|
+
const result = await runAdd({ cwd: dir, capability: 'cron', region: 'us-east-2' });
|
|
1999
|
+
expect(result.ok).toBe(true);
|
|
2000
|
+
expect(result.file).toBe('terraform/cron.tf');
|
|
2001
|
+
const rendered = fs.readFileSync(path.join(dir, 'terraform', 'cron.tf'), 'utf-8');
|
|
2002
|
+
expect(rendered).toContain('arn = aws_lambda_function.app.arn');
|
|
2003
|
+
expect(rendered).toContain('"lambda:InvokeFunction"');
|
|
2004
|
+
expect(rendered).toContain(`command = ${JSON.stringify(DEFAULT_CRON_COMMAND)}`);
|
|
2005
|
+
expect(rendered).not.toContain('{{');
|
|
2006
|
+
expect(rendered).not.toContain('aws_ecs_cluster');
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
it('attaches vpc_config when adding redis to a non-VPC function', async () => {
|
|
2010
|
+
const dir = makeTmp();
|
|
2011
|
+
writeLambdaMainTf(dir);
|
|
2012
|
+
const result = await runAdd({ cwd: dir, capability: 'db:redis' });
|
|
2013
|
+
expect(result.ok).toBe(true);
|
|
2014
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
2015
|
+
expect(mainTf).toContain('vpc_config {');
|
|
2016
|
+
expect(mainTf).toContain('REDIS_URL = ');
|
|
2017
|
+
});
|
|
2018
|
+
});
|