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/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 }} 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 }} 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/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" ] } 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/frontend-stack.ts b/infrastructure/lib/frontend-stack.ts index 017ba11d..647a92c3 100644 --- a/infrastructure/lib/frontend-stack.ts +++ b/infrastructure/lib/frontend-stack.ts @@ -274,9 +274,9 @@ export class FrontendStack extends cdk.Stack { // Create Route53 A record if domain is configured if (config.domainName) { - // Look up the hosted zone + // Use the explicitly configured hosted zone domain const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { - domainName: config.domainName, + domainName: config.infrastructureHostedZoneDomain!, }); // Create A record aliasing to CloudFront 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..e5562038 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"