grada-run 0.0.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +29 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +75 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +105 -6
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +127 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +126 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +65 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Spec: Modular Service Engine (`deploy-stack add`) — `storage:s3` & `db:dynamodb`
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Introduce the `deploy-stack add <capability>` command to provision modular Day-2 cloud primitives without requiring users to write Terraform, configure IAM policies, or open the AWS Management Console. This implementation establishes the table-driven `ADDON_REGISTRY` architecture, consolidates project/region resolution in `src/utils/resolvers.js`, and delivers two scale-to-zero primitives:
|
|
5
|
+
1. **`deploy-stack add storage:s3`**: Private S3 bucket with CloudFront Origin Access Control (OAC), CORS rules for browser uploads, and least-privilege IAM permissions on `aws_iam_role.task_role`.
|
|
6
|
+
2. **`deploy-stack add db:dynamodb`**: Scale-to-zero (`PAY_PER_REQUEST`) DynamoDB table with Point-in-Time Recovery (PITR), a free VPC Gateway Endpoint wired to `aws_route_table.public`, and least-privilege IAM task permissions.
|
|
7
|
+
|
|
8
|
+
Because addon files (`terraform/s3.tf`, `terraform/dynamodb.tf`) live directly inside `terraform/` and use `local.app_name`, `deploy-stack destroy` automatically tears them down as part of the Terraform state, `deploy-stack eject` naturally leaves them in place as standard HCL files, and PR-preview workspaces deliberately receive isolated per-workspace buckets/tables.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Part 1: Resolver Deduplication & CLI Dispatch (`src/utils/resolvers.js`, `src/commands/add.js`, `bin/cli.js`)
|
|
13
|
+
|
|
14
|
+
### 1. Extend `src/utils/resolvers.js` (Single Source of Truth)
|
|
15
|
+
Instead of duplicating flag/file/basename resolution inside `add.js`, extend `src/utils/resolvers.js`:
|
|
16
|
+
* **`resolveProjectName(options = {}, cwd = process.cwd())`:**
|
|
17
|
+
1. If `options.projectName` is provided (mapped from `--project-name`), return it.
|
|
18
|
+
2. Otherwise, if `terraform/main.tf` exists in `cwd`, extract the project name from the rendered file by matching either:
|
|
19
|
+
* `app_name\s*=\s*"([^"$]+)\$\{local\.env_suffix\}"` (from `locals`), or
|
|
20
|
+
* `resource\s+"aws_ecr_repository"\s+"app"\s*\{[^}]*?name\s*=\s*"([^"]+)-repo"`
|
|
21
|
+
3. Fall back to `path.basename(cwd)`.
|
|
22
|
+
* Reuse `resolveRegion(options, cwd)` and `resolveProjectName(options, cwd)` in `src/commands/add.js`.
|
|
23
|
+
|
|
24
|
+
### 2. Command Signature & `ADDON_REGISTRY`
|
|
25
|
+
```bash
|
|
26
|
+
deploy-stack add <capability> [--region <region>] [--project-name <name>] [--partition-key <key>] [--force]
|
|
27
|
+
```
|
|
28
|
+
* Keep the `add` branch in `bin/cli.js` thin (and add a `deploy-stack add <capability>` line to `HELP_TEXT`), delegating capability lookup to a table-driven `ADDON_REGISTRY` inside `src/commands/add.js`.
|
|
29
|
+
* **Supported `<capability>` keys:** `'storage:s3'` and `'db:dynamodb'`.
|
|
30
|
+
* **Flag Scoping & Validation:**
|
|
31
|
+
* `parseAddArgs(args)` must support both `--flag value` and `--flag=value` forms, mapping `--project-name` to `projectName`, `--partition-key` to `partitionKey` (default `'id'`), `--region` to `region`, and `--force` (supporting both bare `--force` -> `true` and `--force=true` / `--force=false` for parity with other CLI parsers).
|
|
32
|
+
* `--partition-key` applies only to `db:dynamodb` (ignore if not passed; if passed, validate that it matches `/^[a-zA-Z0-9_.-]+$/` so it cannot break HCL interpolation, throwing a validation error with `error_code: 'INVALID_PARTITION_KEY'` otherwise).
|
|
33
|
+
* **Unknown / Missing Capability:**
|
|
34
|
+
* If `<capability>` is omitted or not in `ADDON_REGISTRY`, print a clear error listing supported capabilities (`storage:s3`, `db:dynamodb`), emit `trackEvent('add_run', { capability: capability || 'none', success: false, error_code: 'UNSUPPORTED_CAPABILITY' })`, `await flushTelemetry()`, and exit/throw.
|
|
35
|
+
|
|
36
|
+
### 3. Preconditions & Idempotency
|
|
37
|
+
* **Terraform Project Guard:** Verify that `terraform/main.tf` exists in `cwd`. If missing, print `No terraform/main.tf found. Run "deploy-stack init" first before adding services.`, emit `trackEvent('add_run', { capability, success: false, error_code: 'TERRAFORM_NOT_INITIALIZED' })`, `await flushTelemetry()`, and exit/throw.
|
|
38
|
+
* **Idempotency & `--force` Guard:**
|
|
39
|
+
* `storage:s3` writes `terraform/s3.tf`.
|
|
40
|
+
* `db:dynamodb` writes `terraform/dynamodb.tf`.
|
|
41
|
+
* If the target `.tf` file already exists and `--force` is **not** set, print a warning (`terraform/<file>.tf already exists. Pass --force to overwrite.`), emit `trackEvent('add_run', { projectName, capability, success: false, error_code: 'ADDON_ALREADY_EXISTS' })`, `await flushTelemetry()`, and return without modifying files.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Part 2: IAM Role Binding & Container Environment Injection (`src/commands/add.js`)
|
|
46
|
+
|
|
47
|
+
### 1. Attach Policies to Existing `aws_iam_role.task_role`
|
|
48
|
+
* `templates/terraform/main.tf` already defines `aws_iam_role.task_role` (line 76) and wires `task_role_arn = aws_iam_role.task_role.arn` (line 116).
|
|
49
|
+
* Do **not** modify `templates/terraform/main.tf` or create a duplicate role. Reference `role = aws_iam_role.task_role.id` directly in the `aws_iam_role_policy` blocks inside `s3.tf` and `dynamodb.tf`.
|
|
50
|
+
|
|
51
|
+
### 2. Deterministic `injectContainerEnvVars(mainTfContent, envEntries)`
|
|
52
|
+
The user's generated `terraform/main.tf` contains an already-rendered `environment = [...]` block inside `aws_ecs_task_definition.app` (`container_definitions = jsonencode([...])`), potentially alongside compose env vars, database env vars, and secondary containers.
|
|
53
|
+
* **Anchor Strategy:**
|
|
54
|
+
1. Locate `resource "aws_ecs_task_definition" "app"` in `terraform/main.tf`.
|
|
55
|
+
2. Find the **first** `environment = [` array inside that resource (which belongs to the primary app container, prior to any secondary containers).
|
|
56
|
+
3. Walk the balanced square brackets `[...]` of that `environment` array.
|
|
57
|
+
4. For each `{ name, value }` in `envEntries`:
|
|
58
|
+
* Check if `{ name = "<NAME>"` (or `"name": "<NAME>"`) already exists inside that `environment` block. If it already exists, skip inserting a duplicate (ensuring idempotent reruns with `--force`).
|
|
59
|
+
* Otherwise, insert `{ name = "${entry.name}", value = "${entry.value}" }` cleanly before the closing `]` of that `environment` array.
|
|
60
|
+
* **Injected Variables:**
|
|
61
|
+
* For `storage:s3`:
|
|
62
|
+
* `S3_BUCKET_NAME` -> `${aws_s3_bucket.storage.id}` * `S3_CDN_URL` -> `https://${aws_cloudfront_distribution.storage_cdn.domain_name}`
|
|
63
|
+
* For `db:dynamodb`:
|
|
64
|
+
* `DYNAMODB_TABLE_NAME` -> `${aws_dynamodb_table.main.name}` --- ## Part 3: Capability 1 — `storage:s3` (`templates/terraform/addons/s3.tf` -> `terraform/s3.tf`) Render `terraform/s3.tf` with the following specifications: 1. **Workspace-Aware Naming & S3 63-Character / Lowercase Rules:** * Include `data "aws_caller_identity" "current" {}` in `s3.tf`. * Because `-storage-<12-digit-account-id>` takes 21 characters, truncate and sanitize the prefix (`local.app_name`) to at most 42 characters, lowercase it, and strip any trailing hyphen **before** appending `-storage-${data.aws_caller_identity.current.account_id}` so the full account ID is never truncated and S3 naming rules are always satisfied:
|
|
65
|
+
`bucket = "${trimsuffix(substr(lower(local.app_name), 0, 42), "-")}-storage-${data.aws_caller_identity.current.account_id}"`
|
|
66
|
+
2. **Private S3 Bucket (`aws_s3_bucket.storage`):**
|
|
67
|
+
* `aws_s3_bucket_public_access_block.storage`: `block_public_acls = true`, `block_public_policy = true`, `ignore_public_acls = true`, `restrict_public_buckets = true`.
|
|
68
|
+
* `aws_s3_bucket_server_side_encryption_configuration.storage`: `sse_algorithm = "AES256"`.
|
|
69
|
+
* `aws_s3_bucket_cors_configuration.storage`: Allow `["GET", "PUT", "POST", "DELETE", "HEAD"]`, `allowed_origins = ["*"]` (include an HCL comment noting this is intentionally open for zero-config presigned uploads and can be restricted to the app domain), `allowed_headers = ["*"]`, `expose_headers = ["ETag"]`, `max_age_seconds = 3000`.
|
|
70
|
+
3. **CloudFront Distribution (`aws_cloudfront_distribution.storage_cdn`):**
|
|
71
|
+
* Coexists cleanly alongside `aws_cloudfront_distribution.cdn` (the ALB distribution in `main.tf`).
|
|
72
|
+
* `aws_cloudfront_origin_access_control.storage_oac`: `origin_access_control_origin_type = "s3"`, `signing_behavior = "always"`, `signing_protocol = "sigv4"`.
|
|
73
|
+
* `aws_cloudfront_distribution.storage_cdn`: Point origin to `aws_s3_bucket.storage.bucket_regional_domain_name` with `origin_access_control_id = aws_cloudfront_origin_access_control.storage_oac.id`, `viewer_protocol_policy = "redirect-to-https"`, `allowed_methods = ["GET", "HEAD", "OPTIONS"]`, `cached_methods = ["GET", "HEAD"]`, `cloudfront_default_certificate = true`, and standard `forwarded_values` (`query_string = false`, `cookies { forward = "none" }`).
|
|
74
|
+
* `aws_s3_bucket_policy.storage_oac_policy`: Grant principal `cloudfront.amazonaws.com` action `s3:GetObject` on `${aws_s3_bucket.storage.arn}/*` conditioned on `StringEquals = { "AWS:SourceArn" = aws_cloudfront_distribution.storage_cdn.arn }`. 4. **Least-Privilege IAM Policy (`aws_iam_role_policy.storage_s3_access`):** * Attach to `role = aws_iam_role.task_role.id` granting `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`, `s3:ListBucket` on `aws_s3_bucket.storage.arn` and `${aws_s3_bucket.storage.arn}/*`.
|
|
75
|
+
5. **Outputs:**
|
|
76
|
+
* `output "s3_bucket_name" { value = aws_s3_bucket.storage.id }`
|
|
77
|
+
* `output "s3_cdn_domain" { value = aws_cloudfront_distribution.storage_cdn.domain_name }`
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Part 4: Capability 2 — `db:dynamodb` (`templates/terraform/addons/dynamodb.tf` -> `terraform/dynamodb.tf`)
|
|
82
|
+
|
|
83
|
+
Render `terraform/dynamodb.tf` with the following specifications:
|
|
84
|
+
|
|
85
|
+
1. **Workspace-Aware Scale-to-Zero Table (`aws_dynamodb_table.main`):**
|
|
86
|
+
* `name = "${local.app_name}-table"` (deliberately isolates PR-preview workspaces with their own table via `local.app_name`). * `billing_mode = "PAY_PER_REQUEST"` * `hash_key = "{{PARTITION_KEY}}"` (defaults to `"id"`, fixed type `"S"`, no sort key/GSI in v1) * `attribute { name = "{{PARTITION_KEY}}", type = "S" }` * `point_in_time_recovery { enabled = true }` * `server_side_encryption { enabled = true }` 2. **Free VPC Gateway Endpoint (`aws_vpc_endpoint.dynamodb`):** * `vpc_id = aws_vpc.main.id` * `service_name = "com.amazonaws.{{REGION}}.dynamodb"` * `vpc_endpoint_type = "Gateway"` * `route_table_ids = [aws_route_table.public.id]` (referencing `aws_route_table.public` defined in `terraform/network.tf`). 3. **Least-Privilege IAM Policy (`aws_iam_role_policy.dynamodb_access`):** * Attach to `role = aws_iam_role.task_role.id` granting `dynamodb:GetItem`, `dynamodb:PutItem`, `dynamodb:UpdateItem`, `dynamodb:DeleteItem`, `dynamodb:Query`, `dynamodb:Scan`, `dynamodb:BatchGetItem`, `dynamodb:BatchWriteItem`, `dynamodb:DescribeTable` on `aws_dynamodb_table.main.arn` and `${aws_dynamodb_table.main.arn}/index/*`.
|
|
87
|
+
4. **Outputs:**
|
|
88
|
+
* `output "dynamodb_table_name" { value = aws_dynamodb_table.main.name }`
|
|
89
|
+
* `output "dynamodb_table_arn" { value = aws_dynamodb_table.main.arn }`
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Part 5: UX, Docs & Testing
|
|
94
|
+
|
|
95
|
+
1. **Terminal UX & Telemetry:**
|
|
96
|
+
* Display `intro(' deploy-stack add 🧩 ')`, log created/updated files and injected env vars (`S3_BUCKET_NAME`, `S3_CDN_URL`, or `DYNAMODB_TABLE_NAME`), and display `outro(...)` instructing the user to run `deploy-stack apply`.
|
|
97
|
+
* Emit `trackEvent('add_run', { projectName, capability, success: true })` and `await flushTelemetry()`.
|
|
98
|
+
2. **Documentation (`apps/docs/src/content/docs/cli/add.md`):**
|
|
99
|
+
* Document `deploy-stack add storage:s3` and `deploy-stack add db:dynamodb`, including flags (`--partition-key`, `--force`, `--region`, `--project-name`) and the injected container environment variables.
|
|
100
|
+
3. **Unit Tests (`tests/add.test.js` & `tests/resolvers.test.js`):**
|
|
101
|
+
* Test `resolveProjectName` extraction from rendered `terraform/main.tf` (`local.app_name` and `aws_ecr_repository.app`) and `--project-name` override.
|
|
102
|
+
* Test `parseAddArgs` (including `--flag value`, `--flag=value`, and `--force=false` forms) and invalid `--partition-key` rejection (`INVALID_PARTITION_KEY`).
|
|
103
|
+
* Test missing `terraform/main.tf` (`TERRAFORM_NOT_INITIALIZED`), unknown capability (`UNSUPPORTED_CAPABILITY`), and existing addon file with/without `--force` (`ADDON_ALREADY_EXISTS`).
|
|
104
|
+
* Test `injectContainerEnvVars` against a real generated `terraform/main.tf` (including multi-container / existing env vars), verifying idempotency when run twice with `--force`.
|
|
105
|
+
* Verify generated `terraform/s3.tf` references `aws_iam_role.task_role.id`, uses `"${trimsuffix(substr(lower(local.app_name), 0, 42), "-")}-storage-${data.aws_caller_identity.current.account_id}"`, and configures OAC + AES256 + CORS.
|
|
106
|
+
* Verify generated `terraform/dynamodb.tf` references `aws_iam_role.task_role.id`, `route_table_ids = [aws_route_table.public.id]`, `PAY_PER_REQUEST`, PITR, and custom `--partition-key`.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Spec: Multi-Provider Bedrock Model Catalog, Interactive Selector & Day-2 Switching
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Upgrade `deploy-stack add ai:bedrock` from a single hardcoded model constant into a multi-provider model catalog and Day-2 model switcher:
|
|
5
|
+
1. **Bundled Multi-Provider Catalog (`src/data/bedrock-models.json`):** A structured catalog covering major Bedrock providers (Anthropic, OpenAI, DeepSeek, Meta, Amazon, Google, Mistral AI, xAI, Moonshot AI, Cohere) loaded via `fs.readFileSync` for Node 18 compatibility with a deterministic fallback catalog.
|
|
6
|
+
2. **Maintainer Sync Script & Optional Live Refresh (`scripts/sync-bedrock-models.js`, `--refresh`):** A maintainer script (`npm run sync:bedrock-models` with `pruneMissing: true`), an OIDC-gated weekly GitHub Actions workflow that opens/updates a PR, and an optional `--refresh` CLI flag (`pruneMissing: false`) that merges live AWS Bedrock models into `~/.deploy-stack/bedrock-models-cache.json`.
|
|
7
|
+
3. **Two-Step Interactive Model Selector & `--list-models` (`src/commands/add.js`):** When run interactively on a TTY without an explicit model, guide the user through a two-step Clack selector (`Provider` -> `Model`, plus `Custom model ID...`). Support `--list-models` to print the catalog offline even outside a Terraform project.
|
|
8
|
+
4. **Scoped Day-2 Model Switching (`src/commands/add.js`):** Allow `injectContainerEnvVars` to upsert specific opt-in keys (`upsertKeys: ['BEDROCK_MODEL_ID']`) via a 4th `options` parameter while preserving skip-existing behavior and the 3rd `taskDefinitionName` parameter for all other addons, and allow switching Bedrock models without `--force`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Part 1: Catalog Schema, Loader & Live Refresh (`src/data/bedrock-models.json`, `src/utils/bedrock-catalog.js`)
|
|
13
|
+
|
|
14
|
+
### 1. Bundled Catalog & Exact Minimal Fallback
|
|
15
|
+
* Define `export const FALLBACK_BEDROCK_MODEL = 'us.anthropic.claude-sonnet-4-6'` in `src/utils/bedrock-catalog.js`.
|
|
16
|
+
* Load `src/data/bedrock-models.json` using `fs.readFileSync` and `JSON.parse` (avoiding JSON import attributes so Node 18 support is preserved).
|
|
17
|
+
* **Exact In-Memory Minimal Fallback Catalog (`FALLBACK_CATALOG`):** If reading or parsing `src/data/bedrock-models.json` fails or fails `isValidCatalog`, return:
|
|
18
|
+
* `updatedAt: '2026-01-01'`
|
|
19
|
+
* `defaultModelId: FALLBACK_BEDROCK_MODEL`
|
|
20
|
+
* `providers: [{ provider: 'Anthropic', models: [{ id: FALLBACK_BEDROCK_MODEL, name: 'Claude Sonnet 4.6', hint: 'Recommended — balanced coding & reasoning', recommended: true }] }]`
|
|
21
|
+
* Re-export `DEFAULT_BEDROCK_MODEL` from `src/commands/add.js` set to `loadBedrockCatalog().defaultModelId || FALLBACK_BEDROCK_MODEL`.
|
|
22
|
+
* **Catalog JSON Schema (`src/data/bedrock-models.json`):**
|
|
23
|
+
* `updatedAt`: ISO date string (`YYYY-MM-DD`, e.g., `'2026-09-27'`).
|
|
24
|
+
* `defaultModelId`: `'us.anthropic.claude-sonnet-4-6'`.
|
|
25
|
+
* `providers`: Ordered array of provider objects (`Anthropic`, `OpenAI`, `DeepSeek`, `Meta`, `Amazon`, `Google`, `Mistral AI`, `xAI`, `Moonshot AI`, `Cohere`), each with:
|
|
26
|
+
* `provider`: Non-empty provider display name string.
|
|
27
|
+
* `models`: Non-empty array of model objects, each with `id` (short Bedrock inference profile ID or foundation model ID matching `MODEL_ID_RE`, i.e., `^[a-zA-Z0-9._:-]+$`), `name` (human-readable display name, including current models such as `Claude Sonnet 5`, `Claude Sonnet 4.6`, `Claude Opus 4.7`, `Claude Haiku 4.5`, `GPT-5.5`, `DeepSeek V3.2`, `Llama 4 Maverick`, `Amazon Nova Pro`, `Gemma 4`, `Mistral Large`, `Grok 4.6`, `Kimi K3`), `hint` (concise tier/cost hint string), and optional `recommended: true`.
|
|
28
|
+
|
|
29
|
+
### 2. Cache Validation, Pagination, Normalization & Merge/Prune Semantics (`src/utils/bedrock-catalog.js`)
|
|
30
|
+
* **Validation Rule (`isValidCatalog(data)`):** A catalog object is valid iff it is a non-null plain object with a non-empty string `updatedAt` matching `/^\d{4}-\d{2}-\d{2}$/`, a non-empty string `defaultModelId`, and a non-empty array `providers` where every item has a non-empty string `provider` and a non-empty array `models` of objects containing non-empty string `id` and `name`.
|
|
31
|
+
* **Unified Provider Normalization (`normalizeProviderName(raw)`):** Route both inference profile prefixes/ARNs and foundation model `providerName` values through a single case-insensitive lookup table so duplicate groups (such as `'Mistral'` vs `'Mistral AI'`) are never created:
|
|
32
|
+
* `'anthropic'` -> `'Anthropic'`
|
|
33
|
+
* `'openai'` -> `'OpenAI'`
|
|
34
|
+
* `'deepseek'` -> `'DeepSeek'`
|
|
35
|
+
* `'meta'` -> `'Meta'`
|
|
36
|
+
* `'amazon'` -> `'Amazon'`
|
|
37
|
+
* `'google'` -> `'Google'`
|
|
38
|
+
* `'mistral'` or `'mistral ai'` -> `'Mistral AI'`
|
|
39
|
+
* `'xai'` -> `'xAI'`
|
|
40
|
+
* `'moonshot'` or `'moonshot ai'` -> `'Moonshot AI'`
|
|
41
|
+
* `'cohere'` -> `'Cohere'`
|
|
42
|
+
* Any other non-empty string -> trimmed string (or `'Other'` if empty).
|
|
43
|
+
* **`loadBedrockCatalog(options = {})`:**
|
|
44
|
+
* Resolve the cache path from `options.cachePath || process.env.DEPLOY_STACK_BEDROCK_CACHE_PATH || path.join(os.homedir(), '.deploy-stack', 'bedrock-models-cache.json')`.
|
|
45
|
+
* When running in a test environment (`VITEST` or `NODE_ENV === 'test'`) **and** neither `options.cachePath` nor `process.env.DEPLOY_STACK_BEDROCK_CACHE_PATH` is explicitly set, skip reading the home directory cache and return the bundled catalog.
|
|
46
|
+
* Otherwise, if a readable file exists at the resolved cache path, passes `isValidCatalog(cached)`, and satisfies `cached.updatedAt >= bundled.updatedAt`, return `cached`; otherwise return the bundled catalog.
|
|
47
|
+
* **`refreshBedrockCatalog(options = {})`:**
|
|
48
|
+
* Add `@aws-sdk/client-bedrock` (`BedrockClient`, `ListInferenceProfilesCommand`, `ListFoundationModelsCommand`) to `package.json` dependencies. Accept optional `options.bedrockClient`, `options.region` (defaulting to `process.env.AWS_REGION || 'us-east-2'`), `options.cachePath`, and `options.pruneMissing = false`.
|
|
49
|
+
* **Pagination:** Loop `ListInferenceProfilesCommand({ typeEquals: 'SYSTEM_DEFINED', nextToken })` until `response.nextToken` is falsy so all pages of system-defined inference profiles are collected. Call `ListFoundationModelsCommand({ byInferenceType: 'ON_DEMAND' })` (and loop `nextToken` if present).
|
|
50
|
+
* **Merge & Optional Prune Semantics:**
|
|
51
|
+
* Start from a deep clone of the bundled catalog.
|
|
52
|
+
* Collect all active live entries (`status === 'ACTIVE'` for inference profiles; `modelLifecycle?.status === 'ACTIVE'` for foundation models) whose `id` matches `^[a-zA-Z0-9._:-]+$`, normalizing each provider via `normalizeProviderName`.
|
|
53
|
+
* Build a `Set` of existing model `id` strings in the clone. For any live entry whose `id` is not in the clone, append `{ id, name, hint: 'Live AWS Bedrock model' }` to the matching normalized `provider` group (creating the group if absent).
|
|
54
|
+
* **Pruning (`options.pruneMissing === true`):** When `pruneMissing: true` is passed (used by the maintainer sync script) and the live AWS query returned at least one valid model, filter each provider's `models` array to retain only models present in the live `id` Set **or** equal to `merged.defaultModelId`, and drop any provider group whose `models` array becomes empty. When `pruneMissing` is `false` (default for CLI `--refresh`), never drop bundled models.
|
|
55
|
+
* Set `merged.updatedAt = new Date().toISOString().slice(0, 10)`, write `merged` to the resolved cache path (creating parent directories with `recursive: true`), and return `merged`.
|
|
56
|
+
* **Failure Fallback:** If the AWS SDK calls or cache write throw any error, log a non-fatal Clack `log.warn` message and return the bundled catalog without failing the command.
|
|
57
|
+
|
|
58
|
+
### 3. Maintainer Sync Script & Credentialless-Safe Weekly Workflow
|
|
59
|
+
* Add `scripts/sync-bedrock-models.js` (and `"sync:bedrock-models": "node scripts/sync-bedrock-models.js"` in `package.json`) that runs `refreshBedrockCatalog({ cachePath: path.resolve('src/data/bedrock-models.json'), pruneMissing: true })` using local/CI AWS credentials to update and prune the bundled file in place.
|
|
60
|
+
* Create `.github/workflows/sync-bedrock-models.yml` triggered on `schedule` (`0 6 * * 1`) and `workflow_dispatch`:
|
|
61
|
+
* Set top-level `permissions: { id-token: write, contents: write, pull-requests: write }`.
|
|
62
|
+
* When `vars.AWS_ROLE_ARN == ''`, write an informational notice to `$GITHUB_STEP_SUMMARY` and exit 0.
|
|
63
|
+
* When `vars.AWS_ROLE_ARN != ''`, configure AWS credentials via `aws-actions/configure-aws-credentials@v4` (assuming `vars.AWS_ROLE_ARN` in `vars.AWS_REGION || 'us-east-2'`), run `npm ci` and `npm run sync:bedrock-models`, and publish any changes to `src/data/bedrock-models.json` via `peter-evans/create-pull-request@v7` (branch `chore/sync-bedrock-models`, commit message `chore(bedrock): sync model catalog`, title `chore(bedrock): sync AWS Bedrock model catalog`).
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Part 2: CLI Flags, Guard Ordering & Two-Step Interactive Selector (`src/commands/add.js`, `bin/cli.js`)
|
|
68
|
+
|
|
69
|
+
### 1. Argument Parsing (`parseAddArgs`)
|
|
70
|
+
* Extend `parseAddArgs(args)` in `src/commands/add.js` to parse:
|
|
71
|
+
* `--list-models` -> `listModels: true` (default `false`)
|
|
72
|
+
* `--refresh` -> `refresh: true` (default `false`)
|
|
73
|
+
* `--headless` / `--headless=true` -> `isHeadless: true`; `--headless=false` -> `isHeadless: false` (default `false`, mirroring `--force` parsing)
|
|
74
|
+
* **Explicit Model Tracking:** Keep `model` defaulting to `DEFAULT_BEDROCK_MODEL` in `parseAddArgs` and return `modelProvided: true` whenever `--model <id>` or `--model=<id>` was explicitly passed in `args` (`false` otherwise).
|
|
75
|
+
* In `bin/cli.js`, update `HELP_TEXT` to document `--list-models` and `--refresh`.
|
|
76
|
+
|
|
77
|
+
### 2. Guard & Execution Ordering in `runAdd(options = {})`
|
|
78
|
+
At the very top of `runAdd(options = {})` (before defaulting `options.model`), capture:
|
|
79
|
+
* `const explicitModel = options.modelProvided === true || (options.model !== undefined && options.modelProvided !== false);`
|
|
80
|
+
|
|
81
|
+
Execute in this exact sequence:
|
|
82
|
+
1. **`UNSUPPORTED_CAPABILITY` Guard:** Verify `capability` is in `ADDON_REGISTRY`.
|
|
83
|
+
2. **Flag Validation Guards (`INVALID_PARTITION_KEY` / `INVALID_MODEL_ID`):**
|
|
84
|
+
* Keep the existing `MODEL_ID_RE` (`^[a-zA-Z0-9._:-]+$`; short inference profile IDs and foundation model IDs only; ARNs with `/` are rejected).
|
|
85
|
+
* When `capability === 'ai:bedrock'`, validate `model` with `MODEL_ID_RE` before `--refresh` / `--list-models`, so passing an invalid `--model` alongside `--list-models` immediately fails with `INVALID_MODEL_ID`.
|
|
86
|
+
* When `capability !== 'ai:bedrock'`, silently ignore `--model`, `--list-models`, and `--refresh`.
|
|
87
|
+
3. **Optional Live Refresh (`--refresh` on `ai:bedrock`):** If `capability === 'ai:bedrock'` and `options.refresh` is true, call `await refreshBedrockCatalog({ bedrockClient: options.bedrockClient, cachePath: options.cachePath, region: options.region })` inside a Clack spinner (`Refreshing Bedrock model catalog from AWS...`) and hold the returned catalog for step 4 / step 6 (otherwise load via `loadBedrockCatalog({ cachePath: options.cachePath })`).
|
|
88
|
+
4. **Catalog Listing (`--list-models` on `ai:bedrock`):** If `capability === 'ai:bedrock'` and `options.listModels` is true:
|
|
89
|
+
* **Viewport Exemption & Compact Format:** `--list-models` is explicitly exempt from the 14-line command viewport cap. Format the output compactly with one `log.info` line per provider (`<Provider> (<N>): <model1.id> (<model1.name>), ...`) followed by `outro`.
|
|
90
|
+
* Emit `trackEvent('add_run', { projectName, capability, action: 'list_models', success: true })`, flush telemetry, and return `{ ok: true, action: 'list-models', models }` before checking for `terraform/main.tf`.
|
|
91
|
+
5. **`TERRAFORM_NOT_INITIALIZED` Guard:** Verify `terraform/main.tf` exists in `cwd`.
|
|
92
|
+
6. **Interactive Two-Step Model Selection (`ai:bedrock` only):**
|
|
93
|
+
* Import and reuse `isActiveEnvValue` from `src/core/telemetry.js` to evaluate `CI` and `VITEST`.
|
|
94
|
+
* Compute `const isInteractive = options.interactive ?? (!options.isHeadless && !isActiveEnvValue(process.env.CI) && !isActiveEnvValue(process.env.VITEST) && process.env.NODE_ENV !== 'test' && Boolean(process.stdout?.isTTY));`.
|
|
95
|
+
* Trigger the interactive selector when `capability === 'ai:bedrock'`, `isInteractive` is `true`, and `explicitModel` is `false`.
|
|
96
|
+
* **Step 1 (`select`):** Prompt the user to choose a provider from `catalog.providers` (`label: provider.provider`, `hint: provider.models.map(m => m.name).slice(0, 3).join(', ')`) or `Custom model ID...` (`value: '__custom__'`).
|
|
97
|
+
* **Step 2 (`select` or `text`):**
|
|
98
|
+
* If a provider was selected, prompt with a `select` of that provider's models (`label: "${m.name} (${m.id})"`, `hint: m.hint`, `value: m.id`) plus `Custom model ID...` (`value: '__custom__'`).
|
|
99
|
+
* If `'__custom__'` was chosen at Step 1 or Step 2, prompt with Clack `text` for the model ID and validate the trimmed result with `MODEL_ID_RE` (`^[a-zA-Z0-9._:-]+$`). If invalid, trigger the standard `INVALID_MODEL_ID` error path.
|
|
100
|
+
* **Cancellation (`isCancel`):** If the user cancels at any prompt, call `cancel('Model selection cancelled.')`, emit `trackEvent('add_run', { projectName, capability, success: false, reason: 'cancelled' })`, flush telemetry, and return `{ ok: false, reason: 'cancelled' }` without calling `process.exit(1)`.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Part 3: Scoped Day-2 Model Switching & Opt-In Env Upsert (`src/commands/add.js`)
|
|
105
|
+
|
|
106
|
+
1. **Preserve 3rd Parameter (`taskDefinitionName`) & Add 4th Parameter (`options = {}`) on `injectContainerEnvVars`:**
|
|
107
|
+
* Update the signature to `injectContainerEnvVars(tfContent, envEntries = [], taskDefinitionName = 'app', options = {})` (and if `typeof taskDefinitionName === 'object' && taskDefinitionName !== null`, treat it as `options` with `taskDefinitionName = options.taskDefinitionName || 'app'`).
|
|
108
|
+
* This preserves full backward compatibility for `'app'` and `'worker'` callers (`injectContainerEnvVars(workerContent, envVars, 'worker', { upsertKeys })`).
|
|
109
|
+
* Add `const upsertKeys = new Set(options.upsertKeys || []);`.
|
|
110
|
+
* For any env var **not** in `upsertKeys` (the default for all other addons), preserve the exact existing skip-if-already-present behavior so user edits to `S3_BUCKET_NAME`, `DYNAMODB_TABLE_NAME`, `REDIS_URL`, or `SQS_QUEUE_URL` are never clobbered on `--force` and existing `injectContainerEnvVars` unit tests remain completely valid.
|
|
111
|
+
* For an env var whose `name` **is** in `upsertKeys` (`upsertKeys: ['BEDROCK_MODEL_ID']` for `ai:bedrock`) and already exists in the target container's (`'app'` or `'worker'`) `environment` block, update its `value` string in place to the new `entry.value` (leaving the file text unchanged if the value is already identical).
|
|
112
|
+
2. **Implicit Overwrite on `ai:bedrock` Model Switch:**
|
|
113
|
+
* When `terraform/bedrock.tf` already exists and `--force` is not set:
|
|
114
|
+
* If `capability === 'ai:bedrock'` and the user either passed an explicit model (`explicitModel === true`) or completed the interactive selector (`selectedInteractively === true`), proceed with writing `terraform/bedrock.tf` and upserting `BEDROCK_MODEL_ID` in `terraform/main.tf` (and `terraform/worker.tf` if present, passing `'worker'` as the 3rd arg and `{ upsertKeys: ['BEDROCK_MODEL_ID'] }` as the 4th arg) without requiring `--force`.
|
|
115
|
+
* Otherwise, preserve the existing `ADDON_ALREADY_EXISTS` guard.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Part 4: Docs & Unit Tests (`apps/docs/`, `tests/add.test.js`)
|
|
120
|
+
|
|
121
|
+
1. **Documentation (`apps/docs/src/content/docs/cli/add.md`):**
|
|
122
|
+
* Update the default Bedrock model reference to `us.anthropic.claude-sonnet-4-6`.
|
|
123
|
+
* Document the interactive provider/model selector, `--list-models`, `--refresh`, Day-2 model switching, and the one-time Anthropic First Time Use (FTU) form in the AWS Bedrock console.
|
|
124
|
+
2. **Unit Tests (`tests/add.test.js`):**
|
|
125
|
+
* Extend the `@clack/prompts` mock in `tests/add.test.js` to include `intro`, `outro`, `select`, `text`, `spinner`, `log`, `cancel`, and `isCancel`.
|
|
126
|
+
* Update existing `ai:bedrock` tests that assert the old `us.anthropic.claude-sonnet-4-20250514-v1:0` default so they assert `us.anthropic.claude-sonnet-4-6`.
|
|
127
|
+
* Verify existing `injectContainerEnvVars` "skips keys that already exist" tests continue to pass unchanged, and add a new test verifying `injectContainerEnvVars(content, envVars, 'worker', { upsertKeys: ['BEDROCK_MODEL_ID'] })` replaces an existing `BEDROCK_MODEL_ID` value in place in both `'app'` (`main.tf`) and `'worker'` (`worker.tf`).
|
|
128
|
+
* Verify `--list-models` succeeds in an empty directory without `terraform/main.tf`, and that passing an invalid `--model` with `--list-models` fails with `INVALID_MODEL_ID`.
|
|
129
|
+
* Verify `--refresh` with a mocked paginated `bedrockClient` (`nextToken`), provider normalization (`'Mistral'` -> `'Mistral AI'`), and temp `DEPLOY_STACK_BEDROCK_CACHE_PATH` merges live models without dropping bundled entries when `pruneMissing` is `false`, prunes absent entries when `pruneMissing` is `true`, and falls back cleanly to the bundled catalog when the AWS client throws.
|
|
130
|
+
* Verify the two-step interactive selector (`interactive: true`) with mocked Clack `select` / `text` prompts (provider -> model selection, custom model ID path, invalid custom ID error, and prompt cancellation).
|
|
131
|
+
* Verify Day-2 switching: running `add ai:bedrock` with Model A and then running `add ai:bedrock --model <Model B>` without `--force` updates `bedrock.tf`, `main.tf`, and `worker.tf` in place.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Spec: Continuous Infrastructure Validation Pipeline
|
|
2
|
+
|
|
3
|
+
## Objective
|
|
4
|
+
Create a GitHub Actions workflow that automatically generates, compiles, and security-scans our IaC templates across all supported frameworks to prevent regressions.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
1. **Workflow Registration:** Create `.github/workflows/iac-validation.yml` triggered on push to `main` and all pull requests.
|
|
8
|
+
2. **Matrix Strategy:** Run tests across all 10 supported framework permutations: `nestjs`, `nextjs`, `nuxt`, `node`, `svelte`, `static`, `python`, `django`, `rails`, and `go`.
|
|
9
|
+
3. **Execution Steps:** For each framework in the matrix:
|
|
10
|
+
- Scaffold the app: Run `node bin/cli.js --headless --preconfigured --framework=<matrix-framework>` in a temporary directory.
|
|
11
|
+
- Terraform Compile: Run `terraform init -backend=false` followed by `terraform validate`.
|
|
12
|
+
- Terraform Lint: Run `tflint` to catch deprecated syntax.
|
|
13
|
+
- DevSecOps Scan: Run Trivy (via `aquasecurity/trivy-action`) to scan the generated `Dockerfile` and `terraform/` directory.
|
|
14
|
+
|
|
15
|
+
## Constraints
|
|
16
|
+
- The pipeline must execute completely offline without AWS credentials (hence `-backend=false`).
|
|
17
|
+
- Trivy must be configured to fail the build if it detects HIGH or CRITICAL vulnerabilities.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Spec: End-to-End Cost Transparency (`addons.js`, `visualizer.js`, `init`, `add`, `apply`, & Docs)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Resolve all cost-transparency gaps across `src/utils/addons.js`, `src/utils/visualizer.js`, `src/commands/init.js`, `src/commands/add.js`, `src/commands/apply.js`, `src/utils/generator.js`, and documentation so users always see accurate fixed-baseline vs. usage-based cost breakdowns at `init`, `add`, and `apply` time.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Part 1: Shared `src/utils/addons.js` Registry & Visualizer Upgrade (`src/utils/visualizer.js`)
|
|
9
|
+
|
|
10
|
+
### 1. Extract `ADDON_REGISTRY` to `src/utils/addons.js` (No Import Cycle)
|
|
11
|
+
Move `ADDON_REGISTRY` into `src/utils/addons.js` (and re-export it from `src/commands/add.js` so existing imports continue to work). Extend each capability entry with `label` and `cost` metadata:
|
|
12
|
+
* **`'storage:s3'`:**
|
|
13
|
+
* `file`: `'s3.tf'`
|
|
14
|
+
* `template`: `'s3.tf'`
|
|
15
|
+
* `label`: `'S3 + CloudFront OAC'`
|
|
16
|
+
* `cost`:
|
|
17
|
+
* `model`: `'usage-based'`
|
|
18
|
+
* `monthlyFixed`: `0`
|
|
19
|
+
* `summary`: `'$0/mo fixed baseline; billed per GB stored ($0.023/GB-mo), S3 PUT/GET requests, and CloudFront egress'`
|
|
20
|
+
* **`'db:dynamodb'`:**
|
|
21
|
+
* `file`: `'dynamodb.tf'`
|
|
22
|
+
* `template`: `'dynamodb.tf'`
|
|
23
|
+
* `label`: `'DynamoDB (On-Demand + PITR)'`
|
|
24
|
+
* `cost`:
|
|
25
|
+
* `model`: `'usage-based'`
|
|
26
|
+
* `monthlyFixed`: `0`
|
|
27
|
+
* `summary`: `'$0/mo fixed instance baseline (VPC Gateway Endpoint is free); billed per read/write request, table storage ($0.25/GB-mo), and PITR continuous backups ($0.20/GB-mo once data is written)'`
|
|
28
|
+
|
|
29
|
+
### 2. Concrete Shape & `main.tf` CPU/Memory Parsing in `parseTerraformConfig(terraformDir)`
|
|
30
|
+
In `src/utils/visualizer.js`:
|
|
31
|
+
* Keep existing detection for `main.tf`, `hasDb` (`rds.tf` / `database.tf`), and `hasWorker` (`worker.tf`).
|
|
32
|
+
* **Read rendered `cpu` and `memory` from `main.tf`:** In `main.tf` (within `resource "aws_ecs_task_definition" "app"`), parse `cpu\s*=\s*"(\d+)"` and `memory\s*=\s*"(\d+)"` as integers before checking `terraform.tfvars` (allowing `terraform.tfvars` to override if present, and falling back to `256` / `512` defaults only when neither specifies them). This ensures non-micro sizes chosen at `init` are preserved during `apply` previews and README cost syncs.
|
|
33
|
+
* **Pure file existence for `hasSecrets`:** Set `hasSecrets = fs.existsSync(path.join(terraformDir, 'secrets.tf'))`.
|
|
34
|
+
* **Registry-driven `addons` array:** Iterate over `Object.entries(ADDON_REGISTRY)` and populate `config.addons` as a concrete array of capability keys whose target `.tf` file exists in `terraformDir` (e.g., `addons: ['storage:s3', 'db:dynamodb']`).
|
|
35
|
+
|
|
36
|
+
### 3. Per-Secret Billing & Return Object Shape in `estimateMonthlyCost(config)`
|
|
37
|
+
* Keep the existing `us-east-2` pricing table constants for Fargate, ALB (`~$22.27/mo` base + LCU), and RDS (`~$13.98/mo` compute + storage) unchanged.
|
|
38
|
+
* **Secrets Manager Fixed Cost (`$0.40/secret/mo`):**
|
|
39
|
+
* Add `SECRETS_MANAGER_PER_SECRET = 0.40` to the pricing constants.
|
|
40
|
+
* Count `secretCount = (config.hasSecrets ? 1 : 0) + (config.hasDb ? 1 : 0)` (1 for the base `secrets.tf` JSON secret, plus 1 when `hasDb` is true for RDS `manage_master_user_password = true`).
|
|
41
|
+
* Compute `secretsCost = secretCount * 0.40`.
|
|
42
|
+
* If any installed addon in `config.addons || []` defines `cost.monthlyFixed > 0`, add `addon.cost.monthlyFixed` to the total.
|
|
43
|
+
* **Preserve Object Return Shape:** Return `{ fargateMonthly, albMonthly, dbMonthly, secretsMonthly, totalMonthly }` where each value is a two-decimal numeric string (`'XX.XX'`), adding `secretsMonthly: secretsCost.toFixed(2)` alongside the existing fields.
|
|
44
|
+
|
|
45
|
+
### 4. Honest Labeling & Addon Rendering in `renderDryRunPreview(config, isDryRun = false)`
|
|
46
|
+
In `src/utils/visualizer.js`:
|
|
47
|
+
* Compute `secretCount = (config.hasSecrets ? 1 : 0) + (config.hasDb ? 1 : 0)`:
|
|
48
|
+
* When `secretCount > 0`, render `[Secrets Manager (${secretCount === 1 ? '1 secret' : `${secretCount} secrets`})]` in the architecture diagram; when `secretCount === 0` (`hasSecrets === false` and `!hasDb`), omit the `[Secrets Manager]` node.
|
|
49
|
+
* For each capability key in `config.addons || []`, look up its `ADDON_REGISTRY[key]` entry and render `[${entry.label}]` in the architecture diagram.
|
|
50
|
+
* Replace the `"Est. Monthly Cost"` label with:
|
|
51
|
+
`Est. Fixed Baseline: ~$${costs.totalMonthly}/mo (us-east-2 reference rates; usage, requests & data transfer billed per use)`
|
|
52
|
+
* In the dim parenthetical breakdown line (`Fargate: $${costs.fargateMonthly}, ALB: $${costs.albMonthly}...`), append `, Secrets: $${costs.secretsMonthly}` whenever `Number(costs.secretsMonthly) > 0`.
|
|
53
|
+
* When `config.addons` is non-empty, render a `Usage-Based Addons:` section directly below the baseline breakdown using `ADDON_REGISTRY[key].cost.summary`, formatted with a single space after the colon:
|
|
54
|
+
```text
|
|
55
|
+
Usage-Based Addons:
|
|
56
|
+
• storage:s3 (S3 + CloudFront OAC): $0/mo fixed baseline; billed per GB stored ($0.023/GB-mo), S3 PUT/GET requests, and CloudFront egress
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Part 2: `init.js` Contract Alignment, Cost Notice & Doc Sync (`src/commands/init.js`, `src/commands/add.js`, `src/utils/generator.js`)
|
|
62
|
+
|
|
63
|
+
### 1. Align `init.js` and `templates/README.md` on `{{ESTIMATED_COST}}`
|
|
64
|
+
* Export `COST_ESTIMATE_MARKER = 'Estimated Fixed Monthly Baseline:'` and `LEGACY_COST_ESTIMATE_MARKER = 'Estimated Monthly Cost:'` from `src/utils/generator.js` (or `src/utils/visualizer.js`).
|
|
65
|
+
* Update `templates/README.md` (line 19) so the template owns all surrounding wording and `{{ESTIMATED_COST}}` is purely the numeric string (`costs.totalMonthly`):
|
|
66
|
+
`* **Estimated Fixed Monthly Baseline:** ~${{ESTIMATED_COST}}/month (us-east-2 reference rates; excludes variable traffic, ECR/CloudWatch storage, and usage-based addons)`
|
|
67
|
+
* Update `src/commands/init.js` (around line 110) so it computes `estimateMonthlyCost({ cpu, memory, hasDb, hasWorker, hasSecrets: true, addons: [] })` (or passes the numeric total directly) and sets `ESTIMATED_COST: costs.totalMonthly` (a plain numeric string like `'36.18'` without `~$` or `/ month (...)`).
|
|
68
|
+
|
|
69
|
+
### 2. Print Cost Impact in `runAdd`
|
|
70
|
+
* Whenever `deploy-stack add <capability>` succeeds, log the cost line from `ADDON_REGISTRY[capability].cost.summary` before `outro`:
|
|
71
|
+
* `💰 Cost Impact: ${addon.cost.summary}`
|
|
72
|
+
|
|
73
|
+
### 3. Implement `syncDocCostEstimate(cwd)`
|
|
74
|
+
* Called by `runAdd` after writing the addon `.tf` file:
|
|
75
|
+
* Check `DEPLOYMENT.md` first and then `README.md` in `cwd`.
|
|
76
|
+
* If a file containing either `COST_ESTIMATE_MARKER` or `LEGACY_COST_ESTIMATE_MARKER` is found, recompute `parseTerraformConfig` + `estimateMonthlyCost` from `path.join(cwd, 'terraform')`, replace the marker bullet line with:
|
|
77
|
+
`* **Estimated Fixed Monthly Baseline:** ~$${costs.totalMonthly}/month (us-east-2 reference rates; excludes variable traffic, ECR/CloudWatch storage, and usage-based addons)`
|
|
78
|
+
and render/replace an `### Active Addons (Usage-Based)` bullet list immediately beneath the cost bullet list using `ADDON_REGISTRY[key].cost.summary` for each key in `config.addons`.
|
|
79
|
+
* If neither file exists or the user deleted the cost marker, no-op gracefully without throwing.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Part 3: Print-Only Preview When `autoApprove` Is True in `applyStack` (`src/commands/apply.js`)
|
|
84
|
+
|
|
85
|
+
* In `applyStack` (`src/commands/apply.js`):
|
|
86
|
+
* In the `if (!autoApprove)` branch, keep `const confirmed = await renderDryRunPreview(detectedConfig, false);` and its cancellation check intact.
|
|
87
|
+
* Add an `else` branch (when `autoApprove` is `true`) that calls `await renderDryRunPreview(detectedConfig, true);` so the Clack architecture/cost note box is still printed to the terminal without prompting the user for confirmation.
|
|
88
|
+
* Exposing new `--auto-approve` or `--headless` CLI flags on `apply` remains out of scope.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Part 4: Clarify "Scale-to-Zero / Free" Wording in Templates & Docs
|
|
93
|
+
|
|
94
|
+
1. **Template Comments (`templates/terraform/addons/s3.tf` & `dynamodb.tf`):**
|
|
95
|
+
* State clearly that the **VPC Gateway Endpoint** has no hourly/data-processing charge, while DynamoDB table storage (`$0.25/GB-mo`), read/write requests, and PITR continuous backups (`$0.20/GB-mo` once data is written) and S3 storage/requests/CloudFront transfer are usage-billed.
|
|
96
|
+
2. **CLI Add Reference (`apps/docs/src/content/docs/cli/add.md`):**
|
|
97
|
+
* Replace unqualified "scale-to-zero" claims with "no fixed hourly instance cost (usage-billed)" and include a `## Cost & Billing Drivers` section summarizing the billing model for `storage:s3` and `db:dynamodb`.
|
|
98
|
+
3. **PR Preview Guide (`apps/docs/src/content/docs/guides/ephemeral-pr-previews.md`):**
|
|
99
|
+
* Update the cost section (~line 30) to note that because `add` resources use `${local.app_name}`, each open PR preview workspace provisions its own isolated S3 bucket and/or DynamoDB table—accruing usage-based storage/request/PITR charges until the PR closes and the workspace is destroyed (whereas Secrets Manager secrets are shared via `data` source at no extra per-PR secret cost).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Part 5: Unit Tests (`tests/visualizer.test.js`, `tests/add.test.js`, `tests/apply.test.js`, `tests/generator.test.js`)
|
|
104
|
+
|
|
105
|
+
1. **`tests/visualizer.test.js`:**
|
|
106
|
+
* `parseTerraformConfig` extracts rendered `cpu` and `memory` from `main.tf` (and lets `terraform.tfvars` override if present), checks pure `secrets.tf` existence (`hasSecrets: true/false`), and populates `addons: ['storage:s3', 'db:dynamodb']` from `ADDON_REGISTRY`.
|
|
107
|
+
* `estimateMonthlyCost` returns `{ fargateMonthly, albMonthly, dbMonthly, secretsMonthly, totalMonthly }` with `secretsMonthly: '0.40'` (`hasSecrets: true, hasDb: false`), `'0.80'` (`hasSecrets: true, hasDb: true`), and `'0.00'` (`hasSecrets: false, hasDb: false`).
|
|
108
|
+
* `renderDryRunPreview` includes `Est. Fixed Baseline`, `, Secrets: $0.40` (or `$0.80`, and omits `[Secrets Manager]` when `0` secrets), and renders active addons' `[label]` and `Usage-Based Addons:` bullets (`• <key> (<label>): <summary>`).
|
|
109
|
+
* Assert that `templates/README.md` literally contains `COST_ESTIMATE_MARKER` so the template and JS constant cannot drift.
|
|
110
|
+
2. **`tests/add.test.js`:**
|
|
111
|
+
* `runAdd` prints `💰 Cost Impact:` with the registry summary.
|
|
112
|
+
* `syncDocCostEstimate` updates the cost baseline (preserving non-default CPU/memory from `main.tf`) and `### Active Addons (Usage-Based)` section in `README.md` or `DEPLOYMENT.md` (matching both `COST_ESTIMATE_MARKER` and `LEGACY_COST_ESTIMATE_MARKER`), and gracefully no-ops when no marker is present.
|
|
113
|
+
3. **`tests/apply.test.js` & `tests/generator.test.js`:**
|
|
114
|
+
* `applyStack` with `autoApprove: true` calls `await renderDryRunPreview(detectedConfig, true)` (print-only mode) before executing Terraform.
|
|
115
|
+
* Update `ESTIMATED_COST` fixtures in `tests/generator.test.js` (e.g., `'30.00'` instead of `'~$30'`) and update snapshots to match the new `templates/README.md` cost wording.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Spec: Custom Domains, Edge TLS (`domain`) & Transactional Email (`add email:ses`)
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Eliminate the primary production triggers that force developers into the AWS Management Console:
|
|
5
|
+
1. **Shared Foundation & Pre-Refactors (`src/utils/hcl.js`, `src/utils/domains.js`, `src/commands/add.js`):** Extract a shared HCL block-manipulation toolkit, a shared domain/zone validation and `domain.tf` parser module, and per-addon option resolution (`resolveAddonOptions`) with generalized placeholder/conditional-section template rendering.
|
|
6
|
+
2. **Custom Domains & Automated Edge SSL (`deploy-stack domain <add|verify|status|remove>`):** Provision ACM TLS certificates in `us-east-1` (required by CloudFront) via pure HCL builders, support 1-step Route 53 automated validation/routing (`--zone-id`) and 2-step External DNS verification (Cloudflare, Namecheap), and patch `aws_cloudfront_distribution "cdn"` in `terraform/cloudfront.tf`.
|
|
7
|
+
3. **Transactional Email & DKIM Automation (`deploy-stack add email:ses`):** Provision Amazon SES Domain Identities, DKIM tokens, MAIL FROM domain configuration, optional Route 53 DKIM/SPF/DMARC records, container environment variables (`SES_FROM_EMAIL`, `SES_REGION`), and least-privilege `ses:SendEmail` / `ses:SendRawEmail` IAM permissions on `aws_iam_role.task_role`.
|
|
8
|
+
4. **Operational Polish (`doctor_run` & `DATABASE_URL` Synthesis):** Verify `doctor_run` telemetry compatibility (preserving `DOCTOR_CHECKS` ordering and reusing `detectCiProvider()`) and provide a pure `buildMigrationCommand` helper in `src/commands/db/migrate.js` that synthesizes `DATABASE_URL` at runtime when `DB_HOST` is present.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Part 0: Pre-Refactors & Shared Utilities
|
|
13
|
+
|
|
14
|
+
1. **Shared HCL-Editing Toolkit (`src/utils/hcl.js`):**
|
|
15
|
+
* Extract pure, string-literal-aware brace-walking primitives:
|
|
16
|
+
* `findResourceBlock(content, resourceType, resourceName)`
|
|
17
|
+
* `findNestedBlock(blockContent, blockName)`
|
|
18
|
+
* `replaceBlock(content, bounds, replacement)`
|
|
19
|
+
* `upsertAttribute(blockContent, key, valueExpr)`
|
|
20
|
+
* `removeAttribute(blockContent, key)`
|
|
21
|
+
* Rewire existing brace-walking callers (`injectContainerEnvVars` and `ensureWorkerDesiredCountLifecycle` in `src/commands/add.js`, and `upsertSnapshotIdentifier` in `src/commands/db/restore.js`) onto `src/utils/hcl.js` while keeping all existing unit tests green, and build the new CloudFront patcher on top of it.
|
|
22
|
+
2. **Shared Domain Validation & `domain.tf` Parser (`src/utils/domains.js`):**
|
|
23
|
+
* Centralize domain/zone/email normalization and validation so `domain.js` and `add.js` (`email:ses`) never drift:
|
|
24
|
+
* `normalizeDomain(input)`: trims whitespace, strips a trailing dot (FQDN), and lowercases.
|
|
25
|
+
* `DOMAIN_REGEX` & `isValidDomain(domain)`: validates standard domains and optional wildcard prefix (`*.example.com`) up to 253 chars.
|
|
26
|
+
* `ZONE_ID_REGEX` (`/^(?:\/hostedzone\/)?(Z[A-Z0-9]{1,32})$/`) & `normalizeZoneId(input)`: strips optional `/hostedzone/` prefix and validates `Z[A-Z0-9]{1,32}`.
|
|
27
|
+
* `isValidFromEmail(email, expectedDomain)`: validates email syntax and checks that the address domain matches `expectedDomain` (or is a subdomain of `expectedDomain`), preventing unverified-identity send failures at runtime.
|
|
28
|
+
* `parseDomainTf(content)`: parses `# deploy-stack:domain-mode=<mode>`, `domain_name`, and `zone_id` (if present) into a structured `{ domain, mode, zoneId }` object used by both `domain status`/`verify`/`remove` and `add email:ses` auto-detection.
|
|
29
|
+
3. **Per-Addon Option Resolution & Template Generalization (`src/commands/add.js`, `src/utils/addons.js`):**
|
|
30
|
+
* Extract a `resolveAddonOptions(capability, options, ctx)` dispatch with resolvers for stateful addons (`db:dynamodb`, `ai:bedrock`, `email:ses`), each returning `{ ok: true, templateVars, envVars, upsertKeys }` or a structured validation failure (`{ ok: false, errorCode, reason, message }`).
|
|
31
|
+
* Generalize `renderAddonTemplate(templateContent, templateVars, conditionalBlocks)` to iterate over a placeholder map (`{{REGION}}`, `{{PARTITION_KEY}}`, `{{BEDROCK_MODEL_ID}}`, `{{SES_DOMAIN}}`, `{{SES_FROM_EMAIL}}`) and named conditional blocks (`{{WORKER_AUTOSCALING_BLOCK}}`, `{{SES_ROUTE53_RECORDS_BLOCK}}`).
|
|
32
|
+
* Allow `ADDON_ENV_VARS` entries in `src/utils/addons.js` to accept either static strings or `(ctx) => string` functions so dynamic values (`BEDROCK_MODEL_ID`, `SES_FROM_EMAIL`, `SES_REGION`) resolve cleanly without post-hoc `replaceAll` special cases.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Part 1: Custom Domains & Edge SSL (`src/commands/domain.js`)
|
|
37
|
+
|
|
38
|
+
### 1. CLI Surface, Telemetry & Error Conventions
|
|
39
|
+
* Register `domain` in `bin/cli.js` (`HELP_TEXT` and command router) and create `src/commands/domain.js` exporting `parseDomainArgs(args)`, `runDomain(options = {})`, and the pure HCL builders/patchers.
|
|
40
|
+
* **Subcommands:**
|
|
41
|
+
* `deploy-stack domain add <domain> [--zone-id <id>] [--activate] [--force] [--headless]`
|
|
42
|
+
* `deploy-stack domain verify [--headless]` (alias: `activate`)
|
|
43
|
+
* `deploy-stack domain status`
|
|
44
|
+
* `deploy-stack domain remove [--yes] [--headless]`
|
|
45
|
+
* Unknown or missing subcommand fails via `failCommand` with event `'domain_run'`, `errorCode: 'UNKNOWN_DOMAIN_SUBCOMMAND'`, `reason: 'unknown-domain-subcommand'`.
|
|
46
|
+
* **Telemetry (`domain_run`):**
|
|
47
|
+
* Emit `trackSuccess('domain_run', { subcommand, mode })` on success and route all failures through `failCommand` with `'domain_run'` (importing all used `track*` / `fail*` helpers to satisfy `tests/commands-import.test.js`).
|
|
48
|
+
* **Guard Order & Error Codes:**
|
|
49
|
+
1. Normalize `args` (`normalizeArgv`) and `options` (`normalizeOptions`).
|
|
50
|
+
2. Validate subcommand and input flags (`INVALID_DOMAIN` / `reason: 'invalid-domain'`, `INVALID_ZONE_ID` / `reason: 'invalid-zone-id'`) **before** filesystem guards (matching `add.js` flag validation order).
|
|
51
|
+
3. Resolve `cwd` via `resolveCwd` (failing via `failProjectNotInitialized({ event: 'domain_run' })` on unresolvable `cwd`).
|
|
52
|
+
4. Check that `terraform/cloudfront.tf` exists in `cwd` (failing with `errorCode: 'TERRAFORM_NOT_INITIALIZED'`, `reason: 'terraform-not-initialized'` if missing).
|
|
53
|
+
|
|
54
|
+
### 2. Pure HCL Builders for `terraform/domain.tf`
|
|
55
|
+
Instead of a single static template file, implement pure builder functions in `src/commands/domain.js`:
|
|
56
|
+
* **`renderDomainTfRoute53({ domain, zoneId })`:**
|
|
57
|
+
* Header marker: `# deploy-stack:domain-mode=route53` and `# deploy-stack:zone-id=${zoneId}`.
|
|
58
|
+
* `provider "aws" { alias = "us_east_1", region = "us-east-1" }`
|
|
59
|
+
* `resource "aws_acm_certificate" "domain"` (`provider = aws.us_east_1`, `domain_name = "${domain}"`, `validation_method = "DNS"`, `lifecycle { create_before_destroy = true }`).
|
|
60
|
+
* `resource "aws_route53_record" "domain_validation"` using a valid HCL map comprehension for `for_each`:
|
|
61
|
+
`for_each = { for dvo in aws_acm_certificate.domain.domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name, record = dvo.resource_record_value, type = dvo.resource_record_type } }`
|
|
62
|
+
with `zone_id = "${zoneId}"`, `allow_overwrite = true`, `ttl = 60`, `name = each.value.name`, `type = each.value.type`, `records = [each.value.record]`.
|
|
63
|
+
* `resource "aws_acm_certificate_validation" "domain"` (`provider = aws.us_east_1`, `certificate_arn = aws_acm_certificate.domain.arn`, `validation_record_fqdns = [for record in aws_route53_record.domain_validation : record.fqdn]`).
|
|
64
|
+
* `resource "aws_route53_record" "cdn_alias_a"` and `"cdn_alias_aaaa"` (`zone_id = "${zoneId}"`, `name = "${domain}"`, `type = "A"` / `"AAAA"`, `alias` block targeting `aws_cloudfront_distribution.cdn.domain_name`, `aws_cloudfront_distribution.cdn.hosted_zone_id`, and `evaluate_target_health = false`).
|
|
65
|
+
* Outputs: `custom_domain_url = "https://${domain}"`, `acm_certificate_arn = aws_acm_certificate.domain.arn`.
|
|
66
|
+
* **`renderDomainTfExternalPending({ domain })`:**
|
|
67
|
+
* Header marker: `# deploy-stack:domain-mode=external-pending`.
|
|
68
|
+
* `provider "aws" { alias = "us_east_1", region = "us-east-1" }` and `resource "aws_acm_certificate" "domain"`.
|
|
69
|
+
* Outputs: `acm_validation_records` (list of `{ name, type, value }` objects from `domain_validation_options`) and `custom_domain_cname_target = aws_cloudfront_distribution.cdn.domain_name`.
|
|
70
|
+
* **`appendValidationResource(domainTfContent)`:**
|
|
71
|
+
* Idempotently transitions `external-pending` to `external-active` (no-op if already `external-active` or `route53`), appending `resource "aws_acm_certificate_validation" "domain"` (`provider = aws.us_east_1`, `certificate_arn = aws_acm_certificate.domain.arn`) and `output "custom_domain_url"`.
|
|
72
|
+
|
|
73
|
+
### 3. CloudFront Patching in `terraform/cloudfront.tf` (`patchCloudFrontDomain` / `unpatchCloudFrontDomain`)
|
|
74
|
+
* Scope strictly to `resource "aws_cloudfront_distribution" "cdn"` in `terraform/cloudfront.tf` using `src/utils/hcl.js` (never touching `aws_cloudfront_distribution "storage_cdn"` in `s3.tf`).
|
|
75
|
+
* **`patchCloudFrontDomain(cloudfrontTfContent, domain)`:**
|
|
76
|
+
* Upsert `aliases = ["${domain}"]` inside `aws_cloudfront_distribution.cdn` (if an `aliases` list already exists, ensure `domain` is present without clobbering other user-added entries; if replacing a previous `domain.tf` domain on `--force`, replace the old managed domain).
|
|
77
|
+
* Replace the entire `viewer_certificate { ... }` block (removing `cloudfront_default_certificate = true`, which is mutually exclusive with `acm_certificate_arn`) with:
|
|
78
|
+
`acm_certificate_arn = aws_acm_certificate_validation.domain.certificate_arn`, `ssl_support_method = "sni-only"`, `minimum_protocol_version = "TLSv1.2_2021"`.
|
|
79
|
+
* **`unpatchCloudFrontDomain(cloudfrontTfContent, domain)`:**
|
|
80
|
+
* Remove `domain` from `aliases` (and remove the `aliases` attribute entirely if no aliases remain).
|
|
81
|
+
* Replace the `viewer_certificate { ... }` block back to `viewer_certificate { cloudfront_default_certificate = true }`.
|
|
82
|
+
|
|
83
|
+
### 4. Subcommand Behaviors & Idempotency Rules
|
|
84
|
+
* **`domain add <domain>`:**
|
|
85
|
+
* If `terraform/domain.tf` already exists:
|
|
86
|
+
* If `--force` is not passed, fail with `errorCode: 'DOMAIN_ALREADY_CONFIGURED'`, `reason: 'domain-already-configured'` (hinting to pass `--force` to overwrite or `domain verify` to activate).
|
|
87
|
+
* If `--force` is passed and a previous domain was patched into `cloudfront.tf`, replace the old domain cleanly.
|
|
88
|
+
* If `--zone-id` is passed (with or without `--activate`): write Route 53 `domain.tf` and immediately patch `terraform/cloudfront.tf` (`mode = 'route53'`).
|
|
89
|
+
* If `--zone-id` is omitted and `--activate` is passed: write external-active `domain.tf` and patch `terraform/cloudfront.tf` (`mode = 'external-active'`).
|
|
90
|
+
* If `--zone-id` is omitted and `--activate` is not passed: write external-pending `domain.tf` and leave `terraform/cloudfront.tf` untouched (`mode = 'external-pending'`).
|
|
91
|
+
* **`domain verify` (alias `activate`):**
|
|
92
|
+
* If `terraform/domain.tf` does not exist, fail with `errorCode: 'DOMAIN_NOT_CONFIGURED'`, `reason: 'domain-not-configured'`.
|
|
93
|
+
* If mode is already `route53` or `external-active`, ensure `cloudfront.tf` is patched (idempotent no-op) and return `{ ok: true, mode, alreadyActive: true }`.
|
|
94
|
+
* Otherwise, transition `domain.tf` via `appendValidationResource`, patch `terraform/cloudfront.tf`, remind the user to ensure their external DNS CNAME records are in place before running `npx deploy-stack apply`, and return `{ ok: true, mode: 'external-active' }`.
|
|
95
|
+
* **`domain status`:**
|
|
96
|
+
* If `terraform/domain.tf` does not exist, print a friendly non-failing message (`No custom domain configured. Run npx deploy-stack domain add <domain> to get started.`), emit `trackSuccess('domain_run', { subcommand: 'status', configured: false })`, and return `{ ok: true, configured: false }`.
|
|
97
|
+
* Otherwise, parse `domain.tf` via `parseDomainTf` and call `getTerraformOutputs(path.join(cwd, 'terraform'))` from `src/utils/terraform.js` (allowing an optional `options.getOutputs` injection override for unit tests). If `acm_validation_records` / `custom_domain_cname_target` outputs are present, render the copy-paste DNS record table.
|
|
98
|
+
* **`domain remove`:**
|
|
99
|
+
* If `terraform/domain.tf` does not exist, fail with `errorCode: 'DOMAIN_NOT_CONFIGURED'`, `reason: 'domain-not-configured'`.
|
|
100
|
+
* Confirmation behavior: if `--yes` is passed, skip the prompt in both TTY and headless modes. If `--yes` is not passed: in headless mode (`resolveHeadless(options)`), fail with `errorCode: 'CONFIRMATION_REQUIRED'`, `reason: 'confirmation-required'`; in interactive mode, prompt with Clack `confirm` (handling `isCancel` cleanly).
|
|
101
|
+
* Unpatch `terraform/cloudfront.tf` (if present) and delete `terraform/domain.tf`.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Part 2: Transactional Email & DKIM Automation (`deploy-stack add email:ses`)
|
|
106
|
+
|
|
107
|
+
1. **Registry & Visualizer (`src/utils/addons.js`, `src/utils/visualizer.js`):**
|
|
108
|
+
* Add `'email:ses'` to `ADDON_REGISTRY` (`file: 'ses.tf'`, `template: 'ses.tf'`, `label: 'Amazon SES (Transactional Email & DKIM)'`, `cost: { monthlyFixed: 0, summary: '$0/mo fixed baseline; $0.10 per 1,000 emails sent' }`).
|
|
109
|
+
* Add `ADDON_ENV_VARS['email:ses']` with `SES_FROM_EMAIL: (ctx) => ctx.sesFromEmail` and `SES_REGION: (ctx) => ctx.region`, and register `['SES_FROM_EMAIL', 'SES_REGION']` in `upsertKeys`.
|
|
110
|
+
2. **Option Resolution & Validation Order (`resolveAddonOptions` in `src/commands/add.js`):**
|
|
111
|
+
* Parse `--domain`, `--from-email`, and `--zone-id` in `parseAddArgs(args)`.
|
|
112
|
+
* **Pre-Guard Flag Validation (before `terraform/main.tf` guard, matching `--model` and `--partition-key`):**
|
|
113
|
+
* If an explicit `--domain` was passed on `email:ses`, validate via `isValidDomain` (failing with `errorCode: 'INVALID_DOMAIN'`, `reason: 'invalid-domain'`).
|
|
114
|
+
* If an explicit `--zone-id` was passed on `email:ses`, validate via `normalizeZoneId` (failing with `errorCode: 'INVALID_ZONE_ID'`, `reason: 'invalid-zone-id'`).
|
|
115
|
+
* If an explicit `--from-email` was passed alongside an explicit `--domain`, validate via `isValidFromEmail(fromEmail, domain)` (failing with `errorCode: 'INVALID_FROM_EMAIL'`, `reason: 'invalid-from-email'`).
|
|
116
|
+
* **Post-Guard Domain Resolution (after `terraform/main.tf` & overwrite guards):**
|
|
117
|
+
1. Use explicit `--domain` if provided.
|
|
118
|
+
2. Else if `terraform/domain.tf` exists in `cwd`, read it via `parseDomainTf` and reuse its `domain` (and its `zoneId` if `--zone-id` was not explicitly passed).
|
|
119
|
+
3. Else if `isInteractive` is true (reusing `add.js`'s existing `isInteractive` check), prompt via Clack `text` and handle cancellation through `add.js`'s existing `cancelSelection` telemetry helper.
|
|
120
|
+
4. Else fail with `errorCode: 'MISSING_SES_DOMAIN'`, `reason: 'missing-ses-domain'`.
|
|
121
|
+
* Validate the resolved `domain` (`INVALID_DOMAIN`) and resolved `fromEmail = options.fromEmail || 'noreply@' + domain` via `isValidFromEmail(fromEmail, domain)` (`INVALID_FROM_EMAIL`).
|
|
122
|
+
3. **Template (`templates/terraform/addons/ses.tf`):**
|
|
123
|
+
* Do **not** redeclare `data "aws_caller_identity" "current"` (avoiding collision with `s3.tf`).
|
|
124
|
+
* Use `{{SES_DOMAIN}}`, `{{SES_FROM_EMAIL}}`, `{{REGION}}`, and `{{SES_ROUTE53_RECORDS_BLOCK}}`.
|
|
125
|
+
* Attach `resource "aws_iam_role_policy" "ses_send"` to `role = aws_iam_role.task_role.id` (matching `main.tf`), granting `["ses:SendEmail", "ses:SendRawEmail"]` on `Resource = "*"` with a `StringLike` condition on `"ses:FromAddress": ["*@{{SES_DOMAIN}}", "{{SES_FROM_EMAIL}}"]`.
|
|
126
|
+
* In `aws_route53_record.ses_mail_from_mx` (rendered inside `{{SES_ROUTE53_RECORDS_BLOCK}}` when `zoneId` is present), use `10 feedback-smtp.{{REGION}}.amazonses.com`.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Part 3: `doctor_run` Telemetry & `DATABASE_URL` Runtime Synthesis
|
|
131
|
+
|
|
132
|
+
1. **`doctor_run` Telemetry (`src/commands/doctor.js`):**
|
|
133
|
+
* Keep `passed_checks`, `failed_checks` (in their existing `DOCTOR_CHECKS` execution order—do **not** sort alphabetically so existing `tests/doctor.test.js` assertions stay untouched), and `total_failed` unchanged.
|
|
134
|
+
* Reuse `detectCiProvider()` from `src/core/telemetry.js` (no new CI detection logic in `doctor.js`).
|
|
135
|
+
2. **Pure `buildMigrationCommand` Helper (`src/commands/db/migrate.js`):**
|
|
136
|
+
* Extract `buildMigrationCommand(resolvedCmd, containerDef)` returning `['sh', '-c', ...]`:
|
|
137
|
+
* Inspect `containerDef?.environment` and `containerDef?.secrets` (treating missing arrays as empty).
|
|
138
|
+
* If `DATABASE_URL` is already present in either array, or if `DB_HOST` is **not** present in either array (which also preserves all existing `tests/db.test.js` tests that pass env-less mock task definitions), return `['sh', '-c', resolvedCmd]` unchanged.
|
|
139
|
+
* When `DB_HOST` is present (alongside `DB_USER` and `DB_PASSWORD` in `environment` or `secrets`) and `DATABASE_URL` is absent, return `['sh', '-c', 'export DATABASE_URL="${DATABASE_URL:-postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT:-5432}/${DB_NAME:-postgres}}"; ' + resolvedCmd]`.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Part 4: Documentation, Sidebar & Unit Tests
|
|
144
|
+
|
|
145
|
+
1. **Documentation & Sidebar (`apps/docs/`):**
|
|
146
|
+
* Create `apps/docs/src/content/docs/cli/domain.md` and add `{ label: 'domain', slug: 'cli/domain' }` to the CLI Reference sidebar in `apps/docs/astro.config.mjs`.
|
|
147
|
+
* Update `apps/docs/src/content/docs/cli/add.md` to document `deploy-stack add email:ses`.
|
|
148
|
+
* Check off **Custom Domains & Automated SSL** and **Transactional Email & DKIM Automation** in both `README.md` and `apps/docs/src/content/docs/roadmap.md`.
|
|
149
|
+
2. **Unit Tests (`tests/domain.test.js`, `tests/add.test.js`, `tests/db.test.js`):**
|
|
150
|
+
* Use `stripVTControlCharacters` from `'node:util'` on all captured log assertions.
|
|
151
|
+
* Add unit tests in `tests/domain.test.js` covering `src/utils/hcl.js`, `src/utils/domains.js`, `domain add` (Route 53 1-step vs. External DNS Stage 1 & `--activate`), `domain verify` (Stage 2 transition + idempotency), `domain status` (configured with `getOutputs` override vs. unconfigured), `domain remove` (`--yes`, interactive confirm, headless guard, and `cloudfront.tf` unpatching), and fuzzer-hardened inputs.
|
|
152
|
+
* Append `email:ses` tests to `tests/add.test.js` covering explicit `--domain`, auto-detection from `terraform/domain.tf` (including `zoneId` reuse), interactive prompt & cancel telemetry, pre-guard validation (`INVALID_DOMAIN`, `INVALID_ZONE_ID`, `INVALID_FROM_EMAIL` domain mismatch), `MISSING_SES_DOMAIN`, `aws_iam_role.task_role.id` binding, and `SES_FROM_EMAIL` / `SES_REGION` injection.
|
|
153
|
+
* Append `buildMigrationCommand` unit tests to `tests/db.test.js` covering env-less containers (no-wrap), containers with `DATABASE_URL` already defined (no-wrap), and containers with `DB_HOST` + `DB_USER` + `DB_PASSWORD` (synthesizes `DATABASE_URL`).
|