diff --git a/.changeset/simplify-vpc-implementation.md b/.changeset/simplify-vpc-implementation.md new file mode 100644 index 000000000..fb12af690 --- /dev/null +++ b/.changeset/simplify-vpc-implementation.md @@ -0,0 +1,18 @@ +--- +"@aws-blocks/core": minor +"@aws-blocks/bb-kv-store": patch +"@aws-blocks/bb-distributed-table": patch +"@aws-blocks/bb-file-bucket": patch +"@aws-blocks/bb-data": patch +"@aws-blocks/bb-distributed-data": patch +"@aws-blocks/bb-async-job": patch +"@aws-blocks/bb-agent": patch +"@aws-blocks/bb-knowledge-base": patch +"@aws-blocks/bb-email-client": patch +"@aws-blocks/bb-app-setting": patch +"@aws-blocks/bb-realtime": patch +"@aws-blocks/bb-auth-cognito": patch +"@aws-blocks/bb-auth-oidc": patch +--- + +Simplify VPC implementation: replace `registerVpcEndpoint` (instanceof-based) with two explicit methods (`registerVpcGatewayEndpoint` / `registerVpcInterfaceEndpoint`), simplify `BlocksVpcOptions` to `{ vpc, subnets?, provisionEndpoints? }`, and strip persistent test VPC to bare minimum. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 005b6c071..8608fcd49 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -259,7 +259,7 @@ jobs: run: npm run test:e2e:sandbox e2e-sandbox-vpc: - name: E2E Sandbox (VPC Smoke) + name: E2E VPC Smoke needs: [build-and-test-local, detect-changes] if: needs.detect-changes.outputs.source-changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest @@ -293,7 +293,15 @@ jobs: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: us-east-1 - - name: E2E Test Sandbox (VPC Smoke) + - name: Ensure persistent test VPC + run: cd test-infra && npx cdk deploy --require-approval never --outputs-file outputs.json + + - name: Export VPC ID + run: | + VPC_ID=$(cat test-infra/outputs.json | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['BlocksTestVpc']['VpcId'])") + echo "VPC_TEST_VPC_ID=$VPC_ID" >> "$GITHUB_ENV" + + - name: E2E VPC Smoke run: npm run test:e2e:sandbox:vpc e2e-production: diff --git a/docs/design/VPC-DESIGN.md b/docs/design/VPC-DESIGN.md new file mode 100644 index 000000000..1328a767a --- /dev/null +++ b/docs/design/VPC-DESIGN.md @@ -0,0 +1,177 @@ +# VPC Support — Design + +> **Status:** Implemented (PR #277, branch `feat/vpc-support`) + +--- + +**Package:** `@aws-blocks/core` (CDK-level option) +**AWS Services:** Amazon VPC, EC2 (subnets, NAT gateways, security groups, VPC endpoints) + +--- + +## Purpose + +Place an AWS Blocks application in a VPC with a single prop on `BlocksStack`/`BlocksBackend`. The framework handles Lambda placement, endpoint provisioning (based on BB requirements), and security group wiring. + +--- + +## API Surface + +### BlocksVpcOptions + +```typescript +interface BlocksVpcOptions { + /** The VPC to place Lambdas and VPC-resident resources into. */ + vpc: ec2.IVpc; + + /** + * Subnet selection for Lambda and all Blocks-managed compute placement. + * @default { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS } + */ + subnets?: ec2.SubnetSelection; + + /** + * Whether to auto-provision VPC endpoints based on BB registrations. + * Set to `false` to disable (e.g., when using a shared VPC that already has endpoints). + * @default true + */ + provisionEndpoints?: boolean; +} +``` + +### Customer Usage + +```typescript +import * as ec2 from 'aws-cdk-lib/aws-ec2'; + +const vpc = new ec2.Vpc(app, 'AppVpc', { maxAzs: 2, natGateways: 1 }); + +// Simplest: pass VPC, Blocks provisions endpoints automatically +await BlocksStack.create(app, stackName, { + backendHandlerPath: join(__dirname, 'index.handler.ts'), + backendCDKPath: join(__dirname, 'index.ts'), + vpc: { vpc }, +}); + +// Bring existing VPC with pre-provisioned endpoints +const sharedVpc = ec2.Vpc.fromLookup(app, 'SharedVpc', { vpcId: 'vpc-abc123' }); +await BlocksStack.create(app, stackName, { + backendHandlerPath: join(__dirname, 'index.handler.ts'), + backendCDKPath: join(__dirname, 'index.ts'), + vpc: { vpc: sharedVpc, provisionEndpoints: false }, +}); +``` + +--- + +## BB Endpoint Registration + +Each Building Block declares what VPC endpoints it needs via two explicit methods on the `Scope` class. No `instanceof` detection — each BB calls the method matching its endpoint type directly. + +### Registration API + +```typescript +// On Scope (core/cdk) +protected registerVpcGatewayEndpoint(service: ec2.GatewayVpcEndpointAwsService): void; +protected registerVpcInterfaceEndpoint(service: ec2.InterfaceVpcEndpointAwsService): void; +``` + +### Per-BB Declarations + +| Building Block | Registration Call | +|----------------|-----------------| +| bb-kv-store | `this.registerVpcGatewayEndpoint(ec2.GatewayVpcEndpointAwsService.DYNAMODB)` | +| bb-distributed-table | `this.registerVpcGatewayEndpoint(ec2.GatewayVpcEndpointAwsService.DYNAMODB)` | +| bb-file-bucket | `this.registerVpcGatewayEndpoint(ec2.GatewayVpcEndpointAwsService.S3)` | +| bb-data | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER)` + `...RDS_DATA` | +| bb-async-job | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SQS)` | +| bb-agent | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.BEDROCK_RUNTIME)` | +| bb-knowledge-base | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.BEDROCK_RUNTIME)` | +| bb-email-client | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SES)` | +| bb-app-setting | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SSM)` | +| bb-realtime | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.APIGATEWAY)` | +| bb-auth-cognito | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SSM)` | +| bb-auth-oidc | `this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SSM)` | +| bb-distributed-data | None (DSQL uses public HTTPS, reachable via NAT) | + +### Always-Added Endpoints + +The framework always adds these interface endpoints when `provisionEndpoints !== false`: + +- **CloudWatch Logs** — Lambda needs it for log delivery from within VPC +- **SSM** — Used by auth BBs and AppSetting + +### Collection and Provisioning + +After all BBs are constructed, `finalizeVpc` walks the construct tree, collects all registered gateway and interface endpoints, deduplicates by service name, and provisions them on the VPC. Gateway endpoints are free; interface endpoints cost ~$7.20/month/AZ. + +--- + +## Internal VPC Context + +```typescript +interface VpcContext { + readonly vpc: ec2.IVpc; + readonly lambdaSecurityGroup: ec2.ISecurityGroup; + readonly lambdaSubnets: ec2.SubnetSelection; + selectSubnets(role: SubnetRole): ec2.SubnetSelection; +} +``` + +Set on the scope during `initializeVpc()`. BBs like `bb-data` read this via `getVpcContext(scope)` to discover the shared VPC and place Aurora in the correct subnets. + +--- + +## Testing Strategy + +### Persistent Test VPC (Bare Minimum) + +The persistent test VPC stack (`test-infra/vpc-test-stack.ts`) contains only: + +- VPC with public / private / isolated subnets (2 AZs) +- 1 NAT gateway +- VPC ID output + +No pre-provisioned endpoints. No Aurora cluster. No security groups beyond defaults. + +The test app provisions its own endpoints via `provisionEndpoints: true`, testing the real auto-detection path end-to-end. + +### Per-Test Aurora + +The `vpc-smoke` test app instantiates `new Database(scope, 'db')` with **no** `connection` option. The Database BB detects the VPC context and creates its own Aurora Serverless v2 cluster in the shared VPC's isolated subnets. The test runs `SELECT 1` and insert/read operations against this self-provisioned Aurora. + +This avoids: +- A persistent Aurora cluster ($50+/month idle costs) +- External secret ARN management +- Cross-stack coupling between test infra and test app + +### Test App Structure + +``` +test-apps/vpc-smoke/ +├── aws-blocks/ +│ ├── index.ts # Instantiates KVStore, DistributedTable, FileBucket, AsyncJob, AppSetting, Realtime, AuthCognito, Database, Logger, Metrics, Tracer +│ ├── index.cdk.ts # Looks up persistent test VPC, passes vpc: { vpc, provisionEndpoints: true } +│ └── index.handler.ts # Re-exports BB instances +└── package.json +``` + +--- + +## Phased Implementation + +### Phase 1: CDK-level VPC (this PR) + +- `vpc` prop on `BlocksStack` / `BlocksBackend` +- `registerVpcGatewayEndpoint()` / `registerVpcInterfaceEndpoint()` on `Scope` +- Per-BB endpoint declarations in each BB's CDK constructor +- Finalization: collect + deduplicate + provision endpoints +- Lambda placement in private subnets + security group +- `bb-data` refactor: use shared VPC when available +- `BlocksVpcOptions`: `{ vpc, subnets?, provisionEndpoints? }` + +### Phase 2: Per-handler VPC (after configurable compute) + +- `VpcNetwork` Building Block +- `network` option on individual compute targets +- Per-handler scope tree walks for requirement collection diff --git a/package-lock.json b/package-lock.json index 0e629d245..f0f0130dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,7 +62,8 @@ "test-apps/pipeline", "test-apps/db-pull-typecheck", "scripts/agent-bench", - "test-apps/telemetry" + "test-apps/telemetry", + "test-infra" ], "devDependencies": { "@biomejs/biome": "2.4.16", @@ -35550,6 +35551,10 @@ "resolved": "test-apps/hosting-ssr-sveltekit", "link": true }, + "node_modules/bb-test-infra": { + "resolved": "test-infra", + "link": true + }, "node_modules/bb-test-native-bindings": { "resolved": "test-apps/native-bindings", "link": true @@ -58378,7 +58383,41 @@ "test-apps/vpc-smoke": { "name": "bb-test-vpc-smoke", "version": "0.1.0", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@aws-blocks/bb-app-setting": "*", + "@aws-blocks/bb-async-job": "*", + "@aws-blocks/bb-auth-cognito": "*", + "@aws-blocks/bb-data": "*", + "@aws-blocks/bb-distributed-table": "*", + "@aws-blocks/bb-file-bucket": "*", + "@aws-blocks/bb-kv-store": "*", + "@aws-blocks/bb-logger": "*", + "@aws-blocks/bb-metrics": "*", + "@aws-blocks/bb-realtime": "*", + "@aws-blocks/bb-tracer": "*", + "@aws-blocks/blocks": "*", + "@aws-blocks/core": "*", + "aws-cdk-lib": "^2.257.0", + "constructs": "^10.6.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.3.0" + } + }, + "test-infra": { + "name": "bb-test-infra", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "aws-cdk-lib": "^2.257.0", + "constructs": "^10.6.0" + }, + "devDependencies": { + "tsx": "^4.7.0", + "typescript": "^5.3.0" + } } } } diff --git a/package.json b/package.json index 09107362b..dda175ebd 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "test-apps/pipeline", "test-apps/db-pull-typecheck", "scripts/agent-bench", - "test-apps/telemetry" + "test-apps/telemetry", + "test-infra" ], "scripts": { "prepare": "husky && bash scripts/setup-git-secrets.sh", diff --git a/packages/bb-agent/src/index.cdk.ts b/packages/bb-agent/src/index.cdk.ts index e36aa01e2..f512c1909 100644 --- a/packages/bb-agent/src/index.cdk.ts +++ b/packages/bb-agent/src/index.cdk.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Scope } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; import { DistributedTable } from '@aws-blocks/bb-distributed-table'; @@ -30,6 +31,9 @@ export class Agent extends Scope { constructor(scope: ScopeParent, id: string, config?: any) { super(id, { parent: scope }); + // Register VPC endpoint for Bedrock access + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.BEDROCK_RUNTIME); + this.handler.addToRolePolicy(new PolicyStatement({ actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream', 'bedrock:GetFoundationModel', 'bedrock:ListFoundationModels', 'bedrock:GetInferenceProfile'], resources: [ diff --git a/packages/bb-app-setting/src/index.cdk.ts b/packages/bb-app-setting/src/index.cdk.ts index 8f5b80e7d..791630c47 100644 --- a/packages/bb-app-setting/src/index.cdk.ts +++ b/packages/bb-app-setting/src/index.cdk.ts @@ -5,6 +5,7 @@ import * as cdk from 'aws-cdk-lib'; import * as ssm from 'aws-cdk-lib/aws-ssm'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as cr from 'aws-cdk-lib/custom-resources'; import { Scope, registerConfig, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; @@ -49,6 +50,9 @@ export class AppSetting extends Scope { constructor(scope: ScopeParent, id: string, options: AppSettingOptions) { super(id, { parent: scope }); + // Register VPC endpoint for SSM access + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SSM); + // `external` is package-internal (set only by fromExisting), not on the // public AppSettingOptions — read it via the internal options type. const external = (options as InternalAppSettingOptions).external ?? false; diff --git a/packages/bb-async-job/src/index.cdk.ts b/packages/bb-async-job/src/index.cdk.ts index 809f8c2c3..4dc3a997d 100644 --- a/packages/bb-async-job/src/index.cdk.ts +++ b/packages/bb-async-job/src/index.cdk.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Duration } from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Queue, QueueEncryption } from 'aws-cdk-lib/aws-sqs'; import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; import { Scope } from '@aws-blocks/core/cdk'; @@ -24,6 +25,9 @@ export class AsyncJob extends Scope { constructor(scope: ScopeParent, id: string, options: AsyncJobOptions) { super(id, { parent: scope }); + // Register VPC endpoint for SQS access + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SQS); + const maxRetries = options.maxRetries ?? 3; const batchSize = options.batchSize ?? 1; diff --git a/packages/bb-auth-cognito/src/index.cdk.ts b/packages/bb-auth-cognito/src/index.cdk.ts index 5ace421c7..75f893490 100644 --- a/packages/bb-auth-cognito/src/index.cdk.ts +++ b/packages/bb-auth-cognito/src/index.cdk.ts @@ -19,6 +19,7 @@ */ import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as iam from 'aws-cdk-lib/aws-iam'; import type * as lambda from 'aws-cdk-lib/aws-lambda'; @@ -73,6 +74,10 @@ export class AuthCognito) { super(id, { parent: scope }); + // Register VPC endpoint for SSM (cookie secret storage) + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SSM); + this.callbackPath = options.callbackPath ?? DEFAULT_CALLBACK_PATH; this.signOutPath = options.signOutPath ?? DEFAULT_SIGNOUT_PATH; diff --git a/packages/bb-data/src/index.cdk.ts b/packages/bb-data/src/index.cdk.ts index dc4d63624..806b7f628 100644 --- a/packages/bb-data/src/index.cdk.ts +++ b/packages/bb-data/src/index.cdk.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Scope, registerConfig } from '@aws-blocks/core/cdk'; +import { getVpcContext } from '@aws-blocks/core/cdk'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import type { ScopeParent } from '@aws-blocks/core'; import { resolve } from 'node:path'; import * as cdk from 'aws-cdk-lib'; @@ -33,6 +35,11 @@ export class Database extends Scope { constructor(scope: ScopeParent, id: string, options?: DatabaseOptions) { super(id, { parent: scope }); + // Register VPC endpoints for RDS Data API + Secrets Manager + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER); + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.RDS_DATA); + this.registerVpcRequirements({ subnetRole: 'isolated' }); + const isSandbox = cdk.Stack.of(this).node.tryGetContext('sandboxMode') === 'true'; if (options?.connection) { @@ -77,6 +84,7 @@ export class Database extends Scope { migrationsPath: options?.migrationsPath ? resolve(options.migrationsPath) : undefined, removalPolicy: options?.removalPolicy ? REMOVAL_POLICY_MAP[options.removalPolicy] : defaultRemovalPolicy, postgresVersion: options?.postgresVersion, + vpcContext: getVpcContext(this), }); // Inject config so DataApiEngine can read them at runtime diff --git a/packages/bb-data/src/infra.ts b/packages/bb-data/src/infra.ts index 8b1e44bd1..50898ad52 100644 --- a/packages/bb-data/src/infra.ts +++ b/packages/bb-data/src/infra.ts @@ -21,6 +21,8 @@ import { VPC_MAX_AZS, } from './constants.js'; +import type { VpcContext } from '@aws-blocks/core/cdk'; + /** * Configuration for Aurora Serverless v2 infrastructure. */ @@ -37,6 +39,12 @@ export interface AuroraInfraConfig { removalPolicy?: cdk.RemovalPolicy; /** Aurora PostgreSQL engine version, e.g. `'16.13'`. @default '16.13' */ postgresVersion?: string; + /** + * VPC context from the parent scope. When provided, Aurora is placed in the + * shared VPC's isolated subnets instead of creating its own VPC. + * @internal + */ + vpcContext?: VpcContext; } /** @@ -90,26 +98,44 @@ export function materialize( const { minCapacity = DEFAULT_MIN_CAPACITY, maxCapacity = DEFAULT_MAX_CAPACITY, databaseName } = options; const envName = name.replace(ENV_NAME_SANITIZE_PATTERN, '_'); - // VPC with isolated subnets only — no NAT gateways needed for Data API path - const vpc = new ec2.Vpc(scope, `${name}Vpc`, { - maxAzs: VPC_MAX_AZS, - natGateways: 0, - subnetConfiguration: [ - { name: 'isolated', subnetType: ec2.SubnetType.PRIVATE_ISOLATED }, - ], - }); + let vpc: ec2.IVpc; + let securityGroup: ec2.SecurityGroup; - // Security group allowing inbound PostgreSQL from within the VPC - const securityGroup = new ec2.SecurityGroup(scope, `${name}Sg`, { - vpc, - description: `Security group for ${name} Aurora cluster`, - allowAllOutbound: false, - }); - securityGroup.addIngressRule( - ec2.Peer.ipv4(vpc.vpcCidrBlock), - ec2.Port.tcp(DEFAULT_POSTGRES_PORT), - 'Allow PostgreSQL from VPC', - ); + if (options.vpcContext) { + // Use the shared VPC — place Aurora in isolated subnets + vpc = options.vpcContext.vpc; + securityGroup = new ec2.SecurityGroup(scope, `${name}Sg`, { + vpc, + description: `Security group for ${name} Aurora cluster`, + allowAllOutbound: false, + }); + // Allow inbound from the Lambda security group on PostgreSQL port + securityGroup.addIngressRule( + options.vpcContext.lambdaSecurityGroup, + ec2.Port.tcp(DEFAULT_POSTGRES_PORT), + 'Lambda to Aurora', + ); + } else { + // Standalone fallback: create isolated VPC (current behavior) + vpc = new ec2.Vpc(scope, `${name}Vpc`, { + maxAzs: VPC_MAX_AZS, + natGateways: 0, + subnetConfiguration: [ + { name: 'isolated', subnetType: ec2.SubnetType.PRIVATE_ISOLATED }, + ], + }); + + securityGroup = new ec2.SecurityGroup(scope, `${name}Sg`, { + vpc, + description: `Security group for ${name} Aurora cluster`, + allowAllOutbound: false, + }); + securityGroup.addIngressRule( + ec2.Peer.ipv4(vpc.vpcCidrBlock), + ec2.Port.tcp(DEFAULT_POSTGRES_PORT), + 'Allow PostgreSQL from VPC', + ); + } // Aurora Serverless v2 cluster with Data API enabled const removalPolicy = options.removalPolicy ?? cdk.RemovalPolicy.RETAIN; diff --git a/packages/bb-distributed-data/src/index.cdk.ts b/packages/bb-distributed-data/src/index.cdk.ts index 91b964415..01b3537b3 100644 --- a/packages/bb-distributed-data/src/index.cdk.ts +++ b/packages/bb-distributed-data/src/index.cdk.ts @@ -23,6 +23,10 @@ export class DistributedDatabase extends Scope { constructor(scope: ScopeParent, id: string, options?: DistributedDatabaseOptions) { super(id, { parent: scope }); + // DSQL uses public HTTPS endpoints — no gateway/interface endpoint needed. + // Lambda in a VPC with NAT gateway (private-with-egress) can reach DSQL directly. + // No registerVpcRequirements call needed. + const stack = cdk.Stack.of(this); const isSandbox = stack.node.tryGetContext('sandboxMode') === 'true'; const envName = this.fullId.replace(ENV_SANITIZE, '_'); diff --git a/packages/bb-distributed-table/src/index.cdk.ts b/packages/bb-distributed-table/src/index.cdk.ts index 57bc7fdc3..035b899f4 100644 --- a/packages/bb-distributed-table/src/index.cdk.ts +++ b/packages/bb-distributed-table/src/index.cdk.ts @@ -4,6 +4,7 @@ import { Construct } from 'constructs'; import { Table, type ITable, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb'; import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { CustomResource, Duration } from 'aws-cdk-lib'; import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; import { Provider } from 'aws-cdk-lib/custom-resources'; @@ -34,6 +35,9 @@ export class DistributedTable extends Scope { constructor(scope: ScopeParent, id: string, public options: any) { super(id, { parent: scope }); + // Register VPC endpoint for DynamoDB access + this.registerVpcGatewayEndpoint(ec2.GatewayVpcEndpointAwsService.DYNAMODB); + const config = options; if (config?.table) { diff --git a/packages/bb-email-client/src/index.cdk.ts b/packages/bb-email-client/src/index.cdk.ts index fd08e170a..a92f68f8e 100644 --- a/packages/bb-email-client/src/index.cdk.ts +++ b/packages/bb-email-client/src/index.cdk.ts @@ -3,6 +3,7 @@ import { CfnConfigurationSet } from 'aws-cdk-lib/aws-ses'; import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Stack } from 'aws-cdk-lib'; import { Scope } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; @@ -17,6 +18,9 @@ export class EmailClient extends Scope { constructor(scope: ScopeParent, id: string, options: EmailOptions) { super(id, { parent: scope }); + // Register VPC endpoint for SES access + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.SES); + console.warn( `\n⚠️ [Email] Prerequisite: Domain for "${options.fromAddress}" must be verified in SES.\n` + ` Guide: https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html\n` diff --git a/packages/bb-file-bucket/src/index.cdk.ts b/packages/bb-file-bucket/src/index.cdk.ts index 3fece3e38..d908d1b9c 100644 --- a/packages/bb-file-bucket/src/index.cdk.ts +++ b/packages/bb-file-bucket/src/index.cdk.ts @@ -3,6 +3,7 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Duration, RemovalPolicy } from 'aws-cdk-lib'; import { Scope } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; @@ -35,6 +36,9 @@ export class FileBucket extends constructor(scope: ScopeParent, id: string, options?: O) { super(id, { parent: scope }); + // Register VPC endpoint for S3 access + this.registerVpcGatewayEndpoint(ec2.GatewayVpcEndpointAwsService.S3); + if (options?.bucket) { // `fromExisting`: don't provision; bind to the pre-existing bucket and // grant read/write to the Blocks runtime Lambda. diff --git a/packages/bb-knowledge-base/src/index.cdk.ts b/packages/bb-knowledge-base/src/index.cdk.ts index 9d9b409ce..61df6abb5 100644 --- a/packages/bb-knowledge-base/src/index.cdk.ts +++ b/packages/bb-knowledge-base/src/index.cdk.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as bedrock from 'aws-cdk-lib/aws-bedrock'; @@ -190,6 +191,9 @@ export class KnowledgeBase extends Scope { constructor(scope: ScopeParent, id: string, options: KnowledgeBaseOptions) { super(id, { parent: scope }); + // Register VPC endpoint for Bedrock access + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.BEDROCK_RUNTIME); + const dimensions = options.embeddingDimensions ?? 1024; // ── 1. S3 Data Bucket ────────────────────────────────────────────── diff --git a/packages/bb-kv-store/src/index.cdk.ts b/packages/bb-kv-store/src/index.cdk.ts index ff44a1b95..30693b01b 100644 --- a/packages/bb-kv-store/src/index.cdk.ts +++ b/packages/bb-kv-store/src/index.cdk.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Table, type ITable, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { RemovalPolicy } from 'aws-cdk-lib'; import { Scope, synthGuard } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; @@ -26,6 +27,9 @@ export class KVStore extends Scope { constructor(scope: ScopeParent, id: string, options?: KVStoreOptions) { super(id, { parent: scope }); + // Register VPC endpoint for DynamoDB access + this.registerVpcGatewayEndpoint(ec2.GatewayVpcEndpointAwsService.DYNAMODB); + if (options?.table) { // `fromExisting`: don't provision; bind to the pre-existing table by name // and grant the runtime Lambda read/write access. diff --git a/packages/bb-realtime/src/index.cdk.ts b/packages/bb-realtime/src/index.cdk.ts index 94a3b044a..8f39d94e8 100644 --- a/packages/bb-realtime/src/index.cdk.ts +++ b/packages/bb-realtime/src/index.cdk.ts @@ -14,6 +14,7 @@ */ import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2'; import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import { Scope, synthGuard } from '@aws-blocks/core/cdk'; @@ -126,6 +127,10 @@ function getOrCreateSharedInfra(stack: cdk.Stack, handler: cdk.aws_lambda.IFunct export class Realtime extends Scope { constructor(scope: ScopeParent, id: string, options: RealtimeOptions) { super(id, { parent: scope }); + + // Register VPC endpoint for API Gateway (WebSocket management API) + this.registerVpcInterfaceEndpoint(ec2.InterfaceVpcEndpointAwsService.APIGATEWAY); + getOrCreateSharedInfra(cdk.Stack.of(this), this.handler, this); } diff --git a/packages/core/src/cdk/blocks-backend.ts b/packages/core/src/cdk/blocks-backend.ts index 7387d26f3..1ba1c4991 100644 --- a/packages/core/src/cdk/blocks-backend.ts +++ b/packages/core/src/cdk/blocks-backend.ts @@ -10,6 +10,8 @@ import { pathToFileURL } from 'node:url'; import { DEFAULT_NODE_RUNTIME } from './node-version.js'; import { addBlocksStackMetadata } from './stack-metadata.js'; import { finalizeConfigRegistry, registerConfig } from './config-registry.js'; +import { initializeVpc, finalizeVpc } from './vpc.js'; +import type { BlocksVpcOptions, VpcContext } from './vpc-types.js'; import { BLOCKS_NAMESPACE, BLOCKS_RPC_PREFIX } from '../constants.js'; import { registerBuiltinRoutes } from '../builtin-routes.js'; @@ -44,11 +46,19 @@ export function assertCdkConditionActive(): void { export interface BlocksBackendProps { backendHandlerPath: string; backendCDKPath: string; + /** + * Place the app's compute and VPC-resident resources in a VPC. + * Pass a standard CDK VPC — Blocks handles Lambda placement, + * endpoint provisioning (based on BB requirements), and SG wiring. + * + * Omit for no VPC (default — Lambda runs in AWS-managed network). + */ + vpc?: BlocksVpcOptions; } /** Shared infra setup — creates Lambda + API Gateway on the given scope. */ -export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id?: string) { - const handler = new lambda.NodejsFunction(scope, 'Handler', { +export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id?: string, vpcContext?: VpcContext) { + const handlerProps: any = { entry: props.backendHandlerPath, runtime: DEFAULT_NODE_RUNTIME, handler: 'handler', @@ -71,7 +81,16 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id minify: true, esbuildArgs: { '--conditions': 'aws-runtime' }, }, - }); + }; + + // Apply VPC placement to the Lambda if VPC context is provided + if (vpcContext) { + handlerProps.vpc = vpcContext.vpc; + handlerProps.vpcSubnets = vpcContext.lambdaSubnets; + handlerProps.securityGroups = [vpcContext.lambdaSecurityGroup]; + } + + const handler = new lambda.NodejsFunction(scope, 'Handler', handlerProps); // In sandbox mode, allow localhost origins so the local dev frontend can // reach the deployed Lambda API via CORS. @@ -209,15 +228,24 @@ export class BlocksBackend extends Construct { return `${stackName}-${this.node.id}`; } + private _vpcOptions?: BlocksVpcOptions; + private constructor(scope: Construct, id: string, props: BlocksBackendProps) { super(scope, id); this.backendHandlerPath = props.backendHandlerPath; + this._vpcOptions = props.vpc; // Expose self to Building Blocks at CDK time (globalThis as any).CURRENT_BLOCKS_STACK = this; - const infra = setupBlocksInfra(this, props, id); + // Initialize VPC context before BBs are constructed (so BBs can discover it) + let vpcContext: VpcContext | undefined; + if (props.vpc) { + vpcContext = initializeVpc(this, props.vpc); + } + + const infra = setupBlocksInfra(this, props, id, vpcContext); this.handler = infra.handler; this.gateway = infra.gateway; this.apiUrl = infra.apiUrl; @@ -248,6 +276,11 @@ export class BlocksBackend extends Construct { // Finalize BB config → S3 (after all BBs have registered their config) finalizeConfigRegistry(backend, backend.handler); + // Finalize VPC: collect requirements → deduplicate → provision endpoints + if (backend._vpcOptions) { + finalizeVpc(backend, backend._vpcOptions); + } + return backend; } } diff --git a/packages/core/src/cdk/index.ts b/packages/core/src/cdk/index.ts index 2e938eba2..f31183598 100644 --- a/packages/core/src/cdk/index.ts +++ b/packages/core/src/cdk/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Construct } from 'constructs'; import { pathToFileURL } from 'node:url'; import { __PIPELINE_STAGE_SCOPE__ } from '@aws-blocks/pipeline'; @@ -15,6 +16,9 @@ import { import { setupBlocksInfra, BlocksBackend, assertCdkConditionActive } from './blocks-backend.js'; import { addBlocksStackMetadata } from './stack-metadata.js'; import { finalizeConfigRegistry } from './config-registry.js'; +import { registerVpcRequirements as registerVpcReqs, registerVpcGatewayEndpoint as registerGatewayEp, registerVpcInterfaceEndpoint as registerInterfaceEp } from './vpc.js'; +import { initializeVpc, finalizeVpc } from './vpc.js'; +import type { BlocksVpcOptions, VpcRequirements } from './vpc-types.js'; export { BlocksBackend, type BlocksBackendProps } from './blocks-backend.js'; export { DEFAULT_NODE_RUNTIME } from './node-version.js'; @@ -23,6 +27,8 @@ export { registerConfig, finalizeConfigRegistry } from './config-registry.js'; export { synthGuard } from './synth-guard.js'; export type { ScopeOptions } from '../index.js'; export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js'; +export { getVpcContext } from './vpc.js'; +export type { BlocksVpcOptions, VpcRequirements, VpcContext, SubnetRole } from './vpc-types.js'; export class BlocksStack extends cdk.Stack implements BaseBlocksStack { public readonly id: string; @@ -31,18 +37,32 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { public readonly handler: cdk.aws_lambda_nodejs.NodejsFunction; public readonly backendHandlerPath: string; + private _vpcOptions?: BlocksVpcOptions; + private constructor(scope: Construct, id: string, props: BlocksStackProps) { super(scope, id, props); this.id = id; this.backendHandlerPath = props.backendHandlerPath; + this._vpcOptions = props.vpc; // Set globalThis so Building Blocks attach directly to this stack (globalThis as any).CURRENT_BLOCKS_STACK = this; - const infra = setupBlocksInfra(this, props, id); - this.handler = infra.handler; - this.gateway = infra.gateway; - this.apiUrl = infra.apiUrl; + // Initialize VPC context before BBs are constructed (so BBs can discover it) + if (props.vpc) { + const vpcContext = initializeVpc(this, props.vpc); + // Apply VPC placement to the Lambda handler after infra is set up + // (infra setup happens next) + const infra = setupBlocksInfra(this, props, id, vpcContext); + this.handler = infra.handler; + this.gateway = infra.gateway; + this.apiUrl = infra.apiUrl; + } else { + const infra = setupBlocksInfra(this, props, id); + this.handler = infra.handler; + this.gateway = infra.gateway; + this.apiUrl = infra.apiUrl; + } } static async create(scope: Construct, id: string, props: BlocksStackProps) { @@ -68,6 +88,11 @@ export class BlocksStack extends cdk.Stack implements BaseBlocksStack { // Finalize BB config → S3 (after all BBs have registered their config) finalizeConfigRegistry(stack, stack.handler); + // Finalize VPC: collect requirements → deduplicate → provision endpoints + if (stack._vpcOptions) { + finalizeVpc(stack, stack._vpcOptions); + } + new cdk.CfnOutput(stack, 'ApiUrl', { value: stack.apiUrl }); addBlocksStackMetadata(stack); @@ -90,6 +115,36 @@ export class Scope extends Construct { this.parent = parent; } + /** + * Declare what VPC resources this Building Block needs (subnet role). + * Requirements are collected at finalization time. + * + * @param requirements - Subnet role this BB requires + */ + protected registerVpcRequirements(requirements: VpcRequirements): void { + registerVpcReqs(this, requirements); + } + + /** + * Register a gateway VPC endpoint that this Building Block needs. + * Gateway endpoints (S3, DynamoDB) are free and attached to route tables. + * + * @param service - The gateway VPC endpoint AWS service + */ + protected registerVpcGatewayEndpoint(service: ec2.GatewayVpcEndpointAwsService): void { + registerGatewayEp(this, service); + } + + /** + * Register an interface VPC endpoint that this Building Block needs. + * Interface endpoints cost ~$7/mo per AZ and use ENIs + private DNS. + * + * @param service - The interface VPC endpoint AWS service + */ + protected registerVpcInterfaceEndpoint(service: ec2.InterfaceVpcEndpointAwsService): void { + registerInterfaceEp(this, service); + } + get handler() { // Walk up the construct tree to find the owning BlocksStack/BlocksBackend let current: Construct = this; diff --git a/packages/core/src/cdk/vpc-types.ts b/packages/core/src/cdk/vpc-types.ts new file mode 100644 index 000000000..dbf7f7523 --- /dev/null +++ b/packages/core/src/cdk/vpc-types.ts @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type * as ec2 from 'aws-cdk-lib/aws-ec2'; + +/** + * Subnet role — BBs declare what kind of subnet they need. + * The VPC maps roles to actual subnet selections. + */ +export type SubnetRole = 'private-with-egress' | 'isolated' | 'public'; + +/** + * Options for VPC integration on BlocksStack / BlocksBackend. + * + * @example + * ```ts + * const vpc = new ec2.Vpc(app, 'AppVpc', { maxAzs: 2, natGateways: 1 }); + * await BlocksStack.create(app, stackName, { + * backendHandlerPath: join(__dirname, 'index.handler.ts'), + * backendCDKPath: join(__dirname, 'index.ts'), + * vpc: { vpc }, + * }); + * ``` + */ +export interface BlocksVpcOptions { + /** + * The VPC to place Lambdas and VPC-resident resources into. + * Create this however you like — standard CDK: + * + * @example + * const vpc = new ec2.Vpc(stack, 'AppVpc', { maxAzs: 2, natGateways: 1 }); + * // or + * const vpc = ec2.Vpc.fromLookup(stack, 'SharedVpc', { vpcId: 'vpc-abc123' }); + */ + vpc: ec2.IVpc; + + /** + * Subnet selection for Lambda and Blocks-managed compute placement. + * @default { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS } + */ + subnets?: ec2.SubnetSelection; + + /** + * Whether to auto-provision VPC endpoints based on BB registrations. + * Set to `false` to disable (e.g., when using a shared VPC that already has endpoints). + * + * @default true + */ + provisionEndpoints?: boolean; +} + +/** + * VPC requirements declared by a Building Block. + * Collected during finalization to provision endpoints. + */ +export interface VpcRequirements { + /** Subnet role for VPC-resident resources (e.g., Aurora needs 'isolated'). */ + subnetRole?: SubnetRole; +} + +/** + * Internal VPC context propagated through the construct tree. + * Set by the CDK-level VPC option. BBs read this to determine their VPC placement. + * @internal + */ +export interface VpcContext { + readonly vpc: ec2.IVpc; + readonly lambdaSecurityGroup: ec2.ISecurityGroup; + readonly lambdaSubnets: ec2.SubnetSelection; + selectSubnets(role: SubnetRole): ec2.SubnetSelection; +} diff --git a/packages/core/src/cdk/vpc.test.ts b/packages/core/src/cdk/vpc.test.ts new file mode 100644 index 000000000..0f41856fb --- /dev/null +++ b/packages/core/src/cdk/vpc.test.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import { registerVpcGatewayEndpoint, registerVpcInterfaceEndpoint, registerVpcRequirements, getVpcContext, setVpcContext } from './vpc.js'; + +// We can't fully test CDK constructs without a Stack, but we can test +// the registration and getVpcContext logic with mock constructs. + +describe('VPC utilities', () => { + it('registerVpcGatewayEndpoint stores gateway endpoints on the scope', () => { + const fakeScope: any = { node: { children: [] } }; + registerVpcGatewayEndpoint(fakeScope, ec2.GatewayVpcEndpointAwsService.DYNAMODB); + const key = Symbol.for('BLOCKS_VPC_GATEWAY_ENDPOINTS'); + assert.equal(fakeScope[key].length, 1); + assert.equal(fakeScope[key][0], ec2.GatewayVpcEndpointAwsService.DYNAMODB); + }); + + it('registerVpcGatewayEndpoint appends when called multiple times', () => { + const fakeScope: any = { node: { children: [] } }; + registerVpcGatewayEndpoint(fakeScope, ec2.GatewayVpcEndpointAwsService.DYNAMODB); + registerVpcGatewayEndpoint(fakeScope, ec2.GatewayVpcEndpointAwsService.S3); + const key = Symbol.for('BLOCKS_VPC_GATEWAY_ENDPOINTS'); + assert.equal(fakeScope[key].length, 2); + assert.equal(fakeScope[key][0], ec2.GatewayVpcEndpointAwsService.DYNAMODB); + assert.equal(fakeScope[key][1], ec2.GatewayVpcEndpointAwsService.S3); + }); + + it('registerVpcInterfaceEndpoint stores interface endpoints on the scope', () => { + const fakeScope: any = { node: { children: [] } }; + registerVpcInterfaceEndpoint(fakeScope, ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER); + const key = Symbol.for('BLOCKS_VPC_INTERFACE_ENDPOINTS'); + assert.equal(fakeScope[key].length, 1); + assert.equal(fakeScope[key][0], ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER); + }); + + it('registerVpcRequirements stores subnet role on the scope', () => { + const fakeScope: any = { node: { children: [] } }; + registerVpcRequirements(fakeScope, { subnetRole: 'isolated' }); + const key = Symbol.for('BLOCKS_VPC_REQUIREMENTS'); + assert.deepEqual(fakeScope[key], { subnetRole: 'isolated' }); + }); + + it('getVpcContext returns undefined when no context set', () => { + const fakeScope: any = { node: { scope: undefined } }; + assert.equal(getVpcContext(fakeScope), undefined); + }); + + it('getVpcContext walks up the scope tree', () => { + const vpcContext = { vpc: 'mock-vpc', lambdaSecurityGroup: 'mock-sg', lambdaSubnets: {} }; + const parent: any = { node: { scope: undefined } }; + const key = Symbol.for('BLOCKS_VPC_CONTEXT'); + parent[key] = vpcContext; + + const child: any = { node: { scope: parent } }; + assert.equal(getVpcContext(child), vpcContext); + }); +}); diff --git a/packages/core/src/cdk/vpc.ts b/packages/core/src/cdk/vpc.ts new file mode 100644 index 000000000..cef8719ca --- /dev/null +++ b/packages/core/src/cdk/vpc.ts @@ -0,0 +1,213 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import type { Construct } from 'constructs'; +import type { BlocksVpcOptions, VpcContext, VpcRequirements, SubnetRole } from './vpc-types.js'; + +const VPC_GATEWAY_ENDPOINTS_KEY = Symbol.for('BLOCKS_VPC_GATEWAY_ENDPOINTS'); +const VPC_INTERFACE_ENDPOINTS_KEY = Symbol.for('BLOCKS_VPC_INTERFACE_ENDPOINTS'); +const VPC_REQUIREMENTS_KEY = Symbol.for('BLOCKS_VPC_REQUIREMENTS'); +const VPC_CONTEXT_KEY = Symbol.for('BLOCKS_VPC_CONTEXT'); + +/** + * Register a gateway VPC endpoint requirement for a Building Block. + * Called by BB CDK constructors to declare what gateway endpoints they need. + * @internal + */ +export function registerVpcGatewayEndpoint(scope: Construct, service: ec2.GatewayVpcEndpointAwsService): void { + const existing = (scope as any)[VPC_GATEWAY_ENDPOINTS_KEY] as ec2.GatewayVpcEndpointAwsService[] | undefined; + if (existing) { + existing.push(service); + } else { + (scope as any)[VPC_GATEWAY_ENDPOINTS_KEY] = [service]; + } +} + +/** + * Register an interface VPC endpoint requirement for a Building Block. + * Called by BB CDK constructors to declare what interface endpoints they need. + * @internal + */ +export function registerVpcInterfaceEndpoint(scope: Construct, service: ec2.InterfaceVpcEndpointAwsService): void { + const existing = (scope as any)[VPC_INTERFACE_ENDPOINTS_KEY] as ec2.InterfaceVpcEndpointAwsService[] | undefined; + if (existing) { + existing.push(service); + } else { + (scope as any)[VPC_INTERFACE_ENDPOINTS_KEY] = [service]; + } +} + +/** + * Register VPC requirements (subnet role) for a Building Block. + * Called by BB CDK constructors to declare what subnet role they need. + * @internal + */ +export function registerVpcRequirements(scope: Construct, requirements: VpcRequirements): void { + const existing = (scope as any)[VPC_REQUIREMENTS_KEY] as VpcRequirements | undefined; + if (existing) { + (scope as any)[VPC_REQUIREMENTS_KEY] = { + subnetRole: requirements.subnetRole || existing.subnetRole, + }; + } else { + (scope as any)[VPC_REQUIREMENTS_KEY] = requirements; + } +} + +/** + * Set the VPC context on a scope (BlocksStack or BlocksBackend). + * Called during stack creation when `vpc` prop is provided. + * @internal + */ +export function setVpcContext(scope: Construct, context: VpcContext): void { + (scope as any)[VPC_CONTEXT_KEY] = context; +} + +/** + * Get the VPC context from a scope by walking up the construct tree. + * Used by BBs (e.g., bb-data) to discover the shared VPC. + * @internal + */ +export function getVpcContext(scope: Construct): VpcContext | undefined { + let current: Construct | undefined = scope; + while (current) { + const ctx = (current as any)[VPC_CONTEXT_KEY] as VpcContext | undefined; + if (ctx) return ctx; + current = current.node.scope as Construct | undefined; + } + return undefined; +} + +/** + * Collect all gateway endpoint registrations from the construct tree. + * @internal + */ +function collectGatewayEndpoints(scope: Construct): ec2.GatewayVpcEndpointAwsService[] { + const all: ec2.GatewayVpcEndpointAwsService[] = []; + + function walk(node: Construct) { + const eps = (node as any)[VPC_GATEWAY_ENDPOINTS_KEY] as ec2.GatewayVpcEndpointAwsService[] | undefined; + if (eps) { + all.push(...eps); + } + for (const child of node.node.children) { + if ('node' in child) { + walk(child as Construct); + } + } + } + + walk(scope); + return all; +} + +/** + * Collect all interface endpoint registrations from the construct tree. + * @internal + */ +function collectInterfaceEndpoints(scope: Construct): ec2.InterfaceVpcEndpointAwsService[] { + const all: ec2.InterfaceVpcEndpointAwsService[] = []; + + function walk(node: Construct) { + const eps = (node as any)[VPC_INTERFACE_ENDPOINTS_KEY] as ec2.InterfaceVpcEndpointAwsService[] | undefined; + if (eps) { + all.push(...eps); + } + for (const child of node.node.children) { + if ('node' in child) { + walk(child as Construct); + } + } + } + + walk(scope); + return all; +} + +/** + * Initialize VPC support on a Blocks scope (BlocksStack or BlocksBackend). + * Creates the security group, sets VPC context, and returns the VpcContext + * that is used for Lambda placement configuration. + * + * Called during setupBlocksInfra when `vpc` prop is present. + * @internal + */ +export function initializeVpc(scope: Construct, options: BlocksVpcOptions): VpcContext { + const { vpc, subnets } = options; + + const resolvedSubnets = subnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; + + const lambdaSecurityGroup = new ec2.SecurityGroup(scope, 'BlocksLambdaSg', { + vpc, + description: 'Security group for Blocks Lambda functions in VPC', + allowAllOutbound: true, + }); + + const context: VpcContext = { + vpc, + lambdaSecurityGroup, + lambdaSubnets: resolvedSubnets, + selectSubnets(role: SubnetRole): ec2.SubnetSelection { + switch (role) { + case 'isolated': + return { subnetType: ec2.SubnetType.PRIVATE_ISOLATED }; + case 'public': + return { subnetType: ec2.SubnetType.PUBLIC }; + case 'private-with-egress': + default: + return { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; + } + }, + }; + + setVpcContext(scope, context); + return context; +} + +/** + * Finalize VPC: collect endpoint registrations from all BBs, deduplicate, and provision. + * Called after all BBs are constructed (alongside finalizeConfigRegistry). + * @internal + */ +export function finalizeVpc(scope: Construct, options: BlocksVpcOptions): void { + if (options.provisionEndpoints === false) { + return; + } + + const { vpc } = options; + + // Collect gateway endpoints from BB registrations and deduplicate + const gatewayEndpoints = collectGatewayEndpoints(scope); + const provisionedGateway = new Set(); + + for (const service of gatewayEndpoints) { + const key = (service as any).name ?? String(service); + if (provisionedGateway.has(key)) continue; + provisionedGateway.add(key); + + const constructId = `VpcGw${key.replace(/[^a-zA-Z0-9]/g, '')}`; + new ec2.GatewayVpcEndpoint(scope, constructId, { vpc, service }); + } + + // Collect interface endpoints from BB registrations and deduplicate + const interfaceEndpoints = collectInterfaceEndpoints(scope); + // Always add CloudWatch Logs (Lambda needs it for log delivery from within VPC) + interfaceEndpoints.push(ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS); + // Always add SSM (auth BBs and AppSetting all use SSM) + interfaceEndpoints.push(ec2.InterfaceVpcEndpointAwsService.SSM); + + const provisionedInterface = new Set(); + + for (const service of interfaceEndpoints) { + const key = service.name; + if (provisionedInterface.has(key)) continue; + provisionedInterface.add(key); + + const constructId = `VpcIf${key.replace(/[^a-zA-Z0-9]/g, '')}`; + new ec2.InterfaceVpcEndpoint(scope, constructId, { + vpc, + service, + privateDnsEnabled: true, + }); + } +} diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index eb57adfa1..875f54dc1 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -325,6 +325,14 @@ export function computeScopeFullId(scope: { id: string; parent?: any }) { export interface BlocksStackProps extends StackProps { backendHandlerPath: string; backendCDKPath: string; + /** + * Place the app's compute and VPC-resident resources in a VPC. + * Pass a standard CDK VPC — Blocks handles Lambda placement, + * endpoint provisioning (based on BB requirements), and SG wiring. + * + * Omit for no VPC (default — Lambda runs in AWS-managed network). + */ + vpc?: import('../cdk/vpc-types.js').BlocksVpcOptions; } export class BlocksStack { diff --git a/packages/core/src/index.cdk.ts b/packages/core/src/index.cdk.ts index 954a0c9d4..6a3fad4ed 100644 --- a/packages/core/src/index.cdk.ts +++ b/packages/core/src/index.cdk.ts @@ -8,7 +8,8 @@ export { EventSourceMapping } from './lambda-handler.js'; export { BlocksStackProps } from './common/index.js'; export { registerSdkIdentifiers, getSdkIdentifiers, getAllSdkIdentifiers, _resetSdkRegistry } from './common/sdk-registry.js'; export { getConfig, getConfigSync, preloadConfig, loadConfigToProcessEnv, _resetConfigCache } from './common/config.js'; -export { BlocksStack, Scope, SandboxDisableDeletionProtection, BlocksBackend, registerConfig, finalizeConfigRegistry, synthGuard, DEFAULT_NODE_RUNTIME, type BlocksBackendProps } from './cdk/index.js'; +export { BlocksStack, Scope, SandboxDisableDeletionProtection, BlocksBackend, registerConfig, finalizeConfigRegistry, synthGuard, DEFAULT_NODE_RUNTIME, getVpcContext, type BlocksBackendProps } from './cdk/index.js'; +export type { BlocksVpcOptions, VpcRequirements, VpcContext, SubnetRole } from './cdk/index.js'; export { Hosting, type HostingProps, diff --git a/test-apps/vpc-smoke/.gitignore b/test-apps/vpc-smoke/.gitignore new file mode 100644 index 000000000..50d143bd3 --- /dev/null +++ b/test-apps/vpc-smoke/.gitignore @@ -0,0 +1,7 @@ +.blocks-sandbox/ +cdk.out/ +node_modules/ +dist/ +.env +aws-blocks/client.js +.bb-data/ diff --git a/test-apps/vpc-smoke/aws-blocks/client.js b/test-apps/vpc-smoke/aws-blocks/client.js deleted file mode 100644 index dffad1968..000000000 --- a/test-apps/vpc-smoke/aws-blocks/client.js +++ /dev/null @@ -1,15 +0,0 @@ -// ============================================================ -// AUTO-GENERATED FILE — DO NOT EDIT -// -// This file is generated by the Blocks dev server / build process. -// Any manual changes will be overwritten on the next build. -// ============================================================ - -import { ApiNamespaceClient as __BLOCKS_ApiNamespaceClient__ } from '@aws-blocks/blocks/client'; -import '@aws-blocks/bb-file-bucket/middleware'; - -export const api = __BLOCKS_ApiNamespaceClient__('api'); - -export const generateClient = (config) => ({ - api: __BLOCKS_ApiNamespaceClient__('api', config), -}); diff --git a/test-apps/vpc-smoke/aws-blocks/index.cdk.ts b/test-apps/vpc-smoke/aws-blocks/index.cdk.ts new file mode 100644 index 000000000..2796fba5f --- /dev/null +++ b/test-apps/vpc-smoke/aws-blocks/index.cdk.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import { RemovalPolicies, Mixins } from 'aws-cdk-lib'; +import { BlocksStack, SandboxDisableDeletionProtection } from '@aws-blocks/blocks/cdk'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function getSandboxId(projectRoot: string): string { + const dir = join(projectRoot, '.blocks-sandbox'); + const file = join(dir, 'sandbox-id.txt'); + if (existsSync(file)) return readFileSync(file, 'utf-8').trim(); + mkdirSync(dir, { recursive: true }); + const id = randomUUID().slice(0, 8); + writeFileSync(file, id); + return id; +} + +const app = new cdk.App(); +const sandboxMode = app.node.tryGetContext('sandboxMode') === 'true'; +const projectRoot = app.node.tryGetContext('projectRoot') || process.cwd(); +const id = getSandboxId(projectRoot); +const suffix = process.env.BLOCKS_STACK_SUFFIX; + +const stackName = sandboxMode + ? `bb-vpc-smoke-${id}${suffix ? `-${suffix}` : ''}` + : `bb-vpc-smoke-prod-${suffix || 'default'}-${id}`; + +const vpcId = app.node.tryGetContext('vpcId') || process.env.VPC_TEST_VPC_ID; + +if (!vpcId) { + throw new Error( + 'Missing VPC ID. Set the VPC_TEST_VPC_ID env var or pass -c vpcId=vpc-xxx.\n' + + 'Deploy the persistent test VPC first: cd test-infra && npx cdk deploy', + ); +} + +// fromLookup needs a Stack scope. We use the BlocksStack itself by creating +// it without VPC first, then looking up the VPC inside it. +// NOTE: BlocksStack.create() returns the stack — we pass VPC separately. +const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION || 'us-east-1' }; + +export const blocksStack = await BlocksStack.create(app, stackName, { + backendHandlerPath: join(__dirname, 'index.handler.ts'), + backendCDKPath: join(__dirname, 'index.ts'), + vpc: { + // Use fromLookup with the stack's own env — CDK will resolve it at synth. + // This works because BlocksStack IS a Stack, satisfying CDK's scope requirement. + vpc: ec2.Vpc.fromLookup(new cdk.Stack(app, `${stackName}-vpc-ref`, { env }), 'Vpc', { vpcId }), + provisionEndpoints: false, + }, +}); + +// Make the vpc-ref stack depend on nothing and have no resources — it's just +// a context lookup container. Tag it for cleanup. +const refStack = app.node.findChild(`${stackName}-vpc-ref`) as cdk.Stack; +RemovalPolicies.of(refStack).destroy(); + +RemovalPolicies.of(blocksStack).destroy(); +Mixins.of(blocksStack).apply(new SandboxDisableDeletionProtection()); + +cdk.Tags.of(blocksStack).add('blocks:purpose', 'vpc-smoke-e2e'); +cdk.Tags.of(blocksStack).add('blocks:deploy-mode', sandboxMode ? 'sandbox' : 'production'); diff --git a/test-apps/vpc-smoke/aws-blocks/index.handler.ts b/test-apps/vpc-smoke/aws-blocks/index.handler.ts new file mode 100644 index 000000000..3471dbdeb --- /dev/null +++ b/test-apps/vpc-smoke/aws-blocks/index.handler.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createLambdaHandler } from '@aws-blocks/blocks/lambda-handler'; + +export const handler = createLambdaHandler(() => import('./index.js')); diff --git a/test-apps/vpc-smoke/aws-blocks/index.ts b/test-apps/vpc-smoke/aws-blocks/index.ts new file mode 100644 index 000000000..d4f245f08 --- /dev/null +++ b/test-apps/vpc-smoke/aws-blocks/index.ts @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * VPC Smoke Test — Building Block instantiation + API surface for testing. + * Instantiates one of each VPC-relevant BB and exposes an API that exercises + * each BB's basic operation (for the smoke test to call via RPC). + */ + +import { ApiNamespace, Scope } from '@aws-blocks/core'; +import { KVStore } from '@aws-blocks/bb-kv-store'; +import { DistributedTable } from '@aws-blocks/bb-distributed-table'; +import { FileBucket } from '@aws-blocks/bb-file-bucket'; +import { AsyncJob } from '@aws-blocks/bb-async-job'; +import { AppSetting } from '@aws-blocks/bb-app-setting'; +import { Realtime } from '@aws-blocks/bb-realtime'; +import { AuthCognito } from '@aws-blocks/bb-auth-cognito'; +import { Database, sql } from '@aws-blocks/bb-data'; +import { Logger } from '@aws-blocks/bb-logger'; +import { Metrics } from '@aws-blocks/bb-metrics'; +import { Tracer } from '@aws-blocks/bb-tracer'; +import { z } from 'zod'; + +const scope = new Scope('vpc-smoke'); + +// ── Building Blocks ───────────────────────────────────────────────────────── + +const kv = new KVStore(scope, 'cache'); + +const table = new DistributedTable(scope, 'items', { + schema: z.object({ + pk: z.string(), + sk: z.string(), + data: z.string(), + }), + key: { partitionKey: 'pk', sortKey: 'sk' }, +}); + +const files = new FileBucket(scope, 'uploads'); + +const job = new AsyncJob(scope, 'processor', { + schema: z.object({ message: z.string() }), + handler: async (payload: { message: string }) => { + console.log('Processing:', payload.message); + }, +}); + +const setting = new AppSetting(scope, 'config-val', { + value: 'test-value', +}); + +const rt = new Realtime(scope, 'events', { + namespaces: { + notifications: { schema: z.object({ text: z.string() }) }, + }, +}); + +const auth = new AuthCognito(scope, 'auth'); +export const authApi = auth.createApi(); + +const db = new Database(scope, 'db'); + +const logger = new Logger(scope, 'log', { level: 'info' }); +const metrics = new Metrics(scope, 'metrics', { namespace: 'vpc-smoke' }); +const tracer = new Tracer(scope, 'tracer'); + +// ── API (exposes operations for the smoke test to call via RPC) ───────────── + +export const api = new ApiNamespace(scope, 'api', (context) => ({ + async kvPutGet(key: string, value: string) { + await kv.put(key, value); + const result = await kv.get(key); + await kv.delete(key); + return result; + }, + + async tablePutQuery(pk: string, sk: string, data: string) { + await table.put({ pk, sk, data }); + const items: Array<{ pk: string; sk: string; data: string }> = []; + for await (const item of table.query({ where: { pk: { equals: pk } } })) { + items.push(item); + } + await table.delete({ pk, sk }); + return items; + }, + + async filePutGet(key: string, content: string) { + await files.put(key, content); + const result = await files.get(key); + await files.delete(key); + return result !== null ? 'ok' : 'fail'; + }, + + async jobSubmit(message: string) { + await job.submit({ message }); + return 'submitted'; + }, + + async settingGet() { + return await setting.get(); + }, + + async realtimePublish(channel: string, text: string) { + await rt.publish('notifications', channel, { text }); + return 'published'; + }, + + async realtimeGetChannel(channel: string) { + return rt.getChannel('notifications', channel); + }, + + async dbQuery() { + const result = await db.query<{ ping: number }>(sql`SELECT 1 AS ping`); + return result; + }, + + async logEmit(message: string) { + logger.info(message); + return 'logged'; + }, + + async metricsEmit(name: string, value: number) { + metrics.emit(name, value, { unit: 'Count' }); + return 'emitted'; + }, + + async tracerRun(name: string) { + return await tracer.startSegment(name, async (segment) => { + segment.addAnnotation('test', 'vpc-smoke'); + return 'traced'; + }); + }, +})); diff --git a/test-apps/vpc-smoke/aws-blocks/package.json b/test-apps/vpc-smoke/aws-blocks/package.json new file mode 100644 index 000000000..06fc35ff6 --- /dev/null +++ b/test-apps/vpc-smoke/aws-blocks/package.json @@ -0,0 +1,18 @@ +{ + "name": "bb-vpc-smoke-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "echo 'No tests (IFC subpackage)'" + }, + "exports": { + ".": { + "types": "./index.ts", + "browser": "./client.js", + "react-server": "./client.js", + "import": "./client.js", + "default": "./index.ts" + } + } +} diff --git a/test-apps/vpc-smoke/cdk.json b/test-apps/vpc-smoke/cdk.json new file mode 100644 index 000000000..0de3f3b5a --- /dev/null +++ b/test-apps/vpc-smoke/cdk.json @@ -0,0 +1,3 @@ +{ + "app": "npx tsx aws-blocks/index.cdk.ts" +} diff --git a/test-apps/vpc-smoke/package.json b/test-apps/vpc-smoke/package.json index 35a416eb1..e0e60d982 100644 --- a/test-apps/vpc-smoke/package.json +++ b/test-apps/vpc-smoke/package.json @@ -7,9 +7,31 @@ "type": "module", "description": "Thin round-trip smoke tests verifying all BBs work in a VPC environment", "scripts": { - "test": "echo 'VPC smoke tests run during e2e:sandbox:vpc and e2e:production:vpc'", - "test:e2e:local": "echo 'No VPC-dependent BBs on main yet — skipping' && exit 0", - "test:e2e:sandbox": "echo 'No VPC-dependent BBs on main yet — skipping' && exit 0", - "test:e2e:production": "echo 'No VPC-dependent BBs on main yet — skipping' && exit 0" + "build": "tsc --build", + "test": "echo 'VPC smoke tests run via test:e2e:sandbox'", + "test:e2e:local": "echo 'VPC smoke tests require deployment \u2014 skipping local' && exit 0", + "test:e2e:sandbox": "BLOCKS_TEST_ENV=sandbox npx tsx test/vpc-smoke.test.ts", + "test:e2e:production": "BLOCKS_TEST_ENV=production npx tsx test/vpc-smoke.test.ts" + }, + "dependencies": { + "@aws-blocks/blocks": "*", + "@aws-blocks/core": "*", + "@aws-blocks/bb-data": "*", + "@aws-blocks/bb-kv-store": "*", + "@aws-blocks/bb-distributed-table": "*", + "@aws-blocks/bb-file-bucket": "*", + "@aws-blocks/bb-async-job": "*", + "@aws-blocks/bb-app-setting": "*", + "@aws-blocks/bb-realtime": "*", + "@aws-blocks/bb-auth-cognito": "*", + "@aws-blocks/bb-logger": "*", + "@aws-blocks/bb-metrics": "*", + "@aws-blocks/bb-tracer": "*", + "aws-cdk-lib": "^2.257.0", + "constructs": "^10.6.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.3.0" } } diff --git a/test-apps/vpc-smoke/test/sandbox-deploy.ts b/test-apps/vpc-smoke/test/sandbox-deploy.ts new file mode 100644 index 000000000..69b1926dd --- /dev/null +++ b/test-apps/vpc-smoke/test/sandbox-deploy.ts @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { startSandbox, destroySandbox } from '@aws-blocks/blocks/scripts'; + +const backendPath = process.argv[2]; + +if (!process.env.BLOCKS_SANDBOX_KEEP) { + console.log('🧹 Destroying stale sandbox (if any)...'); + try { + await destroySandbox(backendPath); + } catch { + console.log(' No stale sandbox found.'); + } +} else { + console.log('♻️ BLOCKS_SANDBOX_KEEP set — skipping pre-destroy, reusing existing stack.'); +} + +await startSandbox({ backendPath, deployOnly: true }); diff --git a/test-apps/vpc-smoke/test/sandbox-destroy.ts b/test-apps/vpc-smoke/test/sandbox-destroy.ts new file mode 100644 index 000000000..bb78630ff --- /dev/null +++ b/test-apps/vpc-smoke/test/sandbox-destroy.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Destroy ALL stacks created by the vpc-smoke app (main + vpc-ref lookup stack). + * Uses `cdk destroy --all` to ensure complete cleanup. + */ +import { execFileSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cwd = join(__dirname, '..'); +const backendPath = process.argv[2]; + +// Destroy all stacks in the CDK app (handles both main + lookup stacks) +execFileSync('npx', [ + 'cdk', 'destroy', '--all', '--force', + '--context', 'sandboxMode=true', + '--context', `projectRoot=${cwd}`, + '--app', `npx tsx -C cdk ${backendPath}`, +], { cwd, stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: '' } }); diff --git a/test-apps/vpc-smoke/test/vpc-smoke.test.ts b/test-apps/vpc-smoke/test/vpc-smoke.test.ts new file mode 100644 index 000000000..972864255 --- /dev/null +++ b/test-apps/vpc-smoke/test/vpc-smoke.test.ts @@ -0,0 +1,129 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * VPC Smoke Tests — validates each BB can reach its backing service from + * within a VPC by calling API methods through direct HTTP/RPC. + */ + +import { test } from 'node:test'; +import assert from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const ENV = process.env.BLOCKS_TEST_ENV || 'local'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const backendPath = join(__dirname, '..', 'aws-blocks', 'index.cdk.ts'); +const outputsPath = join(__dirname, '..', '.blocks-sandbox', 'outputs.json'); + +let apiUrl: string; + +/** Call an API method via the Blocks JSON-RPC protocol */ +async function rpc(method: string, ...args: unknown[]): Promise { + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: "2.0", method: `api.${method}`, params: args, id: 1 }), + }); + if (!res.ok) throw new Error(`RPC ${method} failed: ${res.status} ${await res.text()}`); + const json = await res.json() as any; + if (json.error) throw new Error(`RPC ${method} error: ${json.error.message || JSON.stringify(json.error)}`); + return json.result ?? json.data; +} + +test('VPC Smoke Tests', async (t) => { + t.before(async () => { + if (ENV === 'local') { + console.log('⏭️ VPC smoke tests require deployment — skipping in local mode.'); + process.exit(0); + } + + console.log(`🚀 Deploying ${ENV}...\n`); + execFileSync('npx', ['tsx', 'test/sandbox-deploy.ts', backendPath], { + cwd: join(__dirname, '..'), stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: '' }, + }); + console.log('\n✅ Deployed\n'); + + // Read API URL from sandbox outputs + const outputs = JSON.parse(readFileSync(outputsPath, 'utf-8')); + apiUrl = outputs.ApiUrl || outputs.apiUrl; + if (!apiUrl) { + // Try nested format + const stackKey = Object.keys(outputs)[0]; + apiUrl = outputs[stackKey]?.ApiUrl || outputs[stackKey]?.apiUrl; + } + if (!apiUrl) throw new Error(`No ApiUrl found in ${outputsPath}: ${JSON.stringify(outputs)}`); + console.log(`📡 API URL: ${apiUrl}\n`); + }); + + t.after(async () => { + if ((ENV === 'sandbox' || ENV === 'production') && !process.env.BLOCKS_SANDBOX_KEEP) { + console.log(`\n🗑️ Destroying ${ENV} stack...`); + try { + execFileSync('npx', ['tsx', 'test/sandbox-destroy.ts', backendPath], { + cwd: join(__dirname, '..'), stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: '' }, + }); + console.log(`✅ Stack destroyed`); + } catch { + console.log(`⚠️ Stack destroy failed (non-fatal — cleanup will be retried next run)`); + } + } + }); + + await t.test('KVStore: put + get via DynamoDB gateway endpoint', async () => { + const key = `vpc-smoke-${Date.now()}`; + const result = await rpc('kvPutGet', key, 'hello-vpc'); + assert.strictEqual(result, 'hello-vpc'); + }); + + await t.test('DistributedTable: put + query via DynamoDB gateway endpoint', async () => { + const pk = `vpc-smoke-${Date.now()}`; + const items = await rpc('tablePutQuery', pk, 'item-1', 'vpc-test-data') as any[]; + assert.ok(items.length >= 1); + assert.strictEqual(items[0].data, 'vpc-test-data'); + }); + + await t.test('FileBucket: put + get via S3 gateway endpoint', async () => { + const key = `vpc-smoke-${Date.now()}.txt`; + const result = await rpc('filePutGet', key, 'vpc file content'); + assert.strictEqual(result, 'ok'); + }); + + await t.test('AsyncJob: submit via SQS interface endpoint', async () => { + const result = await rpc('jobSubmit', 'vpc-smoke-test'); + assert.strictEqual(result, 'submitted'); + }); + + await t.test('AppSetting: get via SSM interface endpoint', async () => { + const value = await rpc('settingGet'); + assert.ok(value !== null && value !== undefined); + }); + + await t.test('Realtime: publish via API Gateway interface endpoint', async () => { + const result = await rpc('realtimePublish', 'vpc-test-channel', 'connectivity check'); + assert.strictEqual(result, 'published'); + }); + + await t.test('Database (Aurora): query via Secrets Manager + RDS Data API endpoints', async () => { + const result = await rpc('dbQuery') as any[]; + assert.ok(result.length >= 1); + assert.strictEqual(result[0].ping, 1); + }); + + await t.test('Logger: emit via CloudWatch Logs endpoint', async () => { + const result = await rpc('logEmit', 'vpc-smoke-test'); + assert.strictEqual(result, 'logged'); + }); + + await t.test('Metrics: emit (EMF/stdout, no endpoint needed)', async () => { + const result = await rpc('metricsEmit', 'VpcSmokeTest', 1); + assert.strictEqual(result, 'emitted'); + }); + + await t.test('Tracer: startSegment (X-Ray agent, no endpoint needed)', async () => { + const result = await rpc('tracerRun', 'vpc-smoke-test'); + assert.strictEqual(result, 'traced'); + }); +}); diff --git a/test-apps/vpc-smoke/tsconfig.json b/test-apps/vpc-smoke/tsconfig.json new file mode 100644 index 000000000..fd4845419 --- /dev/null +++ b/test-apps/vpc-smoke/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["aws-blocks/**/*.ts", "test/**/*.ts"] +} diff --git a/test-infra/README.md b/test-infra/README.md new file mode 100644 index 000000000..759eacb03 --- /dev/null +++ b/test-infra/README.md @@ -0,0 +1,53 @@ +# test-infra — Persistent Test VPC + +This directory contains infrastructure that is deployed **once** and **not torn down** between test runs. + +## Why? + +- VPC quota is 5 per region +- VPC creation takes 2–3 minutes +- VPC deletion can fail (Lambda ENIs linger 10–20 min) +- Without a persistent VPC, CI hits quotas on concurrent runs + +## Usage + +### Deploy (one-time) + +```bash +cd test-infra +npm install +npx cdk deploy +``` + +The stack outputs the VPC ID. Set the `VPC_TEST_VPC_ID` environment variable for downstream test stacks: + +```bash +export VPC_TEST_VPC_ID=$(aws cloudformation describe-stacks \ + --stack-name BlocksTestVpc \ + --query 'Stacks[0].Outputs[?OutputKey==`VpcId`].OutputValue' \ + --output text) +``` + +### Reference from test apps + +The `test-apps/vpc-smoke/` app reads the VPC ID from `VPC_TEST_VPC_ID` env var or `-c vpcId=vpc-xxx` CDK context: + +```bash +cd test-apps/vpc-smoke +VPC_TEST_VPC_ID=vpc-abc123 NODE_OPTIONS="--conditions=cdk" npx cdk deploy +``` + +## Resources Created + +| Resource | Purpose | Cost | +|----------|---------|------| +| VPC (2 AZs, 1 NAT) | Network isolation for Lambda | ~$32/mo (NAT) | +| DynamoDB gateway endpoint | KVStore, DistributedTable | Free | +| S3 gateway endpoint | FileBucket | Free | +| SSM interface endpoint | AppSetting, Auth session secrets | ~$7/mo/AZ | +| Secrets Manager interface endpoint | Database credentials | ~$7/mo/AZ | +| CloudWatch Logs interface endpoint | Lambda log delivery | ~$7/mo/AZ | + +## Do NOT Delete + +This stack is tagged with `blocks:do-not-delete=true`. Deleting it will break all VPC smoke tests in CI until redeployed. diff --git a/test-infra/cdk.json b/test-infra/cdk.json new file mode 100644 index 000000000..cdf2d6cb5 --- /dev/null +++ b/test-infra/cdk.json @@ -0,0 +1,3 @@ +{ + "app": "npx tsx vpc-test-stack.ts" +} diff --git a/test-infra/package.json b/test-infra/package.json new file mode 100644 index 000000000..f77f6fe32 --- /dev/null +++ b/test-infra/package.json @@ -0,0 +1,23 @@ +{ + "name": "bb-test-infra", + "version": "0.1.0", + "author": "Amazon Web Services", + "license": "Apache-2.0", + "private": true, + "type": "module", + "description": "Persistent test infrastructure for AWS Blocks E2E tests (deploy once, do not tear down)", + "scripts": { + "build": "tsc --build", + "deploy": "npx cdk deploy --app 'npx tsx vpc-test-stack.ts'", + "synth": "npx cdk synth --app 'npx tsx vpc-test-stack.ts'", + "test": "echo \"no tests for infra stack\"" + }, + "dependencies": { + "aws-cdk-lib": "^2.257.0", + "constructs": "^10.6.0" + }, + "devDependencies": { + "tsx": "^4.7.0", + "typescript": "^5.3.0" + } +} diff --git a/test-infra/tsconfig.json b/test-infra/tsconfig.json new file mode 100644 index 000000000..9c67e715a --- /dev/null +++ b/test-infra/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["*.ts"] +} diff --git a/test-infra/vpc-test-stack.ts b/test-infra/vpc-test-stack.ts new file mode 100644 index 000000000..8befe1d50 --- /dev/null +++ b/test-infra/vpc-test-stack.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Persistent test VPC stack (bare minimum). + * + * VPC quota is 5 per region. Creation takes 2–3 min. Deletion can fail + * (Lambda ENIs linger 10–20 min). This stack is deployed ONCE and NOT torn + * down between test runs. CI references the VPC ID via env var or CDK context. + * + * Resources: + * - VPC with 2 AZs, 1 NAT gateway, public/private/isolated subnets + * - VPC ID output + * + * No pre-provisioned endpoints. No Aurora cluster. + * The test app provisions its own endpoints via `provisionEndpoints: true`, + * testing the real auto-detection path end-to-end. + */ + +import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'BlocksTestVpc', { + description: 'Persistent test VPC for AWS Blocks VPC smoke tests. Do NOT delete between test runs.', + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, +}); + +// ── VPC ───────────────────────────────────────────────────────────────────── + +const vpc = new ec2.Vpc(stack, 'TestVpc', { + maxAzs: 2, + natGateways: 1, + subnetConfiguration: [ + { + name: 'public', + subnetType: ec2.SubnetType.PUBLIC, + cidrMask: 24, + }, + { + name: 'private', + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + cidrMask: 24, + }, + { + name: 'isolated', + subnetType: ec2.SubnetType.PRIVATE_ISOLATED, + cidrMask: 24, + }, + ], +}); + +// ── Outputs ───────────────────────────────────────────────────────────────── + +new cdk.CfnOutput(stack, 'VpcId', { + value: vpc.vpcId, + description: 'VPC ID for downstream test stacks. Set VPC_TEST_VPC_ID env var to this value.', + exportName: 'BlocksTestVpcId', +}); + +// Prevent accidental deletion +cdk.Tags.of(stack).add('blocks:purpose', 'persistent-test-vpc'); +cdk.Tags.of(stack).add('blocks:do-not-delete', 'true'); + +app.synth();