From a892a4ad85f8406f641fc3912049380e11dfe3dd Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Thu, 9 Apr 2026 22:51:45 -0500 Subject: [PATCH 1/9] chore(infra): add us-east-1 availability zones to CDK context --- infrastructure/cdk.context.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/infrastructure/cdk.context.json b/infrastructure/cdk.context.json index e4b5b41f..3b8d5ed9 100644 --- a/infrastructure/cdk.context.json +++ b/infrastructure/cdk.context.json @@ -81,5 +81,13 @@ "us-west-2b", "us-west-2c", "us-west-2d" + ], + "availability-zones:account=370613533818:region=us-east-1": [ + "us-east-1a", + "us-east-1b", + "us-east-1c", + "us-east-1d", + "us-east-1e", + "us-east-1f" ] } From 89da074b0073937a3fd4e5d451e2ecfb0ef3a291 Mon Sep 17 00:00:00 2001 From: Julian Mino <5713710+jmino@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:20:10 -0500 Subject: [PATCH 2/9] Update frontend-stack.ts Fix to extract the parent domain for the zone lookup. Signed-off-by: Julian Mino <5713710+jmino@users.noreply.github.com> --- infrastructure/lib/frontend-stack.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/infrastructure/lib/frontend-stack.ts b/infrastructure/lib/frontend-stack.ts index 017ba11d..5d197c62 100644 --- a/infrastructure/lib/frontend-stack.ts +++ b/infrastructure/lib/frontend-stack.ts @@ -274,9 +274,15 @@ export class FrontendStack extends cdk.Stack { // Create Route53 A record if domain is configured if (config.domainName) { + // Extract parent zone from domain name (e.g., "app.example.com" → "example.com") + const domainParts = config.domainName.split('.'); + const zoneName = domainParts.length > 2 + ? domainParts.slice(1).join('.') + : config.domainName; + // Look up the hosted zone const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { - domainName: config.domainName, + domainName: zoneName, }); // Create A record aliasing to CloudFront From a1d15b2712e1d4e526cc28aa213388c1f153a121 Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Fri, 10 Apr 2026 00:51:14 -0500 Subject: [PATCH 3/9] feat: add optional existing VPC import support for InfrastructureStack Allow importing a pre-existing VPC via Vpc.fromVpcAttributes() instead of always creating a new one. When the optional existingVpc config block is present, the stack skips VPC/Subnet/NAT creation and uses the provided VPC ID, subnet IDs, and availability zones. Downstream stacks require zero changes since they consume network resources via SSM. --- .github/ACTIONS-REFERENCE.md | 5 + .github/docs/deploy/step-03-github-config.md | 15 + .github/workflows/infrastructure.yml | 18 + .kiro/specs/existing-vpc-support/.config.kiro | 1 + .kiro/specs/existing-vpc-support/design.md | 308 +++++++++++++ .../existing-vpc-support/requirements.md | 108 +++++ .kiro/specs/existing-vpc-support/tasks.md | 115 +++++ infrastructure/lib/config.ts | 138 +++++- infrastructure/lib/infrastructure-stack.ts | 70 +-- infrastructure/package-lock.json | 41 ++ infrastructure/package.json | 1 + .../test/existing-vpc-config.property.test.ts | 417 ++++++++++++++++++ infrastructure/test/helpers/mock-config.ts | 1 + .../infrastructure-stack-property.test.ts | 154 +++++++ scripts/common/load-env.sh | 34 ++ scripts/stack-infrastructure/deploy.sh | 13 + scripts/stack-infrastructure/synth.sh | 13 + 17 files changed, 1423 insertions(+), 29 deletions(-) create mode 100644 .kiro/specs/existing-vpc-support/.config.kiro create mode 100644 .kiro/specs/existing-vpc-support/design.md create mode 100644 .kiro/specs/existing-vpc-support/requirements.md create mode 100644 .kiro/specs/existing-vpc-support/tasks.md create mode 100644 infrastructure/test/existing-vpc-config.property.test.ts create mode 100644 infrastructure/test/infrastructure-stack-property.test.ts diff --git a/.github/ACTIONS-REFERENCE.md b/.github/ACTIONS-REFERENCE.md index 229ff65d..40c59ff2 100644 --- a/.github/ACTIONS-REFERENCE.md +++ b/.github/ACTIONS-REFERENCE.md @@ -34,6 +34,11 @@ GitHub provides two mechanisms for storing configuration values: | CDK_CERTIFICATE_ARN | Variable | No | None | Infrastructure | ACM certificate ARN for HTTPS on ALB | | CDK_CORS_ORIGINS | Variable | No | None | All | Additional CORS origins appended to the auto-derived `https://{CDK_DOMAIN_NAME}`. Comma-separated. Use for localhost during local dev (e.g., `http://localhost:4200`) or extra domains. | | CDK_DOMAIN_NAME | Variable | No | None | All | Primary domain name (e.g., 'alpha.boisestate.ai'). Auto-applied as `https://{value}` to CORS origins for every stack. This is the primary mechanism for CORS configuration. | +| CDK_EXISTING_VPC_ID | Variable | No | None | Infrastructure | VPC ID of a pre-existing VPC to import instead of creating a new one (e.g., `vpc-0abc123def456`). When set, the stack uses `Vpc.fromVpcAttributes()` and skips VPC creation. | +| CDK_EXISTING_VPC_AZS | Variable | No | None | Infrastructure | Comma-separated availability zones for the existing VPC (e.g., `us-west-2a,us-west-2b`). Required when `CDK_EXISTING_VPC_ID` is set. | +| CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS | Variable | No | None | Infrastructure | Comma-separated public subnet IDs for the existing VPC (e.g., `subnet-0a1b2c3d4e5f6,subnet-0f6e5d4c3b2a1`). Required when `CDK_EXISTING_VPC_ID` is set. | +| CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS | Variable | No | None | Infrastructure | Comma-separated private subnet IDs for the existing VPC (e.g., `subnet-0aabbccddee11,subnet-0ffeeddccbbaa`). Required when `CDK_EXISTING_VPC_ID` is set. | +| CDK_EXISTING_VPC_CIDR | Variable | No | None | Infrastructure | CIDR block of the existing VPC (e.g., `10.0.0.0/16`). Optional; used for the SSM vpc-cidr parameter when importing a VPC. | | CDK_FILE_UPLOAD_CORS_ORIGINS | Variable | No | None | Infrastructure | Additional CORS origins for the file upload S3 bucket only (appended to global CORS origins) | | CDK_FILE_UPLOAD_MAX_SIZE_MB | Variable | No | `10` | Infrastructure, App API | Maximum file upload size in megabytes | | CDK_FINE_TUNING_ENABLED | Variable | No | `false` | SageMaker Fine-Tuning, App API | Enable SageMaker fine-tuning stack and App API fine-tuning routes. Must be `true` before deploying the SageMaker Fine-Tuning workflow. | diff --git a/.github/docs/deploy/step-03-github-config.md b/.github/docs/deploy/step-03-github-config.md index 165308eb..f8993784 100644 --- a/.github/docs/deploy/step-03-github-config.md +++ b/.github/docs/deploy/step-03-github-config.md @@ -101,6 +101,21 @@ This prefix is prepended to all AWS resource names to avoid conflicts. Use somet |---------------|---------|-------------| | `CDK_FINE_TUNING_ENABLED` | `false` | Set to `true` to enable the SageMaker Fine-Tuning stack. Must be set before running the fine-tuning deployment workflow in Step 4. | +### Existing VPC (Optional) + +To import a pre-existing VPC instead of creating a new one, add these variables: + +| Variable Name | Example | Description | +|---------------|---------|-------------| +| `CDK_EXISTING_VPC_ID` | `vpc-0abc123def456` | VPC ID to import | +| `CDK_EXISTING_VPC_AZS` | `us-east-1a,us-east-1b` | Comma-separated availability zones | +| `CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS` | `subnet-0a1b2c3d4e5f6,subnet-0f6e5d4c3b2a1` | Comma-separated public subnet IDs | +| `CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS` | `subnet-0aabbccddee11,subnet-0ffeeddccbbaa` | Comma-separated private subnet IDs | +| `CDK_EXISTING_VPC_CIDR` | `192.168.0.0/16` | VPC CIDR block (optional) | + +> [!NOTE] +> The number of public and private subnets must match the number of availability zones. When these variables are not set, the infrastructure stack creates a new VPC automatically. + --- ## 3c. Authentication diff --git a/.github/workflows/infrastructure.yml b/.github/workflows/infrastructure.yml index bb10e65e..0e98b3b5 100644 --- a/.github/workflows/infrastructure.yml +++ b/.github/workflows/infrastructure.yml @@ -165,6 +165,12 @@ jobs: CDK_FILE_UPLOAD_MAX_SIZE_MB: ${{ vars.CDK_FILE_UPLOAD_MAX_SIZE_MB }} CDK_COGNITO_DOMAIN_PREFIX: ${{ vars.CDK_COGNITO_DOMAIN_PREFIX }} CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + # Existing VPC (optional — import a pre-existing VPC instead of creating one) + CDK_EXISTING_VPC_ID: ${{ vars.CDK_EXISTING_VPC_ID }} + CDK_EXISTING_VPC_AZS: ${{ vars.CDK_EXISTING_VPC_AZS }} + CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS: ${{ vars.CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS }} + CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS: ${{ vars.CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS }} + CDK_EXISTING_VPC_CIDR: ${{ vars.CDK_EXISTING_VPC_CIDR }} AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -240,6 +246,12 @@ jobs: CDK_FILE_UPLOAD_MAX_SIZE_MB: ${{ vars.CDK_FILE_UPLOAD_MAX_SIZE_MB }} CDK_COGNITO_DOMAIN_PREFIX: ${{ vars.CDK_COGNITO_DOMAIN_PREFIX }} CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + # Existing VPC (optional — import a pre-existing VPC instead of creating one) + CDK_EXISTING_VPC_ID: ${{ vars.CDK_EXISTING_VPC_ID }} + CDK_EXISTING_VPC_AZS: ${{ vars.CDK_EXISTING_VPC_AZS }} + CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS: ${{ vars.CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS }} + CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS: ${{ vars.CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS }} + CDK_EXISTING_VPC_CIDR: ${{ vars.CDK_EXISTING_VPC_CIDR }} AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -327,6 +339,12 @@ jobs: CDK_INFERENCE_API_MEMORY: ${{ vars.CDK_INFERENCE_API_MEMORY }} CDK_COGNITO_DOMAIN_PREFIX: ${{ vars.CDK_COGNITO_DOMAIN_PREFIX }} CDK_AWS_ACCOUNT: ${{ vars.CDK_AWS_ACCOUNT }} + # Existing VPC (optional — import a pre-existing VPC instead of creating one) + CDK_EXISTING_VPC_ID: ${{ vars.CDK_EXISTING_VPC_ID }} + CDK_EXISTING_VPC_AZS: ${{ vars.CDK_EXISTING_VPC_AZS }} + CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS: ${{ vars.CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS }} + CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS: ${{ vars.CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS }} + CDK_EXISTING_VPC_CIDR: ${{ vars.CDK_EXISTING_VPC_CIDR }} AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.kiro/specs/existing-vpc-support/.config.kiro b/.kiro/specs/existing-vpc-support/.config.kiro new file mode 100644 index 00000000..cb6c56c7 --- /dev/null +++ b/.kiro/specs/existing-vpc-support/.config.kiro @@ -0,0 +1 @@ +{"specId": "a5cb1565-4744-4c7d-83d7-d948907b60f0", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/existing-vpc-support/design.md b/.kiro/specs/existing-vpc-support/design.md new file mode 100644 index 00000000..6e482aa4 --- /dev/null +++ b/.kiro/specs/existing-vpc-support/design.md @@ -0,0 +1,308 @@ +# Design Document: Existing VPC Support + +## Overview + +This feature introduces an optional `existingVpc` configuration block that allows the InfrastructureStack to import a pre-existing VPC via `Vpc.fromVpcAttributes()` instead of always creating a new one. The change is confined to two files (`config.ts` and `infrastructure-stack.ts`) plus CI/CD script updates. Downstream stacks require zero changes because they already consume network resources exclusively through SSM parameters. + +The design follows a "branch at config, converge before use" pattern: the VPC creation vs. import decision is made once, early in the constructor, and the resulting `IVpc` reference is assigned to `this.vpc` so that all downstream resource creation (ALB, ECS Cluster, security groups, SSM exports) proceeds identically regardless of the VPC's origin. + +### Key Design Decisions + +1. **`IVpc` over `Vpc`**: The `this.vpc` property type changes from `ec2.Vpc` to `ec2.IVpc` (the interface). `Vpc.fromVpcAttributes()` returns `IVpc`, not `Vpc`. This is the standard CDK pattern for imported resources and is compatible with all downstream consumers (ALB, ECS, security groups). + +2. **Validation at config load time**: All existing VPC field validation (regex patterns, array lengths, count matching) happens inside `loadConfig()` so that misconfigurations fail at `cdk synth` time, not at deploy time. + +3. **Environment variable precedence**: Existing VPC fields follow the same `env var > CDK context` precedence pattern used by every other config property in the project. + +4. **VPC CIDR bypass**: When `existingVpc` is present, the `vpcCidr` validation is skipped because it's only used for VPC creation. + +## Architecture + +```mermaid +flowchart TD + A[CDK Context / Env Vars] --> B[loadConfig] + B --> C{existingVpc present?} + C -->|Yes| D[Vpc.fromVpcAttributes] + C -->|No| E[new ec2.Vpc] + D --> F[this.vpc: IVpc] + E --> F + F --> G[ALB in public subnets] + F --> H[ECS Cluster] + F --> I[Security Groups] + F --> J[SSM Parameter Exports] + J --> K[Downstream Stacks via SSM] +``` + +### Configuration Flow + +```mermaid +flowchart LR + subgraph "CI/CD" + GH[GitHub Secrets/Vars] --> WF[infrastructure.yml env] + WF --> LE[load-env.sh exports] + LE --> SY[synth.sh --context] + LE --> DP[deploy.sh --context] + end + subgraph "CDK" + SY --> LC[loadConfig] + DP --> LC + CJ[cdk.context.json] --> LC + LC --> IS[InfrastructureStack] + end +``` + +## Components and Interfaces + +### 1. ExistingVpcConfig Interface + +New TypeScript interface added to `config.ts`: + +```typescript +export interface ExistingVpcConfig { + vpcId: string; // e.g. "vpc-0abc123def456" + availabilityZones: string[]; // e.g. ["us-west-2a", "us-west-2b"] + publicSubnetIds: string[]; // e.g. ["subnet-aaa", "subnet-bbb"] + privateSubnetIds: string[]; // e.g. ["subnet-ccc", "subnet-ddd"] + vpcCidrBlock?: string; // e.g. "10.0.0.0/16" (optional) +} +``` + +### 2. AppConfig Extension + +The `AppConfig` interface gains one optional field: + +```typescript +export interface AppConfig { + // ... existing fields ... + existingVpc?: ExistingVpcConfig; +} +``` + +### 3. loadConfig() Changes + +The `loadConfig()` function is extended to: + +1. Read `existingVpc` fields from environment variables (`CDK_EXISTING_VPC_*`) with fallback to CDK context (`existingVpc.*`). +2. If any existing VPC fields are present, assemble and validate an `ExistingVpcConfig` object. +3. Skip `vpcCidr` validation when `existingVpc` is present. + +### 4. validateExistingVpcConfig() Function + +New validation function called from `validateConfig()` when `existingVpc` is defined: + +```typescript +function validateExistingVpcConfig(config: ExistingVpcConfig): void { + // 1. vpcId matches /^vpc-[a-z0-9]+$/ + // 2. availabilityZones.length between 2 and 6 + // 3. publicSubnetIds.length >= 2, each matches /^subnet-[a-z0-9]+$/ + // 4. privateSubnetIds.length >= 2, each matches /^subnet-[a-z0-9]+$/ + // 5. publicSubnetIds.length === availabilityZones.length + // 6. privateSubnetIds.length === availabilityZones.length +} +``` + +### 5. InfrastructureStack VPC Branch + +The VPC creation section of the constructor becomes: + +```typescript +if (config.existingVpc) { + this.vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', { + vpcId: config.existingVpc.vpcId, + availabilityZones: config.existingVpc.availabilityZones, + publicSubnetIds: config.existingVpc.publicSubnetIds, + privateSubnetIds: config.existingVpc.privateSubnetIds, + }); +} else { + this.vpc = new ec2.Vpc(this, 'Vpc', { /* existing config */ }); +} +``` + +The `this.vpc` type changes from `ec2.Vpc` to `ec2.IVpc`. + +### 6. SSM Export Adjustments + +For the VPC CIDR SSM parameter, the value source depends on the path taken: + +- **Imported VPC with `vpcCidrBlock`**: Use `config.existingVpc.vpcCidrBlock` +- **Imported VPC without `vpcCidrBlock`**: Use `this.vpc.vpcCidrBlock` (CDK token) +- **Created VPC**: Use `this.vpc.vpcCidrBlock` (same as today) + +For subnet IDs and AZs with an imported VPC, the values come directly from the config (since `fromVpcAttributes` stores them): + +```typescript +const privateSubnetIds = config.existingVpc + ? config.existingVpc.privateSubnetIds.join(',') + : this.vpc.privateSubnets.map(s => s.subnetId).join(','); +``` + +### 7. CI/CD Script Changes + +**load-env.sh**: Add exports for `CDK_EXISTING_VPC_ID`, `CDK_EXISTING_VPC_AZS`, `CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS`, `CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS`, `CDK_EXISTING_VPC_CIDR`. Add corresponding `build_cdk_context_params` entries that assemble the nested `existingVpc.*` context keys. + +**synth.sh / deploy.sh**: Add conditional `--context existingVpc.*` parameters when the environment variables are set. + +**infrastructure.yml**: Add `CDK_EXISTING_VPC_*` environment variables sourced from GitHub Variables. + +## Data Models + +### ExistingVpcConfig + +| Field | Type | Required | Validation | Source (env var) | Source (context) | +|---|---|---|---|---|---| +| `vpcId` | `string` | Yes | `/^vpc-[a-z0-9]+$/` | `CDK_EXISTING_VPC_ID` | `existingVpc.vpcId` | +| `availabilityZones` | `string[]` | Yes | 2–6 entries | `CDK_EXISTING_VPC_AZS` (comma-separated) | `existingVpc.availabilityZones` | +| `publicSubnetIds` | `string[]` | Yes | ≥2 entries, each `/^subnet-[a-z0-9]+$/` | `CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS` (comma-separated) | `existingVpc.publicSubnetIds` | +| `privateSubnetIds` | `string[]` | Yes | ≥2 entries, each `/^subnet-[a-z0-9]+$/` | `CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS` (comma-separated) | `existingVpc.privateSubnetIds` | +| `vpcCidrBlock` | `string` | No | CIDR format if provided | `CDK_EXISTING_VPC_CIDR` | `existingVpc.vpcCidrBlock` | + +### AppConfig Change + +```typescript +// Added field +existingVpc?: ExistingVpcConfig; +``` + +### InfrastructureStack Property Change + +```typescript +// Before +public readonly vpc: ec2.Vpc; + +// After +public readonly vpc: ec2.IVpc; +``` + +### cdk.context.json Example + +```json +{ + "existingVpc": { + "vpcId": "vpc-0abc123def456", + "availabilityZones": ["us-west-2a", "us-west-2b"], + "publicSubnetIds": ["subnet-pub1", "subnet-pub2"], + "privateSubnetIds": ["subnet-priv1", "subnet-priv2"], + "vpcCidrBlock": "10.0.0.0/16" + } +} +``` + + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Config round-trip from CDK context + +*For any* valid `ExistingVpcConfig` object (with valid vpcId, 2–6 AZs, matching subnet counts, valid subnet IDs), when set as CDK context and loaded via `loadConfig()`, the returned `config.existingVpc` should contain the same `vpcId`, `availabilityZones`, `publicSubnetIds`, `privateSubnetIds`, and `vpcCidrBlock` values. + +**Validates: Requirements 1.1, 1.3, 8.1** + +### Property 2: Config round-trip from environment variables + +*For any* valid `ExistingVpcConfig` object, when its fields are set as `CDK_EXISTING_VPC_*` environment variables (with arrays as comma-separated strings) and loaded via `loadConfig()`, the returned `config.existingVpc` should contain the same values. + +**Validates: Requirements 1.4** + +### Property 3: Environment variable precedence over CDK context + +*For any* two distinct valid `ExistingVpcConfig` objects A and B, when A is set via environment variables and B is set via CDK context, `loadConfig()` should return the values from A (environment variables win). + +**Validates: Requirements 8.2** + +### Property 4: Invalid field rejection + +*For any* `ExistingVpcConfig` where at least one field violates its validation rule (vpcId not matching `vpc-[a-z0-9]+`, AZ count outside [2,6], subnet IDs not matching `subnet-[a-z0-9]+`, or fewer than 2 public/private subnets), `loadConfig()` should throw an error. + +**Validates: Requirements 2.1, 2.2, 2.3, 2.4** + +### Property 5: Subnet count must match AZ count + +*For any* `ExistingVpcConfig` where the number of `publicSubnetIds` or `privateSubnetIds` does not equal the number of `availabilityZones`, `loadConfig()` should throw an error. + +**Validates: Requirements 2.5, 2.6** + +### Property 6: Imported VPC skips VPC creation and preserves downstream resources + +*For any* valid `ExistingVpcConfig`, when the InfrastructureStack is synthesized with that config, the resulting CloudFormation template should contain zero `AWS::EC2::VPC` resources, zero `AWS::EC2::Subnet` resources, zero `AWS::EC2::NatGateway` resources, and should still contain an `AWS::ElasticLoadBalancingV2::LoadBalancer`, an `AWS::ECS::Cluster`, and at least one `AWS::EC2::SecurityGroup`. + +**Validates: Requirements 3.1, 3.2, 5.2, 5.3, 5.4** + +### Property 7: SSM network parameter completeness + +*For any* valid `ExistingVpcConfig`, when the InfrastructureStack is synthesized, the CloudFormation template should contain SSM parameters for `vpc-id`, `private-subnet-ids`, `public-subnet-ids`, `availability-zones`, and `vpc-cidr` — the same set of network SSM parameters produced when creating a new VPC. + +**Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** + +### Property 8: vpcCidr validation bypass for imported VPCs + +*For any* valid `ExistingVpcConfig` and any `vpcCidr` value (including empty string or invalid CIDR), `loadConfig()` should not throw a VPC CIDR validation error when `existingVpc` is present. + +**Validates: Requirements 6.1** + +## Error Handling + +| Scenario | Behavior | Error Message Pattern | +|---|---|---| +| `vpcId` doesn't match `vpc-[a-z0-9]+` | `loadConfig()` throws | `"Invalid existingVpc.vpcId: ... Expected format: vpc-[a-z0-9]+"` | +| `availabilityZones` has <2 or >6 entries | `loadConfig()` throws | `"existingVpc.availabilityZones must contain between 2 and 6 entries"` | +| `publicSubnetIds` has <2 entries | `loadConfig()` throws | `"existingVpc.publicSubnetIds must contain at least 2 entries"` | +| `privateSubnetIds` has <2 entries | `loadConfig()` throws | `"existingVpc.privateSubnetIds must contain at least 2 entries"` | +| Subnet ID doesn't match `subnet-[a-z0-9]+` | `loadConfig()` throws | `"Invalid subnet ID: ... Expected format: subnet-[a-z0-9]+"` | +| Public subnet count ≠ AZ count | `loadConfig()` throws | `"existingVpc.publicSubnetIds count (N) must equal availabilityZones count (M)"` | +| Private subnet count ≠ AZ count | `loadConfig()` throws | `"existingVpc.privateSubnetIds count (N) must equal availabilityZones count (M)"` | +| `existingVpc` absent, `vpcCidr` invalid | `loadConfig()` throws | Existing CIDR validation error (unchanged) | +| `existingVpc` present, `vpcCidr` invalid | No error | Validation skipped | +| Partial env vars (e.g. VPC ID set but no subnets) | `loadConfig()` throws | Validation catches missing required fields | + +All validation errors are thrown at synth time (during `loadConfig()`), never at deploy time. This follows the existing project pattern where `validateConfig()` catches misconfigurations early. + +## Testing Strategy + +### Unit Tests (Jest) + +Unit tests cover specific examples and edge cases: + +- `existingVpc` absent → `config.existingVpc` is `undefined` (Req 1.2) +- Default VPC creation path unchanged when `existingVpc` absent (Req 3.3) +- VPC CIDR validation still enforced when `existingVpc` absent (Req 6.2) +- SSM CIDR parameter uses `vpc.vpcCidrBlock` when `existingVpc` omits `vpcCidrBlock` (Req 4.6) +- Error messages contain the failing field name (Req 2.7) +- Stack synth produces correct resource counts for both paths + +### Property-Based Tests (fast-check) + +The project currently uses Jest for testing. Property-based tests will use [fast-check](https://github.com/dubzzz/fast-check) as the PBT library, which integrates natively with Jest via `fc.assert(fc.property(...))`. + +Each property test must: +- Run a minimum of 100 iterations +- Reference its design document property in a comment tag +- Use custom arbitraries to generate valid/invalid `ExistingVpcConfig` objects + +**Tag format**: `Feature: existing-vpc-support, Property {N}: {title}` + +**Custom Arbitraries needed**: +- `validVpcId()`: generates strings matching `vpc-[a-z0-9]+` +- `validSubnetId()`: generates strings matching `subnet-[a-z0-9]+` +- `validAzList(n)`: generates arrays of n valid AZ strings +- `validExistingVpcConfig()`: composes the above into a complete valid config +- `invalidVpcId()`: generates strings NOT matching the pattern +- `invalidSubnetId()`: generates strings NOT matching the pattern + +**Property test → design property mapping**: + +| Test | Design Property | Min Iterations | +|---|---|---| +| Config round-trip from context | Property 1 | 100 | +| Config round-trip from env vars | Property 2 | 100 | +| Env var precedence | Property 3 | 100 | +| Invalid field rejection | Property 4 | 100 | +| Subnet-AZ count mismatch | Property 5 | 100 | +| Imported VPC skips creation | Property 6 | 100 | +| SSM parameter completeness | Property 7 | 100 | +| vpcCidr bypass | Property 8 | 100 | + +**Note**: Properties 6 and 7 involve CDK stack synthesis which is relatively slow. For these, 100 iterations may be adjusted down if synthesis time becomes prohibitive, but the minimum target is 100. + +**Dependency**: `fast-check` must be added as a dev dependency to `infrastructure/package.json`. diff --git a/.kiro/specs/existing-vpc-support/requirements.md b/.kiro/specs/existing-vpc-support/requirements.md new file mode 100644 index 00000000..798f40d1 --- /dev/null +++ b/.kiro/specs/existing-vpc-support/requirements.md @@ -0,0 +1,108 @@ +# Requirements Document + +## Introduction + +This feature adds support for importing an existing VPC into the InfrastructureStack instead of always creating a new one. Organizations that operate hub-and-spoke network topologies, shared VPCs, or centralized NAT architectures need to deploy the application into a pre-provisioned VPC rather than letting CDK create one from scratch. When an optional `existingVpc` configuration block is present, the stack imports the VPC via `Vpc.fromVpcAttributes()` and skips VPC creation. When absent, behavior remains identical to today. Downstream stacks (app-api, inference-api, gateway, frontend) require zero changes because they already consume network resources via SSM parameters. + +## Glossary + +- **Infrastructure_Stack**: The CDK stack (`InfrastructureStack`) that provisions foundational shared resources including VPC, ALB, ECS Cluster, security groups, DynamoDB tables, and SSM parameters. +- **Config_Loader**: The `loadConfig()` function in `infrastructure/lib/config.ts` that reads CDK context and environment variables to produce an `AppConfig` object. +- **Existing_VPC_Config**: The optional `existingVpc` configuration block within `AppConfig` that contains all attributes required to import a pre-existing VPC. +- **VPC_Importer**: The code path within Infrastructure_Stack that calls `Vpc.fromVpcAttributes()` to import an existing VPC instead of creating a new one. +- **SSM_Exporter**: The set of `ssm.StringParameter` constructs in Infrastructure_Stack that publish VPC ID, subnet IDs, availability zones, and CIDR to SSM Parameter Store for cross-stack consumption. +- **Downstream_Stack**: Any CDK stack (AppApiStack, InferenceApiStack, GatewayStack, FrontendStack) that imports network resources from SSM parameters written by Infrastructure_Stack. +- **Hub_And_Spoke_Topology**: A network architecture where a spoke VPC routes egress traffic through a Transit Gateway to a centralized security VPC that hosts shared NAT gateways. +- **Standalone_Topology**: A network architecture where the VPC has its own NAT gateways providing internet egress for private subnets. + +## Requirements + +### Requirement 1: Optional Existing VPC Configuration Block + +**User Story:** As a platform engineer, I want to provide an optional `existingVpc` configuration block in CDK context, so that I can import a pre-existing VPC instead of creating a new one. + +#### Acceptance Criteria + +1. THE Config_Loader SHALL accept an optional `existingVpc` object in CDK context containing `vpcId`, `availabilityZones`, `publicSubnetIds`, `privateSubnetIds`, and optionally `vpcCidrBlock`. +2. WHEN the `existingVpc` block is absent from CDK context, THE Config_Loader SHALL return an `AppConfig` with `existingVpc` set to `undefined`. +3. WHEN the `existingVpc` block is present, THE Config_Loader SHALL parse all provided fields into a typed `ExistingVpcConfig` object. +4. THE Config_Loader SHALL support loading `existingVpc` fields from environment variables with the `CDK_EXISTING_VPC_` prefix as an alternative to CDK context. + +### Requirement 2: Existing VPC Configuration Validation + +**User Story:** As a platform engineer, I want the CDK stack to validate my existing VPC configuration at synth time, so that I catch misconfigurations before deployment. + +#### Acceptance Criteria + +1. WHEN the `existingVpc` block is present, THE Config_Loader SHALL validate that `vpcId` matches the pattern `vpc-[a-z0-9]+`. +2. WHEN the `existingVpc` block is present, THE Config_Loader SHALL validate that `availabilityZones` contains between 2 and 6 entries. +3. WHEN the `existingVpc` block is present, THE Config_Loader SHALL validate that `publicSubnetIds` contains at least 2 entries and each entry matches the pattern `subnet-[a-z0-9]+`. +4. WHEN the `existingVpc` block is present, THE Config_Loader SHALL validate that `privateSubnetIds` contains at least 2 entries and each entry matches the pattern `subnet-[a-z0-9]+`. +5. WHEN the `existingVpc` block is present, THE Config_Loader SHALL validate that the count of `publicSubnetIds` equals the count of `availabilityZones`. +6. WHEN the `existingVpc` block is present, THE Config_Loader SHALL validate that the count of `privateSubnetIds` equals the count of `availabilityZones`. +7. IF any validation check fails, THEN THE Config_Loader SHALL throw an error with a descriptive message identifying the failing field and expected format. + +### Requirement 3: VPC Import via fromVpcAttributes + +**User Story:** As a platform engineer, I want the InfrastructureStack to import my existing VPC using `Vpc.fromVpcAttributes()`, so that CDK does not create duplicate network resources. + +#### Acceptance Criteria + +1. WHEN the `existingVpc` configuration is present, THE Infrastructure_Stack SHALL call `Vpc.fromVpcAttributes()` with the provided `vpcId`, `availabilityZones`, `publicSubnetIds`, and `privateSubnetIds`. +2. WHEN the `existingVpc` configuration is present, THE Infrastructure_Stack SHALL skip creation of the `new ec2.Vpc()` construct entirely. +3. WHEN the `existingVpc` configuration is absent, THE Infrastructure_Stack SHALL create a new VPC using `new ec2.Vpc()` with the existing `vpcCidr`, `maxAzs`, and `natGateways` settings. +4. THE Infrastructure_Stack SHALL assign the imported or created VPC to the same `this.vpc` property so that all downstream resource creation (ALB, ECS Cluster, security groups) uses the VPC without conditional branching. + +### Requirement 4: SSM Parameter Export Consistency + +**User Story:** As a platform engineer, I want the same SSM parameters to be published regardless of whether the VPC is created or imported, so that downstream stacks work without modification. + +#### Acceptance Criteria + +1. THE SSM_Exporter SHALL write the VPC ID to `/${projectPrefix}/network/vpc-id` for both created and imported VPCs. +2. THE SSM_Exporter SHALL write comma-separated private subnet IDs to `/${projectPrefix}/network/private-subnet-ids` for both created and imported VPCs. +3. THE SSM_Exporter SHALL write comma-separated public subnet IDs to `/${projectPrefix}/network/public-subnet-ids` for both created and imported VPCs. +4. THE SSM_Exporter SHALL write comma-separated availability zones to `/${projectPrefix}/network/availability-zones` for both created and imported VPCs. +5. WHEN the `existingVpc` configuration includes `vpcCidrBlock`, THE SSM_Exporter SHALL write the provided CIDR to `/${projectPrefix}/network/vpc-cidr`. +6. WHEN the `existingVpc` configuration omits `vpcCidrBlock`, THE SSM_Exporter SHALL write the value from `vpc.vpcCidrBlock` to `/${projectPrefix}/network/vpc-cidr`. + +### Requirement 5: Downstream Stack Compatibility + +**User Story:** As a platform engineer, I want downstream stacks to continue working without any code changes when I switch between a new VPC and an imported VPC, so that the migration is non-disruptive. + +#### Acceptance Criteria + +1. THE Downstream_Stack instances (AppApiStack, InferenceApiStack, GatewayStack, FrontendStack) SHALL import network resources exclusively via SSM parameters and require zero code changes. +2. THE Infrastructure_Stack SHALL place the ALB in public subnets of the imported VPC using the same `vpcSubnets` selection as the created VPC path. +3. THE Infrastructure_Stack SHALL create the ECS Cluster within the imported VPC using the same configuration as the created VPC path. +4. THE Infrastructure_Stack SHALL create security groups within the imported VPC using the same rules as the created VPC path. + +### Requirement 6: VPC CIDR Validation Bypass for Imported VPCs + +**User Story:** As a platform engineer, I want the `vpcCidr` validation to be skipped when I import an existing VPC, so that I am not forced to provide a CIDR that is only used for VPC creation. + +#### Acceptance Criteria + +1. WHEN the `existingVpc` configuration is present, THE Config_Loader SHALL skip validation of the `vpcCidr` field. +2. WHEN the `existingVpc` configuration is absent, THE Config_Loader SHALL continue to validate the `vpcCidr` field as it does today. + +### Requirement 7: CI/CD Pipeline Configuration Support + +**User Story:** As a DevOps engineer, I want to configure the existing VPC via GitHub Actions environment variables, so that I can manage VPC configuration through the standard CI/CD pipeline. + +#### Acceptance Criteria + +1. THE load-env.sh script SHALL export `CDK_EXISTING_VPC_ID`, `CDK_EXISTING_VPC_AZS`, `CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS`, `CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS`, and `CDK_EXISTING_VPC_CIDR` from environment variables with fallback to CDK context. +2. THE build_cdk_context_params function SHALL include `existingVpc.*` context parameters only when the corresponding environment variables are set and non-empty. +3. THE synth.sh and deploy.sh scripts for the infrastructure stack SHALL pass `existingVpc.*` context parameters when the environment variables are set. +4. WHEN none of the `CDK_EXISTING_VPC_*` environment variables are set, THE CI/CD pipeline SHALL behave identically to today with no existing VPC configuration passed. + +### Requirement 8: CDK Context JSON Configuration Support + +**User Story:** As a platform engineer, I want to configure the existing VPC directly in `cdk.context.json`, so that I can manage VPC configuration alongside other infrastructure settings. + +#### Acceptance Criteria + +1. THE Config_Loader SHALL read the `existingVpc` block from `cdk.context.json` when present. +2. WHEN both environment variables and CDK context provide existing VPC configuration, THE Config_Loader SHALL give precedence to environment variables. +3. THE `cdk.context.json` SHALL support the `existingVpc` block as a nested object with keys `vpcId`, `availabilityZones`, `publicSubnetIds`, `privateSubnetIds`, and `vpcCidrBlock`. diff --git a/.kiro/specs/existing-vpc-support/tasks.md b/.kiro/specs/existing-vpc-support/tasks.md new file mode 100644 index 00000000..0520e54e --- /dev/null +++ b/.kiro/specs/existing-vpc-support/tasks.md @@ -0,0 +1,115 @@ +# Implementation Plan: Existing VPC Support + +## Overview + +Add an optional `existingVpc` configuration block that allows importing a pre-existing VPC via `Vpc.fromVpcAttributes()` instead of always creating a new one. Changes are confined to `config.ts`, `infrastructure-stack.ts`, CI/CD scripts, and corresponding tests. Downstream stacks require zero changes. + +## Tasks + +- [x] 1. Add ExistingVpcConfig interface and extend AppConfig + - [x] 1.1 Define `ExistingVpcConfig` interface and add optional `existingVpc` field to `AppConfig` in `infrastructure/lib/config.ts` + - Add `ExistingVpcConfig` interface with `vpcId`, `availabilityZones`, `publicSubnetIds`, `privateSubnetIds`, `vpcCidrBlock?` + - Add `existingVpc?: ExistingVpcConfig` to `AppConfig` + - _Requirements: 1.1, 1.3_ + + - [x] 1.2 Extend `loadConfig()` to parse `existingVpc` from environment variables and CDK context + - Read `CDK_EXISTING_VPC_ID`, `CDK_EXISTING_VPC_AZS`, `CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS`, `CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS`, `CDK_EXISTING_VPC_CIDR` with fallback to `existingVpc.*` context + - Split comma-separated env var strings into arrays + - Only assemble `ExistingVpcConfig` when `vpcId` is present + - Set `config.existingVpc` to `undefined` when absent + - _Requirements: 1.1, 1.2, 1.3, 1.4, 8.1, 8.2_ + + - [x] 1.3 Add `validateExistingVpcConfig()` function and integrate into `validateConfig()` + - Validate `vpcId` matches `/^vpc-[a-z0-9]+$/` + - Validate `availabilityZones` has 2–6 entries + - Validate `publicSubnetIds` has ≥2 entries, each matches `/^subnet-[a-z0-9]+$/` + - Validate `privateSubnetIds` has ≥2 entries, each matches `/^subnet-[a-z0-9]+$/` + - Validate `publicSubnetIds.length === availabilityZones.length` + - Validate `privateSubnetIds.length === availabilityZones.length` + - Skip `vpcCidr` validation when `existingVpc` is present + - Throw descriptive errors identifying the failing field + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 6.1, 6.2_ + + - [x] 1.4 Update `createMockConfig` helper in `infrastructure/test/helpers/mock-config.ts` + - Add `existingVpc` to the mock config overrides support + - _Requirements: 1.1_ + +- [x] 2. Checkpoint - Ensure config changes compile and existing tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Add fast-check and write config property-based tests + - [x] 3.1 Add `fast-check` as a devDependency to `infrastructure/package.json` + - Run `npm install --save-dev fast-check` in `infrastructure/` + - _Requirements: (testing infrastructure)_ + + - [x] 3.2 Write property test: Config round-trip from CDK context + - **Property 1: Config round-trip from CDK context** + - **Validates: Requirements 1.1, 1.3, 8.1** + + - [x] 3.3 Write property test: Config round-trip from environment variables + - **Property 2: Config round-trip from environment variables** + - **Validates: Requirements 1.4** + + - [x] 3.4 Write property test: Environment variable precedence over CDK context + - **Property 3: Environment variable precedence over CDK context** + - **Validates: Requirements 8.2** + + - [x] 3.5 Write property test: Invalid field rejection + - **Property 4: Invalid field rejection** + - **Validates: Requirements 2.1, 2.2, 2.3, 2.4** + + - [x] 3.6 Write property test: Subnet count must match AZ count + - **Property 5: Subnet count must match AZ count** + - **Validates: Requirements 2.5, 2.6** + + - [x] 3.7 Write property test: vpcCidr validation bypass for imported VPCs + - **Property 8: vpcCidr validation bypass for imported VPCs** + - **Validates: Requirements 6.1** + +- [x] 4. Modify InfrastructureStack to support VPC import + - [x] 4.1 Change `this.vpc` type from `ec2.Vpc` to `ec2.IVpc` and add VPC branch logic in `infrastructure/lib/infrastructure-stack.ts` + - When `config.existingVpc` is present, call `ec2.Vpc.fromVpcAttributes()` and skip `new ec2.Vpc()` + - When absent, create VPC as today + - Assign result to `this.vpc` so downstream resources (ALB, ECS, SGs) work unchanged + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [x] 4.2 Adjust SSM parameter exports for imported VPC path + - For subnet IDs and AZs: use config values when imported, `this.vpc` properties when created + - For VPC CIDR: use `config.existingVpc.vpcCidrBlock` if provided, else `this.vpc.vpcCidrBlock` + - Ensure all 5 network SSM parameters are written for both paths + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ + + - [x] 4.3 Write property test: Imported VPC skips VPC creation and preserves downstream resources + - **Property 6: Imported VPC skips VPC creation and preserves downstream resources** + - **Validates: Requirements 3.1, 3.2, 5.2, 5.3, 5.4** + + - [x] 4.4 Write property test: SSM network parameter completeness + - **Property 7: SSM network parameter completeness** + - **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** + +- [x] 5. Checkpoint - Ensure stack synth works for both VPC paths + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. Update CI/CD scripts for existing VPC environment variables + - [x] 6.1 Update `scripts/common/load-env.sh` + - Add exports for `CDK_EXISTING_VPC_ID`, `CDK_EXISTING_VPC_AZS`, `CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS`, `CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS`, `CDK_EXISTING_VPC_CIDR` with fallback to context file + - Add corresponding entries to `build_cdk_context_params()` that conditionally include `existingVpc.*` context keys + - Add display lines in the config output section + - _Requirements: 7.1, 7.2, 7.4_ + + - [x] 6.2 Update `scripts/stack-infrastructure/synth.sh` and `scripts/stack-infrastructure/deploy.sh` + - Add conditional `--context existingVpc.*` parameters when environment variables are set + - Ensure both scripts have identical context parameters + - _Requirements: 7.3_ + +- [x] 7. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- The design uses TypeScript throughout (CDK + Jest + fast-check) +- `fast-check` must be installed as a devDependency before property tests can run (task 3.1) +- Properties 6 and 7 involve CDK stack synthesis which is slower; iteration count may be adjusted +- Downstream stacks (app-api, inference-api, gateway, frontend) require zero code changes +- Each property test references its design document property number for traceability diff --git a/infrastructure/lib/config.ts b/infrastructure/lib/config.ts index f2fa94dd..d97ff42c 100644 --- a/infrastructure/lib/config.ts +++ b/infrastructure/lib/config.ts @@ -28,6 +28,7 @@ export interface AppConfig { fileUpload: FileUploadConfig; ragIngestion: RagIngestionConfig; fineTuning: FineTuningConfig; + existingVpc?: ExistingVpcConfig; appVersion: string; tags: { [key: string]: string }; } @@ -101,6 +102,14 @@ export interface FineTuningConfig { additionalCorsOrigins?: string; // Extra CORS origins to append (comma-separated) } +export interface ExistingVpcConfig { + vpcId: string; // e.g. "vpc-0abc123def456" + availabilityZones: string[]; // e.g. ["us-west-2a", "us-west-2b"] + publicSubnetIds: string[]; // e.g. ["subnet-aaa", "subnet-bbb"] + privateSubnetIds: string[]; // e.g. ["subnet-ccc", "subnet-ddd"] + vpcCidrBlock?: string; // e.g. "10.0.0.0/16" (optional) +} + /** * Load and validate configuration from CDK context * @param scope The CDK construct scope @@ -251,6 +260,7 @@ export function loadConfig(scope: cdk.App): AppConfig { defaultQuotaHours: parseIntEnv(process.env.CDK_FINE_TUNING_DEFAULT_QUOTA_HOURS) ?? scope.node.tryGetContext('fineTuning')?.defaultQuotaHours ?? 0, additionalCorsOrigins: process.env.CDK_FINE_TUNING_CORS_ORIGINS || scope.node.tryGetContext('fineTuning')?.additionalCorsOrigins, }, + existingVpc: parseExistingVpcConfig(scope), tags: { ...(scope.node.tryGetContext('tags') || {}), }, @@ -308,6 +318,119 @@ export function parseBooleanEnv(value: string | undefined, defaultValue?: boolea ); } +/** + * Parse existing VPC configuration from environment variables with fallback to CDK context. + * Returns undefined when no vpcId is provided (neither via env var nor context). + * Environment variables take precedence over CDK context values. + */ +function parseExistingVpcConfig(scope: cdk.App): ExistingVpcConfig | undefined { + const existingVpcContext = scope.node.tryGetContext('existingVpc'); + + const vpcId = process.env.CDK_EXISTING_VPC_ID + || existingVpcContext?.vpcId; + + // Only assemble ExistingVpcConfig when vpcId is present + if (!vpcId) { + return undefined; + } + + const azsRaw = process.env.CDK_EXISTING_VPC_AZS + || existingVpcContext?.availabilityZones; + const publicRaw = process.env.CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS + || existingVpcContext?.publicSubnetIds; + const privateRaw = process.env.CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS + || existingVpcContext?.privateSubnetIds; + const vpcCidrBlock = process.env.CDK_EXISTING_VPC_CIDR + || existingVpcContext?.vpcCidrBlock + || undefined; + + // Split comma-separated env var strings into arrays; context values are already arrays + const availabilityZones = typeof azsRaw === 'string' + ? azsRaw.split(',').map(s => s.trim()).filter(Boolean) + : (azsRaw ?? []); + const publicSubnetIds = typeof publicRaw === 'string' + ? publicRaw.split(',').map(s => s.trim()).filter(Boolean) + : (publicRaw ?? []); + const privateSubnetIds = typeof privateRaw === 'string' + ? privateRaw.split(',').map(s => s.trim()).filter(Boolean) + : (privateRaw ?? []); + + return { + vpcId, + availabilityZones, + publicSubnetIds, + privateSubnetIds, + ...(vpcCidrBlock ? { vpcCidrBlock } : {}), + }; +} + +/** + * Validate existing VPC configuration fields. + * Called from validateConfig() when config.existingVpc is defined. + * Throws descriptive errors identifying the failing field and expected format. + */ +function validateExistingVpcConfig(config: ExistingVpcConfig): void { + // Validate vpcId format + if (!/^vpc-[a-z0-9]+$/.test(config.vpcId)) { + throw new Error( + `Invalid existingVpc.vpcId: "${config.vpcId}". Expected format: vpc-[a-z0-9]+` + ); + } + + // Validate availabilityZones count (2–6) + if (config.availabilityZones.length < 2 || config.availabilityZones.length > 6) { + throw new Error( + `existingVpc.availabilityZones must contain between 2 and 6 entries, got ${config.availabilityZones.length}` + ); + } + + // Validate publicSubnetIds minimum count + if (config.publicSubnetIds.length < 2) { + throw new Error( + `existingVpc.publicSubnetIds must contain at least 2 entries, got ${config.publicSubnetIds.length}` + ); + } + + // Validate each publicSubnetId format + for (const id of config.publicSubnetIds) { + if (!/^subnet-[a-z0-9]+$/.test(id)) { + throw new Error( + `Invalid subnet ID in existingVpc.publicSubnetIds: "${id}". Expected format: subnet-[a-z0-9]+` + ); + } + } + + // Validate privateSubnetIds minimum count + if (config.privateSubnetIds.length < 2) { + throw new Error( + `existingVpc.privateSubnetIds must contain at least 2 entries, got ${config.privateSubnetIds.length}` + ); + } + + // Validate each privateSubnetId format + for (const id of config.privateSubnetIds) { + if (!/^subnet-[a-z0-9]+$/.test(id)) { + throw new Error( + `Invalid subnet ID in existingVpc.privateSubnetIds: "${id}". Expected format: subnet-[a-z0-9]+` + ); + } + } + + // Validate publicSubnetIds count matches availabilityZones count + if (config.publicSubnetIds.length !== config.availabilityZones.length) { + throw new Error( + `existingVpc.publicSubnetIds count (${config.publicSubnetIds.length}) must equal availabilityZones count (${config.availabilityZones.length})` + ); + } + + // Validate privateSubnetIds count matches availabilityZones count + if (config.privateSubnetIds.length !== config.availabilityZones.length) { + throw new Error( + `existingVpc.privateSubnetIds count (${config.privateSubnetIds.length}) must equal availabilityZones count (${config.availabilityZones.length})` + ); + } +} + /** * Parse integer environment variable * Returns undefined if the value is not set or invalid, allowing for fallback logic @@ -381,10 +504,17 @@ function validateConfig(config: AppConfig): void { console.warn(`Warning: ${config.awsRegion} is not in the common regions list. Proceeding anyway.`); } - // Validate VPC CIDR - const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; - if (!cidrPattern.test(config.vpcCidr)) { - throw new Error(`Invalid VPC CIDR format: ${config.vpcCidr}`); + // Validate VPC CIDR (only when not using an existing VPC) + if (!config.existingVpc) { + const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + if (!cidrPattern.test(config.vpcCidr)) { + throw new Error(`Invalid VPC CIDR format: ${config.vpcCidr}`); + } + } + + // Validate existing VPC configuration when present + if (config.existingVpc) { + validateExistingVpcConfig(config.existingVpc); } // Validate RAG Ingestion configuration diff --git a/infrastructure/lib/infrastructure-stack.ts b/infrastructure/lib/infrastructure-stack.ts index 777b857d..5c8c3162 100644 --- a/infrastructure/lib/infrastructure-stack.ts +++ b/infrastructure/lib/infrastructure-stack.ts @@ -33,7 +33,7 @@ export interface InfrastructureStackProps extends cdk.StackProps { * This stack should be deployed FIRST, before any application stacks. */ export class InfrastructureStack extends cdk.Stack { - public readonly vpc: ec2.Vpc; + public readonly vpc: ec2.IVpc; public readonly alb: elbv2.ApplicationLoadBalancer; public readonly albListener: elbv2.ApplicationListener; public readonly albSecurityGroup: ec2.SecurityGroup; @@ -51,26 +51,35 @@ export class InfrastructureStack extends cdk.Stack { // ============================================================ // VPC - Network Foundation // ============================================================ - this.vpc = new ec2.Vpc(this, 'Vpc', { - vpcName: getResourceName(config, 'vpc'), - ipAddresses: ec2.IpAddresses.cidr(config.vpcCidr), - maxAzs: 2, // Use 2 AZs for high availability - natGateways: 1, // Single NAT Gateway for cost optimization (can be increased for HA) - subnetConfiguration: [ - { - cidrMask: 24, - name: 'Public', - subnetType: ec2.SubnetType.PUBLIC, - }, - { - cidrMask: 24, - name: 'Private', - subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - ], - enableDnsHostnames: true, - enableDnsSupport: true, - }); + if (config.existingVpc) { + this.vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', { + vpcId: config.existingVpc.vpcId, + availabilityZones: config.existingVpc.availabilityZones, + publicSubnetIds: config.existingVpc.publicSubnetIds, + privateSubnetIds: config.existingVpc.privateSubnetIds, + }); + } else { + this.vpc = new ec2.Vpc(this, 'Vpc', { + vpcName: getResourceName(config, 'vpc'), + ipAddresses: ec2.IpAddresses.cidr(config.vpcCidr), + maxAzs: 2, // Use 2 AZs for high availability + natGateways: 1, // Single NAT Gateway for cost optimization (can be increased for HA) + subnetConfiguration: [ + { + cidrMask: 24, + name: 'Public', + subnetType: ec2.SubnetType.PUBLIC, + }, + { + cidrMask: 24, + name: 'Private', + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + ], + enableDnsHostnames: true, + enableDnsSupport: true, + }); + } // Export VPC ID to SSM for cross-stack references new ssm.StringParameter(this, 'VpcIdParameter', { @@ -81,15 +90,22 @@ export class InfrastructureStack extends cdk.Stack { }); // Export VPC CIDR to SSM + // For imported VPCs: use config value if provided, else vpc.vpcCidrBlock (CDK token) + // For created VPCs: use vpc.vpcCidrBlock (same as before) + const vpcCidrValue = config.existingVpc?.vpcCidrBlock ?? this.vpc.vpcCidrBlock; new ssm.StringParameter(this, 'VpcCidrParameter', { parameterName: `/${config.projectPrefix}/network/vpc-cidr`, - stringValue: this.vpc.vpcCidrBlock, + stringValue: vpcCidrValue, description: 'Shared VPC CIDR block', tier: ssm.ParameterTier.STANDARD, }); // Export Private Subnet IDs to SSM - const privateSubnetIds = this.vpc.privateSubnets.map(subnet => subnet.subnetId).join(','); + // For imported VPCs: use config values directly (fromVpcAttributes stores them) + // For created VPCs: extract from vpc.privateSubnets + const privateSubnetIds = config.existingVpc + ? config.existingVpc.privateSubnetIds.join(',') + : this.vpc.privateSubnets.map(subnet => subnet.subnetId).join(','); new ssm.StringParameter(this, 'PrivateSubnetIdsParameter', { parameterName: `/${config.projectPrefix}/network/private-subnet-ids`, stringValue: privateSubnetIds, @@ -98,7 +114,9 @@ export class InfrastructureStack extends cdk.Stack { }); // Export Public Subnet IDs to SSM - const publicSubnetIds = this.vpc.publicSubnets.map(subnet => subnet.subnetId).join(','); + const publicSubnetIds = config.existingVpc + ? config.existingVpc.publicSubnetIds.join(',') + : this.vpc.publicSubnets.map(subnet => subnet.subnetId).join(','); new ssm.StringParameter(this, 'PublicSubnetIdsParameter', { parameterName: `/${config.projectPrefix}/network/public-subnet-ids`, stringValue: publicSubnetIds, @@ -107,7 +125,9 @@ export class InfrastructureStack extends cdk.Stack { }); // Export Availability Zones to SSM - const availabilityZones = this.vpc.availabilityZones.join(','); + const availabilityZones = config.existingVpc + ? config.existingVpc.availabilityZones.join(',') + : this.vpc.availabilityZones.join(','); new ssm.StringParameter(this, 'AvailabilityZonesParameter', { parameterName: `/${config.projectPrefix}/network/availability-zones`, stringValue: availabilityZones, diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index cef0a78b..9e90f702 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -18,6 +18,7 @@ "@types/jest": "30.0.0", "@types/node": "25.5.2", "aws-cdk": "2.1117.0", + "fast-check": "^4.6.0", "jest": "30.3.0", "ts-jest": "29.4.9", "ts-node": "10.9.2", @@ -2697,6 +2698,46 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/fast-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.6.0.tgz", + "integrity": "sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", diff --git a/infrastructure/package.json b/infrastructure/package.json index f184b466..5d3dfab8 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -14,6 +14,7 @@ "@types/jest": "30.0.0", "@types/node": "25.5.2", "aws-cdk": "2.1117.0", + "fast-check": "^4.6.0", "jest": "30.3.0", "ts-jest": "29.4.9", "ts-node": "10.9.2", diff --git a/infrastructure/test/existing-vpc-config.property.test.ts b/infrastructure/test/existing-vpc-config.property.test.ts new file mode 100644 index 00000000..0142eefb --- /dev/null +++ b/infrastructure/test/existing-vpc-config.property.test.ts @@ -0,0 +1,417 @@ +import * as cdk from 'aws-cdk-lib'; +import * as fc from 'fast-check'; +import { loadConfig, ExistingVpcConfig } from '../lib/config'; + +/** + * Property-Based Tests for Existing VPC Configuration + * + * Feature: existing-vpc-support + * + * These tests use fast-check to verify that the ExistingVpcConfig loading, + * validation, and precedence logic holds across a wide range of generated inputs. + */ + +// ============================================================ +// Custom Arbitraries +// ============================================================ + +/** Generates a valid VPC ID matching vpc-[a-z0-9]+ */ +function validVpcId(): fc.Arbitrary { + return fc.stringMatching(/^[a-z0-9]{1,17}$/).map((s: string) => `vpc-${s}`); +} + +/** Generates a valid subnet ID matching subnet-[a-z0-9]+ */ +function validSubnetId(): fc.Arbitrary { + return fc.stringMatching(/^[a-z0-9]{1,17}$/).map((s: string) => `subnet-${s}`); +} + +/** Generates an array of n valid AZ strings like us-east-1a */ +function validAzList(n: number): fc.Arbitrary { + const azLetters = 'abcdef'.split(''); + const regions = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-1']; + return fc + .tuple( + fc.constantFrom(...regions), + fc.shuffledSubarray(azLetters, { minLength: n, maxLength: n }), + ) + .map(([region, letters]) => letters.map((l) => `${region}${l}`)); +} + +/** Generates a complete valid ExistingVpcConfig with matching counts */ +function validExistingVpcConfig(): fc.Arbitrary { + return fc + .integer({ min: 2, max: 6 }) + .chain((azCount) => + fc.tuple( + validVpcId(), + validAzList(azCount), + fc.array(validSubnetId(), { minLength: azCount, maxLength: azCount }), + fc.array(validSubnetId(), { minLength: azCount, maxLength: azCount }), + fc.option(fc.constantFrom('10.0.0.0/16', '172.16.0.0/12', '192.168.0.0/24'), { + nil: undefined, + }), + ), + ) + .map(([vpcId, azs, publicSubnets, privateSubnets, cidr]) => ({ + vpcId, + availabilityZones: azs, + publicSubnetIds: publicSubnets, + privateSubnetIds: privateSubnets, + ...(cidr ? { vpcCidrBlock: cidr } : {}), + })); +} + +/** Generates an invalid VPC ID (does NOT match vpc-[a-z0-9]+, but is truthy so parseExistingVpcConfig assembles it) */ +function invalidVpcId(): fc.Arbitrary { + return fc.oneof( + fc.constant('vpc-'), + fc.constant('VPC-abc123'), + fc.constant('vpc_abc123'), + fc.constant('abc123'), + fc.constant('vpc-ABC'), + fc.constant('vpc-abc-def'), + fc.constant('ec2-abc123'), + ); +} + +/** Generates an invalid subnet ID (does NOT match subnet-[a-z0-9]+, but is non-empty) */ +function invalidSubnetId(): fc.Arbitrary { + return fc.oneof( + fc.constant('subnet-'), + fc.constant('SUBNET-abc123'), + fc.constant('subnet_abc123'), + fc.constant('abc123'), + fc.constant('subnet-ABC'), + fc.constant('sub-abc123'), + ); +} + +// ============================================================ +// Helpers +// ============================================================ + +/** Base CDK context required by loadConfig() for all tests */ +function baseContext(): Record { + return { + projectPrefix: 'test-project', + awsAccount: '123456789012', + awsRegion: 'us-east-1', + production: false, + retainDataOnDelete: false, + vpcCidr: '10.0.0.0/16', + corsOrigins: 'http://localhost:4200', + frontend: { enabled: true, cloudFrontPriceClass: 'PriceClass_100' }, + appApi: { enabled: true, cpu: 256, memory: 512, desiredCount: 1, maxCapacity: 2 }, + inferenceApi: { + enabled: true, + cpu: 256, + memory: 512, + desiredCount: 1, + maxCapacity: 2, + logLevel: 'INFO', + }, + gateway: { + enabled: true, + apiType: 'REST', + throttleRateLimit: 100, + throttleBurstLimit: 50, + enableWaf: false, + }, + assistants: { enabled: true }, + fileUpload: { + enabled: true, + maxFileSizeBytes: 10485760, + maxFilesPerMessage: 5, + userQuotaBytes: 104857600, + retentionDays: 30, + }, + ragIngestion: { + enabled: true, + lambdaMemorySize: 3008, + lambdaTimeout: 900, + embeddingModel: 'amazon.titan-embed-text-v2', + vectorDimension: 1024, + vectorDistanceMetric: 'cosine', + }, + fineTuning: { enabled: false }, + tags: { ManagedBy: 'CDK' }, + }; +} + +/** Create a CDK App with base context and optional existingVpc context */ +function createApp(existingVpc?: ExistingVpcConfig): cdk.App { + const ctx: Record = { ...baseContext() }; + if (existingVpc) { + ctx.existingVpc = existingVpc; + } + return new cdk.App({ context: ctx }); +} + +/** Environment variable keys used for existing VPC config */ +const ENV_KEYS = [ + 'CDK_EXISTING_VPC_ID', + 'CDK_EXISTING_VPC_AZS', + 'CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS', + 'CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS', + 'CDK_EXISTING_VPC_CIDR', +] as const; + +/** Clean up all existing VPC env vars */ +function cleanEnvVars(): void { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +/** Set env vars from an ExistingVpcConfig */ +function setEnvVars(vpc: ExistingVpcConfig): void { + process.env.CDK_EXISTING_VPC_ID = vpc.vpcId; + process.env.CDK_EXISTING_VPC_AZS = vpc.availabilityZones.join(','); + process.env.CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS = vpc.publicSubnetIds.join(','); + process.env.CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS = vpc.privateSubnetIds.join(','); + if (vpc.vpcCidrBlock) { + process.env.CDK_EXISTING_VPC_CIDR = vpc.vpcCidrBlock; + } +} + +// ============================================================ +// Property Tests +// ============================================================ + +describe('Existing VPC Config Property Tests', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + cleanEnvVars(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + // ---------------------------------------------------------- + // Property 1: Config round-trip from CDK context + // Feature: existing-vpc-support, Property 1: Config round-trip from CDK context + // **Validates: Requirements 1.1, 1.3, 8.1** + // ---------------------------------------------------------- + it('Property 1: valid ExistingVpcConfig set as CDK context round-trips through loadConfig()', () => { + fc.assert( + fc.property(validExistingVpcConfig(), (vpc) => { + const app = createApp(vpc); + const config = loadConfig(app); + + expect(config.existingVpc).toBeDefined(); + expect(config.existingVpc!.vpcId).toBe(vpc.vpcId); + expect(config.existingVpc!.availabilityZones).toEqual(vpc.availabilityZones); + expect(config.existingVpc!.publicSubnetIds).toEqual(vpc.publicSubnetIds); + expect(config.existingVpc!.privateSubnetIds).toEqual(vpc.privateSubnetIds); + if (vpc.vpcCidrBlock) { + expect(config.existingVpc!.vpcCidrBlock).toBe(vpc.vpcCidrBlock); + } + }), + { numRuns: 100 }, + ); + }); + + // ---------------------------------------------------------- + // Property 2: Config round-trip from environment variables + // Feature: existing-vpc-support, Property 2: Config round-trip from environment variables + // **Validates: Requirements 1.4** + // ---------------------------------------------------------- + it('Property 2: valid ExistingVpcConfig set as env vars round-trips through loadConfig()', () => { + fc.assert( + fc.property(validExistingVpcConfig(), (vpc) => { + try { + setEnvVars(vpc); + const app = createApp(); // no existingVpc in context + const config = loadConfig(app); + + expect(config.existingVpc).toBeDefined(); + expect(config.existingVpc!.vpcId).toBe(vpc.vpcId); + expect(config.existingVpc!.availabilityZones).toEqual(vpc.availabilityZones); + expect(config.existingVpc!.publicSubnetIds).toEqual(vpc.publicSubnetIds); + expect(config.existingVpc!.privateSubnetIds).toEqual(vpc.privateSubnetIds); + if (vpc.vpcCidrBlock) { + expect(config.existingVpc!.vpcCidrBlock).toBe(vpc.vpcCidrBlock); + } + } finally { + cleanEnvVars(); + } + }), + { numRuns: 100 }, + ); + }); + + // ---------------------------------------------------------- + // Property 3: Environment variable precedence over CDK context + // Feature: existing-vpc-support, Property 3: Environment variable precedence over CDK context + // **Validates: Requirements 8.2** + // ---------------------------------------------------------- + it('Property 3: env vars take precedence over CDK context for existingVpc', () => { + fc.assert( + fc.property( + validExistingVpcConfig(), + validExistingVpcConfig(), + (envVpc, ctxVpc) => { + // Skip if both configs happen to be identical (can't verify precedence) + fc.pre(envVpc.vpcId !== ctxVpc.vpcId); + + try { + setEnvVars(envVpc); + const app = createApp(ctxVpc); + const config = loadConfig(app); + + expect(config.existingVpc).toBeDefined(); + // Env vars should win + expect(config.existingVpc!.vpcId).toBe(envVpc.vpcId); + expect(config.existingVpc!.availabilityZones).toEqual(envVpc.availabilityZones); + expect(config.existingVpc!.publicSubnetIds).toEqual(envVpc.publicSubnetIds); + expect(config.existingVpc!.privateSubnetIds).toEqual(envVpc.privateSubnetIds); + } finally { + cleanEnvVars(); + } + }, + ), + { numRuns: 100 }, + ); + }); + + // ---------------------------------------------------------- + // Property 4: Invalid field rejection + // Feature: existing-vpc-support, Property 4: Invalid field rejection + // **Validates: Requirements 2.1, 2.2, 2.3, 2.4** + // ---------------------------------------------------------- + it('Property 4: config with at least one invalid field causes loadConfig() to throw', () => { + // Strategy: generate a valid config then corrupt exactly one field + const invalidConfigs = fc.oneof( + // Invalid vpcId + fc.tuple(invalidVpcId(), validExistingVpcConfig()).map(([badId, vpc]) => ({ + ...vpc, + vpcId: badId, + })), + // AZ count < 2 + fc.tuple(validVpcId(), validSubnetId(), validSubnetId()).map(([vpcId, pub1, priv1]) => ({ + vpcId, + availabilityZones: ['us-east-1a'], + publicSubnetIds: [pub1], + privateSubnetIds: [priv1], + })), + // AZ count > 6 + validVpcId().map((vpcId) => ({ + vpcId, + availabilityZones: ['us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d', 'us-east-1e', 'us-east-1f', 'us-west-2a'], + publicSubnetIds: Array.from({ length: 7 }, (_, i) => `subnet-pub${i}`), + privateSubnetIds: Array.from({ length: 7 }, (_, i) => `subnet-priv${i}`), + })), + // Invalid public subnet ID + fc.tuple(invalidSubnetId(), validExistingVpcConfig()).map(([badSubnet, vpc]) => ({ + ...vpc, + publicSubnetIds: [badSubnet, ...vpc.publicSubnetIds.slice(1)], + })), + // Invalid private subnet ID + fc.tuple(invalidSubnetId(), validExistingVpcConfig()).map(([badSubnet, vpc]) => ({ + ...vpc, + privateSubnetIds: [badSubnet, ...vpc.privateSubnetIds.slice(1)], + })), + // Fewer than 2 public subnets + fc.tuple(validVpcId(), validSubnetId()).map(([vpcId, sub]) => ({ + vpcId, + availabilityZones: ['us-east-1a', 'us-east-1b'], + publicSubnetIds: [sub], + privateSubnetIds: ['subnet-aaa', 'subnet-bbb'], + })), + // Fewer than 2 private subnets + fc.tuple(validVpcId(), validSubnetId()).map(([vpcId, sub]) => ({ + vpcId, + availabilityZones: ['us-east-1a', 'us-east-1b'], + publicSubnetIds: ['subnet-aaa', 'subnet-bbb'], + privateSubnetIds: [sub], + })), + ); + + fc.assert( + fc.property(invalidConfigs, (vpc) => { + const app = createApp(vpc as ExistingVpcConfig); + expect(() => loadConfig(app)).toThrow(); + }), + { numRuns: 100 }, + ); + }); + + // ---------------------------------------------------------- + // Property 5: Subnet count must match AZ count + // Feature: existing-vpc-support, Property 5: Subnet count must match AZ count + // **Validates: Requirements 2.5, 2.6** + // ---------------------------------------------------------- + it('Property 5: mismatched subnet/AZ counts cause loadConfig() to throw', () => { + // Generate a valid config then add or remove a subnet to create a mismatch + const mismatchedConfigs = fc.oneof( + // Public subnet count != AZ count (extra public subnet) + validExistingVpcConfig().chain((vpc) => + validSubnetId().map((extra) => ({ + ...vpc, + publicSubnetIds: [...vpc.publicSubnetIds, extra], + })), + ), + // Private subnet count != AZ count (extra private subnet) + validExistingVpcConfig().chain((vpc) => + validSubnetId().map((extra) => ({ + ...vpc, + privateSubnetIds: [...vpc.privateSubnetIds, extra], + })), + ), + // Public subnet count != AZ count (one fewer public subnet, only when azCount > 2) + validExistingVpcConfig() + .filter((vpc) => vpc.availabilityZones.length > 2) + .map((vpc) => ({ + ...vpc, + publicSubnetIds: vpc.publicSubnetIds.slice(0, -1), + })), + // Private subnet count != AZ count (one fewer private subnet, only when azCount > 2) + validExistingVpcConfig() + .filter((vpc) => vpc.availabilityZones.length > 2) + .map((vpc) => ({ + ...vpc, + privateSubnetIds: vpc.privateSubnetIds.slice(0, -1), + })), + ); + + fc.assert( + fc.property(mismatchedConfigs, (vpc) => { + const app = createApp(vpc as ExistingVpcConfig); + expect(() => loadConfig(app)).toThrow(); + }), + { numRuns: 100 }, + ); + }); + + // ---------------------------------------------------------- + // Property 8: vpcCidr validation bypass for imported VPCs + // Feature: existing-vpc-support, Property 8: vpcCidr validation bypass for imported VPCs + // **Validates: Requirements 6.1** + // ---------------------------------------------------------- + it('Property 8: loadConfig() does not throw VPC CIDR error when existingVpc is present', () => { + const badCidrs = fc.oneof( + fc.constant(''), + fc.constant('not-a-cidr'), + fc.constant('999.999.999.999/99'), + fc.constant('abc'), + fc.constant('10.0.0/16'), + fc.constant('10.0.0.0'), + ); + + fc.assert( + fc.property(validExistingVpcConfig(), badCidrs, (vpc, badCidr) => { + const ctx: Record = { ...baseContext(), vpcCidr: badCidr }; + ctx.existingVpc = vpc; + const app = new cdk.App({ context: ctx }); + + // Should NOT throw a VPC CIDR validation error + const config = loadConfig(app); + expect(config.existingVpc).toBeDefined(); + expect(config.existingVpc!.vpcId).toBe(vpc.vpcId); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/infrastructure/test/helpers/mock-config.ts b/infrastructure/test/helpers/mock-config.ts index 74eea733..51b3a577 100644 --- a/infrastructure/test/helpers/mock-config.ts +++ b/infrastructure/test/helpers/mock-config.ts @@ -81,6 +81,7 @@ export function createMockConfig(overrides: Partial = {}): AppConfig domainPrefix: MOCK_PREFIX, passwordMinLength: 8, }, + existingVpc: undefined, tags: { ManagedBy: 'CDK', Environment: 'test' }, }; diff --git a/infrastructure/test/infrastructure-stack-property.test.ts b/infrastructure/test/infrastructure-stack-property.test.ts new file mode 100644 index 00000000..2f62fd94 --- /dev/null +++ b/infrastructure/test/infrastructure-stack-property.test.ts @@ -0,0 +1,154 @@ +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as fc from 'fast-check'; +import { InfrastructureStack } from '../lib/infrastructure-stack'; +import { createMockConfig, mockEnv } from './helpers/mock-config'; +import { ExistingVpcConfig } from '../lib/config'; + +/** + * Property-Based Tests for InfrastructureStack with Existing VPC + * + * Feature: existing-vpc-support + * + * These tests use fast-check to verify that the InfrastructureStack behaves + * correctly when synthesized with an imported VPC configuration across a + * wide range of generated inputs. + */ + +// ============================================================ +// Custom Arbitraries +// ============================================================ + +/** Generates a valid VPC ID matching vpc-[a-z0-9]+ */ +function validVpcId(): fc.Arbitrary { + return fc.stringMatching(/^[a-z0-9]{1,17}$/).map((s: string) => `vpc-${s}`); +} + +/** Generates a valid subnet ID matching subnet-[a-z0-9]+ */ +function validSubnetId(): fc.Arbitrary { + return fc.stringMatching(/^[a-z0-9]{1,17}$/).map((s: string) => `subnet-${s}`); +} + +/** Generates an array of n valid AZ strings like us-east-1a */ +function validAzList(n: number): fc.Arbitrary { + const azLetters = 'abcdef'.split(''); + const regions = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-1']; + return fc + .tuple( + fc.constantFrom(...regions), + fc.shuffledSubarray(azLetters, { minLength: n, maxLength: n }), + ) + .map(([region, letters]) => letters.map((l) => `${region}${l}`)); +} + +/** Generates n unique subnet IDs */ +function uniqueSubnetIds(n: number, prefix: string): fc.Arbitrary { + return fc + .uniqueArray(fc.stringMatching(/^[a-z0-9]{1,17}$/), { minLength: n, maxLength: n }) + .map((ids) => ids.map((s) => `subnet-${prefix}${s}`)); +} + +/** Generates a complete valid ExistingVpcConfig with matching counts and unique subnet IDs */ +function validExistingVpcConfig(): fc.Arbitrary { + return fc + .integer({ min: 2, max: 6 }) + .chain((azCount) => + fc.tuple( + validVpcId(), + validAzList(azCount), + uniqueSubnetIds(azCount, 'pub'), + uniqueSubnetIds(azCount, 'priv'), + // Always include vpcCidrBlock to avoid CDK error when accessing vpc.vpcCidrBlock + // on an imported VPC (fromVpcAttributes does not support vpcCidrBlock lookup) + fc.constantFrom('10.0.0.0/16', '172.16.0.0/12', '192.168.0.0/24'), + ), + ) + .map(([vpcId, azs, publicSubnets, privateSubnets, cidr]) => ({ + vpcId, + availabilityZones: azs, + publicSubnetIds: publicSubnets, + privateSubnetIds: privateSubnets, + vpcCidrBlock: cidr, + })); +} + +// ============================================================ +// Property Tests +// ============================================================ + +describe('InfrastructureStack Property Tests', () => { + // ---------------------------------------------------------- + // Property 6: Imported VPC skips VPC creation and preserves downstream resources + // Feature: existing-vpc-support, Property 6: Imported VPC skips VPC creation and preserves downstream resources + // **Validates: Requirements 3.1, 3.2, 5.2, 5.3, 5.4** + // ---------------------------------------------------------- + it('Property 6: imported VPC produces zero VPC/Subnet/NAT resources and preserves ALB, ECS, SecurityGroup', () => { + fc.assert( + fc.property(validExistingVpcConfig(), (vpc) => { + const app = new cdk.App(); + const config = createMockConfig({ existingVpc: vpc }); + const stack = new InfrastructureStack(app, 'TestStack', { + config, + env: mockEnv(config), + }); + const template = Template.fromStack(stack); + + // No VPC, Subnet, or NAT Gateway resources should be created + template.resourceCountIs('AWS::EC2::VPC', 0); + template.resourceCountIs('AWS::EC2::Subnet', 0); + template.resourceCountIs('AWS::EC2::NatGateway', 0); + + // Downstream resources must still exist + const albs = template.findResources('AWS::ElasticLoadBalancingV2::LoadBalancer'); + expect(Object.keys(albs).length).toBeGreaterThanOrEqual(1); + + const clusters = template.findResources('AWS::ECS::Cluster'); + expect(Object.keys(clusters).length).toBeGreaterThanOrEqual(1); + + const securityGroups = template.findResources('AWS::EC2::SecurityGroup'); + expect(Object.keys(securityGroups).length).toBeGreaterThanOrEqual(1); + }), + // CDK stack synthesis is relatively slow; 50 iterations keeps test time reasonable + { numRuns: 50 }, + ); + }, 120_000); + + // ---------------------------------------------------------- + // Property 7: SSM network parameter completeness + // Feature: existing-vpc-support, Property 7: SSM network parameter completeness + // **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5** + // ---------------------------------------------------------- + it('Property 7: imported VPC produces all 5 network SSM parameters', () => { + const EXPECTED_SUFFIXES = [ + 'vpc-id', + 'private-subnet-ids', + 'public-subnet-ids', + 'availability-zones', + 'vpc-cidr', + ]; + + fc.assert( + fc.property(validExistingVpcConfig(), (vpc) => { + const app = new cdk.App(); + const config = createMockConfig({ existingVpc: vpc }); + const stack = new InfrastructureStack(app, 'TestStack', { + config, + env: mockEnv(config), + }); + const template = Template.fromStack(stack); + + const ssmParams = template.findResources('AWS::SSM::Parameter'); + const paramNames = Object.values(ssmParams).map( + (r: any) => r.Properties.Name as string, + ); + + for (const suffix of EXPECTED_SUFFIXES) { + const found = paramNames.some((name) => name.includes(`/network/${suffix}`)); + expect(found).toBe(true); + } + }), + // CDK stack synthesis is relatively slow; 50 iterations keeps test time reasonable + { numRuns: 50 }, + ); + }, 120_000); +}); diff --git a/scripts/common/load-env.sh b/scripts/common/load-env.sh index 002ed0b5..ab1841a6 100644 --- a/scripts/common/load-env.sh +++ b/scripts/common/load-env.sh @@ -206,6 +206,23 @@ build_cdk_context_params() { context_params="${context_params} --context fineTuning.enabled=\"${CDK_FINE_TUNING_ENABLED}\"" fi + # Existing VPC optional parameters (only include when set) + if [ -n "${CDK_EXISTING_VPC_ID:-}" ]; then + context_params="${context_params} --context existingVpc.vpcId=\"${CDK_EXISTING_VPC_ID}\"" + fi + if [ -n "${CDK_EXISTING_VPC_AZS:-}" ]; then + context_params="${context_params} --context existingVpc.availabilityZones=\"${CDK_EXISTING_VPC_AZS}\"" + fi + if [ -n "${CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS:-}" ]; then + context_params="${context_params} --context existingVpc.publicSubnetIds=\"${CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS}\"" + fi + if [ -n "${CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS:-}" ]; then + context_params="${context_params} --context existingVpc.privateSubnetIds=\"${CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS}\"" + fi + if [ -n "${CDK_EXISTING_VPC_CIDR:-}" ]; then + context_params="${context_params} --context existingVpc.vpcCidrBlock=\"${CDK_EXISTING_VPC_CIDR}\"" + fi + echo "${context_params}" } @@ -272,6 +289,13 @@ export CDK_RAG_LAMBDA_TIMEOUT="${CDK_RAG_LAMBDA_TIMEOUT:-$(get_json_value "ragIn # SageMaker Fine-Tuning configuration export CDK_FINE_TUNING_ENABLED="${CDK_FINE_TUNING_ENABLED:-$(get_json_value "fineTuning.enabled" "${CONTEXT_FILE}")}" +# Existing VPC configuration (optional — import a pre-existing VPC instead of creating one) +export CDK_EXISTING_VPC_ID="${CDK_EXISTING_VPC_ID:-$(get_json_value "existingVpc.vpcId" "${CONTEXT_FILE}")}" +export CDK_EXISTING_VPC_AZS="${CDK_EXISTING_VPC_AZS:-$(get_json_value "existingVpc.availabilityZones" "${CONTEXT_FILE}")}" +export CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS="${CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS:-$(get_json_value "existingVpc.publicSubnetIds" "${CONTEXT_FILE}")}" +export CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS="${CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS:-$(get_json_value "existingVpc.privateSubnetIds" "${CONTEXT_FILE}")}" +export CDK_EXISTING_VPC_CIDR="${CDK_EXISTING_VPC_CIDR:-$(get_json_value "existingVpc.vpcCidrBlock" "${CONTEXT_FILE}")}" + # Cognito configuration (optional — defaults to projectPrefix for domain prefix) export CDK_COGNITO_DOMAIN_PREFIX="${CDK_COGNITO_DOMAIN_PREFIX:-$(get_json_value "cognito.domainPrefix" "${CONTEXT_FILE}")}" @@ -344,6 +368,16 @@ if [ "${LOAD_ENV_QUIET:-false}" != "true" ]; then log_config " HTTPS Enabled: Yes" fi + if [ -n "${CDK_EXISTING_VPC_ID:-}" ]; then + log_config " Existing VPC: ${CDK_EXISTING_VPC_ID}" + log_config " VPC AZs: ${CDK_EXISTING_VPC_AZS}" + log_config " Public Subnets: ${CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS}" + log_config " Private Subnets:${CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS}" + if [ -n "${CDK_EXISTING_VPC_CIDR:-}" ]; then + log_config " VPC CIDR: ${CDK_EXISTING_VPC_CIDR}" + fi + fi + # Check AWS credentials if ! aws sts get-caller-identity &> /dev/null; then log_warn "AWS credentials not configured or invalid" diff --git a/scripts/stack-infrastructure/deploy.sh b/scripts/stack-infrastructure/deploy.sh index 767b1ff4..a58c6cd8 100644 --- a/scripts/stack-infrastructure/deploy.sh +++ b/scripts/stack-infrastructure/deploy.sh @@ -42,6 +42,18 @@ cdk bootstrap aws://${CDK_DEFAULT_ACCOUNT}/${CDK_DEFAULT_REGION} \ || log_info "CDK already bootstrapped or bootstrap failed (continuing anyway)" cd "${PROJECT_ROOT}/infrastructure" +# Build existing VPC context parameters (only when set) +EXISTING_VPC_CONTEXT="" +if [ -n "${CDK_EXISTING_VPC_ID:-}" ]; then + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.vpcId=\"${CDK_EXISTING_VPC_ID}\"" + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.availabilityZones=\"${CDK_EXISTING_VPC_AZS}\"" + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.publicSubnetIds=\"${CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS}\"" + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.privateSubnetIds=\"${CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS}\"" + if [ -n "${CDK_EXISTING_VPC_CIDR:-}" ]; then + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.vpcCidrBlock=\"${CDK_EXISTING_VPC_CIDR}\"" + fi +fi + # Deploy the Infrastructure Stack # Check if pre-synthesized template exists (from CI/CD pipeline) if [ -d "${PROJECT_ROOT}/infrastructure/cdk.out" ] && [ -f "${PROJECT_ROOT}/infrastructure/cdk.out/InfrastructureStack.template.json" ]; then @@ -64,6 +76,7 @@ else --context albSubdomain="${CDK_ALB_SUBDOMAIN}" \ --context certificateArn="${CDK_CERTIFICATE_ARN}" \ --context domainName="${CDK_DOMAIN_NAME}" \ + ${EXISTING_VPC_CONTEXT} \ --require-approval never \ --outputs-file "${PROJECT_ROOT}/infrastructure/infrastructure-outputs.json" fi diff --git a/scripts/stack-infrastructure/synth.sh b/scripts/stack-infrastructure/synth.sh index 1949de1f..141ae61c 100644 --- a/scripts/stack-infrastructure/synth.sh +++ b/scripts/stack-infrastructure/synth.sh @@ -34,6 +34,18 @@ if [ ! -d "node_modules" ]; then npm ci fi +# Build existing VPC context parameters (only when set) +EXISTING_VPC_CONTEXT="" +if [ -n "${CDK_EXISTING_VPC_ID:-}" ]; then + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.vpcId=\"${CDK_EXISTING_VPC_ID}\"" + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.availabilityZones=\"${CDK_EXISTING_VPC_AZS}\"" + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.publicSubnetIds=\"${CDK_EXISTING_VPC_PUBLIC_SUBNET_IDS}\"" + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.privateSubnetIds=\"${CDK_EXISTING_VPC_PRIVATE_SUBNET_IDS}\"" + if [ -n "${CDK_EXISTING_VPC_CIDR:-}" ]; then + EXISTING_VPC_CONTEXT="${EXISTING_VPC_CONTEXT} --context existingVpc.vpcCidrBlock=\"${CDK_EXISTING_VPC_CIDR}\"" + fi +fi + # Synthesize the Infrastructure Stack log_info "Running CDK synth for InfrastructureStack..." cdk synth InfrastructureStack \ @@ -46,6 +58,7 @@ cdk synth InfrastructureStack \ --context albSubdomain="${CDK_ALB_SUBDOMAIN}" \ --context certificateArn="${CDK_CERTIFICATE_ARN}" \ --context domainName="${CDK_DOMAIN_NAME}" \ + ${EXISTING_VPC_CONTEXT} \ --output "${PROJECT_ROOT}/infrastructure/cdk.out" log_success "Infrastructure Stack CloudFormation template synthesized successfully" From ecb590e7a8e0ec7bc16f55b2d7e25bbb8a8ccdd3 Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Fri, 10 Apr 2026 08:04:54 -0500 Subject: [PATCH 4/9] Pinned fask-check. --- infrastructure/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/package.json b/infrastructure/package.json index 5d3dfab8..e5562038 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -14,7 +14,7 @@ "@types/jest": "30.0.0", "@types/node": "25.5.2", "aws-cdk": "2.1117.0", - "fast-check": "^4.6.0", + "fast-check": "4.6.0", "jest": "30.3.0", "ts-jest": "29.4.9", "ts-node": "10.9.2", From 933fda21e679de6a7dea2832af689b356e5b85b9 Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Wed, 15 Apr 2026 14:17:13 -0500 Subject: [PATCH 5/9] fix(frontend): use infrastructureHostedZoneDomain for Route53 hosted zone lookup Use config.infrastructureHostedZoneDomain (CDK_HOSTED_ZONE_DOMAIN) for the Route53 fromLookup() call instead of deriving the zone name from config.domainName. Falls back to domainName when the hosted zone config is not set, preserving existing behavior for apex domain deployments. --- infrastructure/lib/frontend-stack.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/infrastructure/lib/frontend-stack.ts b/infrastructure/lib/frontend-stack.ts index 5d197c62..5dfdc6b9 100644 --- a/infrastructure/lib/frontend-stack.ts +++ b/infrastructure/lib/frontend-stack.ts @@ -274,15 +274,9 @@ export class FrontendStack extends cdk.Stack { // Create Route53 A record if domain is configured if (config.domainName) { - // Extract parent zone from domain name (e.g., "app.example.com" → "example.com") - const domainParts = config.domainName.split('.'); - const zoneName = domainParts.length > 2 - ? domainParts.slice(1).join('.') - : config.domainName; - - // Look up the hosted zone + // Use the explicitly configured hosted zone domain, falling back to domainName const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { - domainName: zoneName, + domainName: config.infrastructureHostedZoneDomain || config.domainName, }); // Create A record aliasing to CloudFront From 6f88dda623f41f5e8d76b86afdaf68c7b00ce7f8 Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Wed, 15 Apr 2026 17:33:40 -0500 Subject: [PATCH 6/9] fix(frontend): add CDK_HOSTED_ZONE_DOMAIN to frontend workflow The frontend workflow was missing CDK_HOSTED_ZONE_DOMAIN in its env sections, causing Route53 hosted zone lookups to fail when domainName differs from the hosted zone domain (e.g., app.example.com vs example.com). - Add CDK_HOSTED_ZONE_DOMAIN to synth-cdk and deploy-infrastructure jobs --- .github/workflows/frontend.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 3f1dfee4..aaf23fa7 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -228,6 +228,7 @@ jobs: CDK_PRODUCTION: ${{ vars.CDK_PRODUCTION }} CDK_DOMAIN_NAME: ${{ vars.CDK_DOMAIN_NAME }} CDK_CORS_ORIGINS: ${{ vars.CDK_CORS_ORIGINS }} + CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_FRONTEND_ENABLED: ${{ vars.CDK_FRONTEND_ENABLED }} CDK_FRONTEND_CLOUDFRONT_PRICE_CLASS: ${{ vars.CDK_FRONTEND_CLOUDFRONT_PRICE_CLASS }} CDK_RETAIN_DATA_ON_DELETE: ${{ vars.CDK_RETAIN_DATA_ON_DELETE }} @@ -357,6 +358,7 @@ jobs: CDK_PRODUCTION: ${{ vars.CDK_PRODUCTION }} CDK_DOMAIN_NAME: ${{ vars.CDK_DOMAIN_NAME }} CDK_CORS_ORIGINS: ${{ vars.CDK_CORS_ORIGINS }} + CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_FRONTEND_ENABLED: ${{ vars.CDK_FRONTEND_ENABLED }} CDK_FRONTEND_CLOUDFRONT_PRICE_CLASS: ${{ vars.CDK_FRONTEND_CLOUDFRONT_PRICE_CLASS }} CDK_RETAIN_DATA_ON_DELETE: ${{ vars.CDK_RETAIN_DATA_ON_DELETE }} From efab456e0a354b3ce4a8ec4927b74385152992bf Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Wed, 15 Apr 2026 17:42:19 -0500 Subject: [PATCH 7/9] refactor(frontend): remove domainName fallback for hosted zone lookup CDK_HOSTED_ZONE_DOMAIN is a required variable per documentation, so the fallback to domainName is unnecessary. --- infrastructure/lib/frontend-stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/lib/frontend-stack.ts b/infrastructure/lib/frontend-stack.ts index 5dfdc6b9..2b577362 100644 --- a/infrastructure/lib/frontend-stack.ts +++ b/infrastructure/lib/frontend-stack.ts @@ -276,7 +276,7 @@ export class FrontendStack extends cdk.Stack { if (config.domainName) { // Use the explicitly configured hosted zone domain, falling back to domainName const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { - domainName: config.infrastructureHostedZoneDomain || config.domainName, + domainName: config.infrastructureHostedZoneDomain!, }); // Create A record aliasing to CloudFront From 2de2968416dfb525dea6540339b993c5ff3326ca Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Wed, 15 Apr 2026 18:12:34 -0500 Subject: [PATCH 8/9] Updated comment. --- infrastructure/lib/frontend-stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/lib/frontend-stack.ts b/infrastructure/lib/frontend-stack.ts index 2b577362..647a92c3 100644 --- a/infrastructure/lib/frontend-stack.ts +++ b/infrastructure/lib/frontend-stack.ts @@ -274,7 +274,7 @@ export class FrontendStack extends cdk.Stack { // Create Route53 A record if domain is configured if (config.domainName) { - // Use the explicitly configured hosted zone domain, falling back to domainName + // Use the explicitly configured hosted zone domain const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { domainName: config.infrastructureHostedZoneDomain!, }); From a35d0e7156e83842996cf1fcb8ed4ebde6db0f25 Mon Sep 17 00:00:00 2001 From: Julian Mino Date: Wed, 15 Apr 2026 19:36:03 -0500 Subject: [PATCH 9/9] fix(gateway): add CDK_HOSTED_ZONE_DOMAIN to gateway workflow FrontendStack requires this variable for Route53 hosted zone lookup when synthesizing all stacks. --- .github/workflows/gateway.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/gateway.yml b/.github/workflows/gateway.yml index 35a8cc4d..e8463a92 100644 --- a/.github/workflows/gateway.yml +++ b/.github/workflows/gateway.yml @@ -169,6 +169,7 @@ jobs: CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} CDK_DOMAIN_NAME: ${{ vars.CDK_DOMAIN_NAME }} CDK_CORS_ORIGINS: ${{ vars.CDK_CORS_ORIGINS }} + CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_RETAIN_DATA_ON_DELETE: ${{ vars.CDK_RETAIN_DATA_ON_DELETE }} CDK_GATEWAY_ENABLED: ${{ vars.CDK_GATEWAY_ENABLED }} CDK_GATEWAY_API_TYPE: ${{ vars.CDK_GATEWAY_API_TYPE }} @@ -232,6 +233,7 @@ jobs: CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} CDK_DOMAIN_NAME: ${{ vars.CDK_DOMAIN_NAME }} CDK_CORS_ORIGINS: ${{ vars.CDK_CORS_ORIGINS }} + CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_RETAIN_DATA_ON_DELETE: ${{ vars.CDK_RETAIN_DATA_ON_DELETE }} CDK_GATEWAY_ENABLED: ${{ vars.CDK_GATEWAY_ENABLED }} CDK_GATEWAY_API_TYPE: ${{ vars.CDK_GATEWAY_API_TYPE }} @@ -305,6 +307,7 @@ jobs: CDK_PROJECT_PREFIX: ${{ vars.CDK_PROJECT_PREFIX }} CDK_DOMAIN_NAME: ${{ vars.CDK_DOMAIN_NAME }} CDK_CORS_ORIGINS: ${{ vars.CDK_CORS_ORIGINS }} + CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_RETAIN_DATA_ON_DELETE: ${{ vars.CDK_RETAIN_DATA_ON_DELETE }} CDK_GATEWAY_ENABLED: ${{ vars.CDK_GATEWAY_ENABLED }} CDK_GATEWAY_API_TYPE: ${{ vars.CDK_GATEWAY_API_TYPE }}