@aws-blocks/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +25 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +32 -0
  4. package/dist/api.test.js +22 -0
  5. package/dist/bb-utils.d.ts +1 -0
  6. package/dist/bb-utils.d.ts.map +1 -1
  7. package/dist/bb-utils.js +3 -0
  8. package/dist/cdk/apigateway-account.d.ts +33 -0
  9. package/dist/cdk/apigateway-account.d.ts.map +1 -0
  10. package/dist/cdk/apigateway-account.js +60 -0
  11. package/dist/cdk/blocks-backend.d.ts +11 -0
  12. package/dist/cdk/blocks-backend.d.ts.map +1 -1
  13. package/dist/cdk/blocks-backend.js +72 -13
  14. package/dist/cdk/blocks-backend.test.js +88 -1
  15. package/dist/cdk/blocks-defaults.d.ts +76 -1
  16. package/dist/cdk/blocks-defaults.d.ts.map +1 -1
  17. package/dist/cdk/blocks-defaults.js +17 -1
  18. package/dist/cdk/blocks-defaults.test.js +17 -0
  19. package/dist/cdk/blocks-stack.test.js +44 -3
  20. package/dist/cdk/compute/compute-registry.d.ts +19 -0
  21. package/dist/cdk/compute/compute-registry.d.ts.map +1 -0
  22. package/dist/cdk/compute/compute-registry.js +38 -0
  23. package/dist/cdk/compute/compute.d.ts +82 -2
  24. package/dist/cdk/compute/compute.d.ts.map +1 -1
  25. package/dist/cdk/compute/compute.js +64 -2
  26. package/dist/cdk/compute/default-compute-factory.d.ts +1 -0
  27. package/dist/cdk/compute/default-compute-factory.d.ts.map +1 -1
  28. package/dist/cdk/config-registry.d.ts +34 -4
  29. package/dist/cdk/config-registry.d.ts.map +1 -1
  30. package/dist/cdk/config-registry.js +83 -25
  31. package/dist/cdk/config-registry.test.d.ts +2 -0
  32. package/dist/cdk/config-registry.test.d.ts.map +1 -0
  33. package/dist/cdk/config-registry.test.js +127 -0
  34. package/dist/cdk/dashboard-registry.d.ts +41 -0
  35. package/dist/cdk/dashboard-registry.d.ts.map +1 -0
  36. package/dist/cdk/dashboard-registry.js +61 -0
  37. package/dist/cdk/index.d.ts +90 -6
  38. package/dist/cdk/index.d.ts.map +1 -1
  39. package/dist/cdk/index.js +135 -13
  40. package/dist/cdk/internal.d.ts +3 -1
  41. package/dist/cdk/internal.d.ts.map +1 -1
  42. package/dist/cdk/internal.js +4 -1
  43. package/dist/cdk/tracer-registry.d.ts +31 -0
  44. package/dist/cdk/tracer-registry.d.ts.map +1 -0
  45. package/dist/cdk/tracer-registry.js +49 -0
  46. package/dist/cdk/vpc-requirements-registry.d.ts +33 -0
  47. package/dist/cdk/vpc-requirements-registry.d.ts.map +1 -0
  48. package/dist/cdk/vpc-requirements-registry.js +46 -0
  49. package/dist/cdk/vpc-types.d.ts +151 -0
  50. package/dist/cdk/vpc-types.d.ts.map +1 -0
  51. package/dist/cdk/vpc-types.js +3 -0
  52. package/dist/cdk/vpc.d.ts +59 -0
  53. package/dist/cdk/vpc.d.ts.map +1 -0
  54. package/dist/cdk/vpc.js +298 -0
  55. package/dist/cdk/vpc.test.d.ts +2 -0
  56. package/dist/cdk/vpc.test.d.ts.map +1 -0
  57. package/dist/cdk/vpc.test.js +285 -0
  58. package/dist/client/index.d.ts +1 -1
  59. package/dist/client/index.d.ts.map +1 -1
  60. package/dist/client/index.js +1 -1
  61. package/dist/common/config.d.ts +34 -0
  62. package/dist/common/config.d.ts.map +1 -1
  63. package/dist/common/config.js +45 -3
  64. package/dist/common/config.test.js +19 -0
  65. package/dist/common/index.d.ts +8 -0
  66. package/dist/common/index.d.ts.map +1 -1
  67. package/dist/errors.d.ts +21 -0
  68. package/dist/errors.d.ts.map +1 -1
  69. package/dist/errors.js +25 -0
  70. package/dist/hosting.d.ts +9 -0
  71. package/dist/hosting.d.ts.map +1 -1
  72. package/dist/hosting.js +12 -1
  73. package/dist/hosting.test.js +63 -1
  74. package/dist/index.cdk.d.ts +3 -2
  75. package/dist/index.cdk.d.ts.map +1 -1
  76. package/dist/index.cdk.js +2 -2
  77. package/dist/index.d.ts +1 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +1 -1
  80. package/dist/lambda-handler.d.ts +17 -0
  81. package/dist/lambda-handler.d.ts.map +1 -1
  82. package/dist/lambda-handler.js +61 -5
  83. package/dist/lambda-handler.test.js +191 -2
  84. package/dist/raw-route.d.ts +15 -1
  85. package/dist/raw-route.d.ts.map +1 -1
  86. package/dist/raw-route.js +96 -12
  87. package/dist/raw-route.test.js +332 -1
  88. package/dist/scripts/dev-server.d.ts.map +1 -1
  89. package/dist/scripts/dev-server.js +11 -0
  90. package/dist/scripts/extract-ts-types.d.ts.map +1 -1
  91. package/dist/scripts/extract-ts-types.js +107 -23
  92. package/dist/scripts/extract-ts-types.test.js +225 -26
  93. package/dist/scripts/generate-spec.d.ts.map +1 -1
  94. package/dist/scripts/generate-spec.js +14 -5
  95. package/dist/scripts/generate-spec.test.js +93 -0
  96. package/dist/scripts/sandbox-empty-buckets.test.d.ts +2 -0
  97. package/dist/scripts/sandbox-empty-buckets.test.d.ts.map +1 -0
  98. package/dist/scripts/sandbox-empty-buckets.test.js +171 -0
  99. package/dist/scripts/sandbox.d.ts +54 -0
  100. package/dist/scripts/sandbox.d.ts.map +1 -1
  101. package/dist/scripts/sandbox.js +163 -25
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/package.json +10 -1
  105. package/src/api.test.ts +25 -0
  106. package/src/api.ts +39 -0
  107. package/src/bb-utils.ts +3 -0
  108. package/src/cdk/apigateway-account.ts +66 -0
  109. package/src/cdk/blocks-backend.test.ts +168 -60
  110. package/src/cdk/blocks-backend.ts +299 -227
  111. package/src/cdk/blocks-defaults.test.ts +21 -0
  112. package/src/cdk/blocks-defaults.ts +79 -1
  113. package/src/cdk/blocks-stack.test.ts +57 -13
  114. package/src/cdk/compute/compute-registry.ts +45 -0
  115. package/src/cdk/compute/compute.ts +114 -2
  116. package/src/cdk/compute/default-compute-factory.ts +1 -0
  117. package/src/cdk/config-registry.test.ts +149 -0
  118. package/src/cdk/config-registry.ts +92 -34
  119. package/src/cdk/dashboard-registry.ts +68 -0
  120. package/src/cdk/index.ts +427 -256
  121. package/src/cdk/internal.ts +6 -2
  122. package/src/cdk/tracer-registry.ts +54 -0
  123. package/src/cdk/vpc-requirements-registry.ts +63 -0
  124. package/src/cdk/vpc-types.ts +158 -0
  125. package/src/cdk/vpc.test.ts +348 -0
  126. package/src/cdk/vpc.ts +336 -0
  127. package/src/client/index.ts +1 -1
  128. package/src/common/config.test.ts +21 -0
  129. package/src/common/config.ts +47 -3
  130. package/src/common/index.ts +8 -0
  131. package/src/errors.ts +26 -0
  132. package/src/hosting.test.ts +87 -1
  133. package/src/hosting.ts +24 -1
  134. package/src/index.cdk.ts +11 -1
  135. package/src/index.ts +1 -1
  136. package/src/lambda-handler.test.ts +220 -2
  137. package/src/lambda-handler.ts +65 -4
  138. package/src/raw-route.test.ts +427 -1
  139. package/src/raw-route.ts +125 -12
  140. package/src/scripts/dev-server.ts +12 -1
  141. package/src/scripts/extract-ts-types.test.ts +228 -26
  142. package/src/scripts/extract-ts-types.ts +104 -20
  143. package/src/scripts/generate-spec.test.ts +101 -0
  144. package/src/scripts/generate-spec.ts +15 -5
  145. package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
  146. package/src/scripts/sandbox.ts +185 -24
  147. package/src/version.ts +1 -1
@@ -23,8 +23,12 @@
23
23
  * @internal
24
24
  */
25
25
 
26
- export { Compute } from './compute/compute.js';
27
- export type { DefaultComputeFactory } from './compute/default-compute-factory.js';
28
26
  // Reserved `/aws-blocks` path segment, needed by concrete computes (e.g.
29
27
  // LambdaCompute in @aws-blocks/bb-lambda-compute) to build their API route tree.
30
28
  export { BLOCKS_NAMESPACE } from '../constants.js';
29
+ export type { ComputeDashboardSection } from './compute/compute.js';
30
+ export { Compute } from './compute/compute.js';
31
+ // Enumerate the computes registered on a stack — the Dashboard BB's default
32
+ // compute selection resolves through this at finalize.
33
+ export { getComputes } from './compute/compute-registry.js';
34
+ export type { DefaultComputeFactory } from './compute/default-compute-factory.js';
@@ -0,0 +1,54 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import * as cdk from 'aws-cdk-lib';
4
+ import { type IRole, PolicyStatement } from 'aws-cdk-lib/aws-iam';
5
+ import type { Construct } from 'constructs';
6
+ import { getComputes } from './compute/compute-registry.js';
7
+
8
+ const REGISTRY_KEY = Symbol.for('BLOCKS_TRACER_PRESENCE');
9
+
10
+ /**
11
+ * Mark that the app contains a `Tracer`. Tracing is **presence-gated**: a Tracer
12
+ * anywhere in the app means every compute should be traced (X-Ray provisions
13
+ * real, costed infra, so it's off unless the app opts in by creating a Tracer).
14
+ * Multiple Tracers are fine — this just records the boolean. Stored per stack
15
+ * (keyed by a Symbol), like the config/compute registries.
16
+ *
17
+ * @param scope - Any construct in the stack (used to locate the stack).
18
+ */
19
+ export function registerTracer(scope: Construct): void {
20
+ (cdk.Stack.of(scope) as unknown as Record<symbol, boolean>)[REGISTRY_KEY] = true;
21
+ }
22
+
23
+ function hasTracer(stack: cdk.Stack): boolean {
24
+ return (stack as unknown as Record<symbol, boolean | undefined>)[REGISTRY_KEY] === true;
25
+ }
26
+
27
+ /**
28
+ * If the app contains a `Tracer`, enable tracing on **every** compute in the
29
+ * stack and grant X-Ray publish **once** on the shared execution role. Runs at
30
+ * the end of `create()` (after the backend module has imported, so all computes
31
+ * are registered). `Compute.enableTracing()` is idempotent, so this is safe
32
+ * regardless of how many Tracers exist.
33
+ *
34
+ * The IAM grant lives here — at the framework level, once on the shared role —
35
+ * rather than in each compute's `applyTracing()`: every compute assumes the same
36
+ * execution role, so a per-compute grant would add N identical statements. The
37
+ * compute only flips its own tracing mode (e.g. Lambda `TracingConfig: Active`);
38
+ * the permission to publish segments is a single stack-level concern.
39
+ *
40
+ * @param scope - Any construct in the stack (used to locate the stack + computes).
41
+ * @param executionRole - The shared execution role every compute assumes; granted
42
+ * X-Ray publish once when tracing is enabled.
43
+ */
44
+ export function finalizeTracing(scope: Construct, executionRole: IRole): void {
45
+ if (!hasTracer(cdk.Stack.of(scope))) return;
46
+ for (const compute of getComputes(scope)) compute.enableTracing();
47
+ // One grant on the shared role rather than one per traced compute.
48
+ executionRole.addToPrincipalPolicy(
49
+ new PolicyStatement({
50
+ actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'],
51
+ resources: ['*'],
52
+ }),
53
+ );
54
+ }
@@ -0,0 +1,63 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import * as cdk from 'aws-cdk-lib';
5
+ import type { Construct } from 'constructs';
6
+ import type { VpcRequirements } from './vpc-types.js';
7
+
8
+ const REGISTRY_KEY = Symbol.for('BLOCKS_VPC_REQUIREMENTS_REGISTRY');
9
+
10
+ /** A registered requirement plus the fullId of the BB that declared it (for errors). */
11
+ export interface RegisteredVpcRequirement {
12
+ readonly fullId: string;
13
+ readonly requirements: VpcRequirements;
14
+ }
15
+
16
+ /**
17
+ * Get or create the VPC-requirements list for a given stack. Stored on the stack
18
+ * object (keyed by a Symbol), so each stack in a multi-stack synth gets its own —
19
+ * a requirement never leaks into another stack's list. Mirrors the compute and
20
+ * config registries, which scope their state the same way.
21
+ */
22
+ function getRegistry(stack: cdk.Stack): RegisteredVpcRequirement[] {
23
+ let list = (stack as any)[REGISTRY_KEY] as RegisteredVpcRequirement[] | undefined;
24
+ if (!list) {
25
+ list = [];
26
+ (stack as any)[REGISTRY_KEY] = list;
27
+ }
28
+ return list;
29
+ }
30
+
31
+ /**
32
+ * Register a Building Block's VPC requirements on its owning stack. Called from
33
+ * the {@link BuildingBlockScope} base constructor, so every BB self-registers the
34
+ * moment it is constructed — `finalizeVpc` then enumerates them without a separate
35
+ * discovery pass (mirrors how `registerCompute`/`registerConfig` accumulate during
36
+ * the backend import). Because it's driven by the base constructor, a BB **cannot**
37
+ * silently skip declaring its requirements: the constructor won't compile without
38
+ * supplying them (see `BuildingBlockScope`).
39
+ *
40
+ * @param bb - The construct declaring the requirement (used to locate its stack and name it).
41
+ * @param requirements - What the BB needs from the VPC.
42
+ */
43
+ export function registerVpcRequirements(
44
+ bb: Construct & { readonly fullId: string },
45
+ requirements: VpcRequirements,
46
+ ): void {
47
+ getRegistry(cdk.Stack.of(bb)).push({ fullId: bb.fullId, requirements });
48
+ }
49
+
50
+ /**
51
+ * The VPC requirements registered on the stack that owns `scope`, in construction
52
+ * order. Returns an empty array before any BB is constructed.
53
+ *
54
+ * @param scope - Any construct in the stack (used to locate the stack).
55
+ */
56
+ export function getVpcRequirements(scope: Construct): readonly RegisteredVpcRequirement[] {
57
+ return getRegistry(cdk.Stack.of(scope));
58
+ }
59
+
60
+ /** Clear the registry. **For test cleanup only.** */
61
+ export function _resetVpcRequirementsRegistry(stack: cdk.Stack): void {
62
+ (stack as any)[REGISTRY_KEY] = [];
63
+ }
@@ -0,0 +1,158 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import type * as ec2 from 'aws-cdk-lib/aws-ec2';
5
+
6
+ /**
7
+ * A subnet role — the kind of subnet a Building Block needs, expressed as an
8
+ * intent the VPC resolves to a concrete {@link ec2.SubnetSelection}:
9
+ *
10
+ * - `'private-with-egress'` — private subnets with outbound internet access via
11
+ * a NAT gateway. Required by anything that must reach a public AWS endpoint at
12
+ * runtime (e.g. a service with no interface endpoint).
13
+ * - `'isolated'` — private subnets with no internet route at all. Best for
14
+ * resources reached entirely over VPC endpoints or in-VPC (e.g. a database).
15
+ * - `'public'` — subnets with a direct internet gateway route.
16
+ */
17
+ export type SubnetRole = 'private-with-egress' | 'isolated' | 'public';
18
+
19
+ /**
20
+ * Options for VPC integration on BlocksStack / BlocksBackend.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * const vpc = new ec2.Vpc(app, 'AppVpc', { maxAzs: 2, natGateways: 1 });
25
+ * await BlocksStack.create(app, stackName, {
26
+ * backendHandlerPath: join(__dirname, 'index.handler.ts'),
27
+ * backendCDKPath: join(__dirname, 'index.ts'),
28
+ * defaults: { ...BlocksPresets.production, vpc: { network: vpc } },
29
+ * });
30
+ * ```
31
+ */
32
+ export interface BlocksVpcOptions {
33
+ /**
34
+ * The VPC to place Lambdas and VPC-resident resources into.
35
+ * Create this however you like — standard CDK:
36
+ *
37
+ * @example
38
+ * const vpc = new ec2.Vpc(stack, 'AppVpc', { maxAzs: 2, natGateways: 1 });
39
+ * // or
40
+ * const vpc = ec2.Vpc.fromLookup(stack, 'SharedVpc', { vpcId: 'vpc-abc123' });
41
+ */
42
+ network: ec2.IVpc;
43
+
44
+ /**
45
+ * Subnet selection for Lambda and Blocks-managed compute placement.
46
+ *
47
+ * Any subnets you select must belong to {@link network}. This is not
48
+ * validated at synth — CDK can't check it for an imported subnet (which
49
+ * carries no `vpcId`) — so a mismatch surfaces as a CloudFormation error at
50
+ * deploy, not a synth-time failure.
51
+ *
52
+ * @default { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }
53
+ */
54
+ subnets?: ec2.SubnetSelection;
55
+
56
+ /**
57
+ * Whether to auto-provision VPC endpoints based on BB registrations.
58
+ * Set to `false` to disable (e.g., when using a shared VPC that already has endpoints).
59
+ *
60
+ * @default true
61
+ */
62
+ provisionEndpoints?: boolean;
63
+ }
64
+
65
+ /**
66
+ * VPC requirements declared by a Building Block.
67
+ * Supplied to the `BuildingBlockScope` constructor and collected in the central
68
+ * requirements registry; `finalizeVpc` pulls them to provision endpoints, and the
69
+ * lazy VPC uses `requiresVpc` to decide whether to derive a VPC when none was
70
+ * provided.
71
+ */
72
+ export interface VpcRequirements {
73
+ /** Gateway VPC endpoints this BB needs (e.g., S3, DynamoDB). */
74
+ gatewayEndpoints?: ec2.GatewayVpcEndpointAwsService[];
75
+ /** Interface VPC endpoints this BB needs (e.g., SQS, SSM, Secrets Manager). */
76
+ interfaceEndpoints?: ec2.InterfaceVpcEndpointAwsService[];
77
+ /**
78
+ * Whether this BB **cannot function without a VPC** — i.e. it must run in (or
79
+ * provision resources into) a VPC regardless of whether the customer asked
80
+ * for one. When any registered requirement sets this and no `vpc` was
81
+ * provided, the framework **lazily derives** a default VPC (see
82
+ * `getOrCreateVpc`) rather than erroring — mirroring how bb-data creates its
83
+ * own VPC when no shared one exists.
84
+ *
85
+ * Most BBs leave this unset: they reach AWS services from the AWS-managed
86
+ * Lambda network and only *benefit* from a VPC (endpoints) when one is present.
87
+ * Set it only for a BB whose resources are intrinsically VPC-resident.
88
+ *
89
+ * @default false
90
+ */
91
+ requiresVpc?: boolean;
92
+ /**
93
+ * Whether the BB's **parent runtime** — the shared Blocks handler Lambda (or,
94
+ * in future, a container) that executes this BB's operations — must be able to
95
+ * reach the internet (outbound egress) for the BB to work at runtime.
96
+ *
97
+ * Set this when your BB's runtime code calls a service it can only reach over
98
+ * the public internet. The canonical case: a service with no VPC endpoint
99
+ * (e.g. Aurora DSQL) is reached over a public HTTPS endpoint, so the runtime
100
+ * must sit in a subnet with an egress route; if it's placed in isolated
101
+ * subnets the deploy still succeeds but every call times out at runtime.
102
+ *
103
+ * The framework **validates** this at synth against the runtime's resolved
104
+ * placement (egress is satisfied by a `private-with-egress` or `public`
105
+ * selection) and fails the build with an actionable message on a mismatch. It
106
+ * never moves the runtime — reassigning a customer's explicit placement is not
107
+ * a BB's responsibility, so an unsatisfiable requirement is an error, not a
108
+ * silent relocation.
109
+ *
110
+ * This constrains the BB's **host**. To place compute the BB provisions
111
+ * **itself** (e.g. an Aurora cluster), resolve a subnet inline in your
112
+ * constructor via {@link VpcContext.selectSubnets} instead.
113
+ *
114
+ * @default false
115
+ */
116
+ requiresEgress?: boolean;
117
+ }
118
+
119
+ /**
120
+ * The minimal shape {@link VpcContext.selectSubnets} needs from a Building
121
+ * Block to produce an instructive, BB-named error — just its `fullId`. Typed
122
+ * structurally so `vpc-types.ts` stays type-only and free of a dependency on
123
+ * the `Scope` class.
124
+ */
125
+ export interface SubnetScope {
126
+ readonly fullId: string;
127
+ }
128
+
129
+ /**
130
+ * Internal VPC context propagated through the construct tree.
131
+ * Set by the CDK-level VPC option. BBs read this to determine their VPC placement.
132
+ * @internal
133
+ */
134
+ export interface VpcContext {
135
+ readonly vpc: ec2.IVpc;
136
+ readonly computeSecurityGroup: ec2.ISecurityGroup;
137
+ readonly computeSubnets: ec2.SubnetSelection;
138
+ /**
139
+ * Resolve a {@link SubnetRole} to a concrete {@link ec2.SubnetSelection} for a
140
+ * resource this Building Block provisions itself, verifying the VPC actually
141
+ * has subnets of that role.
142
+ *
143
+ * Prefer this over building an `ec2.SubnetSelection` by hand: if the VPC has
144
+ * no matching subnet, it throws an actionable, BB-named error at synth
145
+ * ("`KVStore 'app/cache'` needs an 'isolated' subnet, but VPC 'vpc-…' has
146
+ * none …") instead of the opaque CDK "no subnet groups" error thrown later.
147
+ *
148
+ * @param scope the BB requesting the subnet (its `fullId` names the error)
149
+ * @param role the subnet role the BB's own resource needs
150
+ * @param opts.fallback an alternate role to use when `role` is absent from
151
+ * the VPC. Provide it to **explicitly** allow graceful degradation (e.g.
152
+ * Aurora over the Data API works from `'private-with-egress'` when there is
153
+ * no isolated tier); omit it to require `role` strictly and fail otherwise.
154
+ * The downgrade is never silent — it only happens when you opt in here.
155
+ * @throws if the VPC has neither `role` nor (when given) `opts.fallback`
156
+ */
157
+ selectSubnets(scope: SubnetScope, role: SubnetRole, opts?: { fallback?: SubnetRole }): ec2.SubnetSelection;
158
+ }
@@ -0,0 +1,348 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import * as assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+ import * as cdk from 'aws-cdk-lib';
7
+ import { Template } from 'aws-cdk-lib/assertions';
8
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
9
+ import { Construct } from 'constructs';
10
+ import {
11
+ anyRequirementNeedsVpc,
12
+ finalizeVpc,
13
+ getOrCreateVpc,
14
+ getVpcContext,
15
+ initializeVpc,
16
+ setVpcContext,
17
+ } from './vpc.js';
18
+ import { registerVpcRequirements } from './vpc-requirements-registry.js';
19
+ import type { BlocksVpcOptions, VpcRequirements } from './vpc-types.js';
20
+
21
+ // A minimal stand-in for a BuildingBlockScope: finalizeVpc reads requirements
22
+ // from the central registry, and the real BuildingBlockScope base registers in
23
+ // its constructor. This fake does the same (register on construction) so it
24
+ // exercises the real pull-and-provision path without pulling every BB package
25
+ // into core's test graph. A `fullId` getter mirrors the real Scope so error
26
+ // messages that name the BB can be asserted.
27
+ class FakeBB extends Construct {
28
+ constructor(scope: Construct, id: string, reqs: VpcRequirements) {
29
+ super(scope, id);
30
+ registerVpcRequirements(this, reqs);
31
+ }
32
+ get fullId(): string {
33
+ return this.node.id;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Build a stack + VPC, optionally initialize the VPC context (so the endpoint
39
+ * SG can scope to the Lambda SG), add fake BBs, then run finalizeVpc.
40
+ */
41
+ function synthWith(
42
+ bbs: (scope: Construct) => void,
43
+ opts?: Partial<BlocksVpcOptions> & { natGateways?: number; withContext?: boolean },
44
+ ): Template {
45
+ const app = new cdk.App();
46
+ const stack = new cdk.Stack(app, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' } });
47
+ const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2, natGateways: opts?.natGateways ?? 1 });
48
+ const options: BlocksVpcOptions = {
49
+ network: vpc,
50
+ subnets: opts?.subnets,
51
+ provisionEndpoints: opts?.provisionEndpoints,
52
+ };
53
+ if (opts?.withContext !== false) initializeVpc(stack, options);
54
+ bbs(stack);
55
+ finalizeVpc(stack, options);
56
+ return Template.fromStack(stack);
57
+ }
58
+
59
+ const IFACE = 'AWS::EC2::VPCEndpoint';
60
+
61
+ /** Count interface vs gateway endpoints in a synthesized template. */
62
+ function endpointCounts(template: Template): { iface: number; gateway: number } {
63
+ const eps = template.findResources(IFACE);
64
+ let iface = 0;
65
+ let gateway = 0;
66
+ for (const ep of Object.values(eps)) {
67
+ const type = (ep as { Properties?: { VpcEndpointType?: string } }).Properties?.VpcEndpointType;
68
+ // Interface endpoints set VpcEndpointType: 'Interface'; gateway endpoints
69
+ // default the property (Gateway) and omit it in the synthesized template.
70
+ if (type === 'Interface') iface += 1;
71
+ else gateway += 1;
72
+ }
73
+ return { iface, gateway };
74
+ }
75
+
76
+ describe('finalizeVpc — endpoint provisioning', () => {
77
+ it('always provisions CloudWatch Logs (interface) and S3 (gateway), even with no BBs', () => {
78
+ const template = synthWith(() => {});
79
+ const { iface, gateway } = endpointCounts(template);
80
+ assert.equal(gateway, 1); // S3 gateway, always-on (free)
81
+ assert.equal(iface, 1); // Logs, always-on
82
+ template.hasResourceProperties(IFACE, {
83
+ VpcEndpointType: 'Interface',
84
+ ServiceName: 'com.amazonaws.us-east-1.logs',
85
+ });
86
+ // Exactly one gateway endpoint (S3). Its ServiceName synthesizes as a
87
+ // region-tokenized Fn::Join, so assert by type/count rather than a literal.
88
+ const gateways = template.findResources(IFACE, { Properties: { VpcEndpointType: 'Gateway' } });
89
+ assert.equal(Object.keys(gateways).length, 1, 'exactly one gateway endpoint (S3)');
90
+ // SSM is NOT always-on — it flows from BB requirements only.
91
+ const ssm = template.findResources(IFACE, {
92
+ Properties: { ServiceName: 'com.amazonaws.us-east-1.ssm' },
93
+ });
94
+ assert.equal(Object.keys(ssm).length, 0, 'no SSM endpoint without a BB that needs it');
95
+ });
96
+
97
+ it('provisions the union of BB-declared endpoints plus the always-on Logs + S3', () => {
98
+ const template = synthWith((scope) => {
99
+ new FakeBB(scope, 'Kv', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] });
100
+ new FakeBB(scope, 'Bucket', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.S3] });
101
+ new FakeBB(scope, 'Job', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SQS] });
102
+ new FakeBB(scope, 'Db', {
103
+ interfaceEndpoints: [
104
+ ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
105
+ ec2.InterfaceVpcEndpointAwsService.RDS_DATA,
106
+ ],
107
+ });
108
+ });
109
+ const { iface, gateway } = endpointCounts(template);
110
+ // Gateways: DynamoDB + S3 (BB's S3 dedups with the always-on S3).
111
+ assert.equal(gateway, 2);
112
+ // Interfaces: SQS + Secrets Manager + RDS Data + always-on Logs.
113
+ assert.equal(iface, 4);
114
+ });
115
+
116
+ it('deduplicates endpoints requested by multiple BBs', () => {
117
+ const template = synthWith((scope) => {
118
+ new FakeBB(scope, 'Kv1', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] });
119
+ new FakeBB(scope, 'Kv2', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] });
120
+ new FakeBB(scope, 'Job1', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SQS] });
121
+ new FakeBB(scope, 'Job2', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SQS] });
122
+ });
123
+ const { iface, gateway } = endpointCounts(template);
124
+ // Two DynamoDB requests collapse to one; plus always-on S3 = 2 gateways.
125
+ assert.equal(gateway, 2);
126
+ // Two SQS requests collapse to one; plus always-on Logs = 2 interfaces.
127
+ assert.equal(iface, 2);
128
+ });
129
+
130
+ it('provisions SSM only when a BB declares it (and dedups multiple declarations)', () => {
131
+ const template = synthWith((scope) => {
132
+ new FakeBB(scope, 'AppSetting', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SSM] });
133
+ new FakeBB(scope, 'Auth', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SSM] });
134
+ });
135
+ const { iface } = endpointCounts(template);
136
+ // Two SSM requests collapse to one; plus always-on Logs = 2 interfaces.
137
+ assert.equal(iface, 2);
138
+ template.hasResourceProperties(IFACE, {
139
+ VpcEndpointType: 'Interface',
140
+ ServiceName: 'com.amazonaws.us-east-1.ssm',
141
+ });
142
+ });
143
+
144
+ it('provisions nothing when provisionEndpoints is false (but still validates requiresEgress)', () => {
145
+ const template = synthWith(
146
+ (scope) => {
147
+ new FakeBB(scope, 'Kv', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] });
148
+ },
149
+ { provisionEndpoints: false },
150
+ );
151
+ template.resourceCountIs(IFACE, 0);
152
+ });
153
+
154
+ it('scopes the interface-endpoint SG to 443 from the Blocks Lambda SG only', () => {
155
+ const template = synthWith((scope) => {
156
+ new FakeBB(scope, 'Job', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SQS] });
157
+ });
158
+ // A dedicated endpoint SG exists, described as Lambda-scoped, with a 443
159
+ // ingress rule sourced from another SG (the Lambda SG) rather than the whole
160
+ // VPC CIDR. We assert the rule is present (arrayWith) rather than the exact
161
+ // array, since CDK may add its own paired rule for the endpoint association.
162
+ const sgs = template.findResources('AWS::EC2::SecurityGroup', {
163
+ Properties: { GroupDescription: 'Blocks interface VPC endpoints — 443 from the Blocks Lambda only' },
164
+ });
165
+ assert.equal(Object.keys(sgs).length, 1, 'exactly one dedicated endpoint SG');
166
+ const sg = Object.values(sgs)[0] as {
167
+ Properties: { SecurityGroupIngress: Array<{ FromPort?: number; ToPort?: number; CidrIp?: string }> };
168
+ };
169
+ const ingress = sg.Properties.SecurityGroupIngress ?? [];
170
+ const https = ingress.filter((r) => r.FromPort === 443 && r.ToPort === 443);
171
+ assert.equal(https.length, 1, 'exactly one 443 ingress rule');
172
+ // Must NOT be open to a CIDR (that would be the broad default we're avoiding).
173
+ assert.equal(https[0].CidrIp, undefined, '443 ingress is SG-sourced, not CIDR-open');
174
+ });
175
+ });
176
+
177
+ describe('finalizeVpc — requiresEgress validation', () => {
178
+ it('passes when the runtime placement provides egress', () => {
179
+ // Default placement is private-with-egress, which provides egress.
180
+ assert.doesNotThrow(() =>
181
+ synthWith((scope) => {
182
+ new FakeBB(scope, 'Dsql', { requiresEgress: true });
183
+ }),
184
+ );
185
+ });
186
+
187
+ it('throws an actionable, BB-named error when the placement has no egress', () => {
188
+ // Place the runtime in isolated subnets; a BB needing egress cannot be served.
189
+ assert.throws(
190
+ () =>
191
+ synthWith(
192
+ (scope) => {
193
+ new FakeBB(scope, 'Dsql', { requiresEgress: true });
194
+ },
195
+ { subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED } },
196
+ ),
197
+ /Dsql requires its runtime to reach the internet.*no egress/s,
198
+ );
199
+ });
200
+
201
+ it('does not validate a BB that does not require egress', () => {
202
+ // A BB with no egress need is fine in isolated subnets.
203
+ assert.doesNotThrow(() =>
204
+ synthWith(
205
+ (scope) => {
206
+ new FakeBB(scope, 'Bb', {});
207
+ },
208
+ { subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED } },
209
+ ),
210
+ );
211
+ });
212
+ });
213
+
214
+ describe('initializeVpc — Lambda placement guard', () => {
215
+ function init(natGateways: number, subnets?: ec2.SubnetSelection) {
216
+ const app = new cdk.App();
217
+ const stack = new cdk.Stack(app, 'S', { env: { account: '123456789012', region: 'us-east-1' } });
218
+ const subnetConfiguration =
219
+ natGateways === 0
220
+ ? [{ name: 'isolated', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 }]
221
+ : undefined;
222
+ const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2, natGateways, subnetConfiguration });
223
+ return () => initializeVpc(stack, { network: vpc, subnets });
224
+ }
225
+
226
+ it('throws an actionable error when defaulting to private-with-egress but the VPC has none', () => {
227
+ assert.throws(init(0), /no private-with-egress subnets.*NAT gateway/s);
228
+ });
229
+
230
+ it('does not throw when the VPC has a private-with-egress tier', () => {
231
+ assert.doesNotThrow(init(1));
232
+ });
233
+
234
+ it('honors an explicit isolated placement without the private-with-egress guard', () => {
235
+ // Caller explicitly chose isolated; the default-path guard must not fire.
236
+ assert.doesNotThrow(init(0, { subnetType: ec2.SubnetType.PRIVATE_ISOLATED }));
237
+ });
238
+ });
239
+
240
+ describe('VpcContext.selectSubnets — instructive resolution', () => {
241
+ function contextFor(natGateways: number) {
242
+ const app = new cdk.App();
243
+ const stack = new cdk.Stack(app, 'S', { env: { account: '123456789012', region: 'us-east-1' } });
244
+ const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2, natGateways });
245
+ // With natGateways: 0 CDK still creates the default public+private config,
246
+ // but the private tier becomes isolated (no NAT). Build an explicit
247
+ // isolated-only VPC to exercise the "no isolated" and "no egress" cases.
248
+ return initializeVpc(stack, { network: vpc, subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS } });
249
+ }
250
+
251
+ it('returns the requested role when the VPC has it', () => {
252
+ const ctx = contextFor(1);
253
+ const sel = ctx.selectSubnets({ fullId: 'app/db' }, 'private-with-egress');
254
+ assert.equal(sel.subnetType, ec2.SubnetType.PRIVATE_WITH_EGRESS);
255
+ });
256
+
257
+ it('falls back to an explicit fallback role when the requested role is absent', () => {
258
+ const ctx = contextFor(1); // has public + private-with-egress, no isolated
259
+ const sel = ctx.selectSubnets({ fullId: 'app/db' }, 'isolated', { fallback: 'private-with-egress' });
260
+ assert.equal(sel.subnetType, ec2.SubnetType.PRIVATE_WITH_EGRESS);
261
+ });
262
+
263
+ it('throws a BB-named error when neither the role nor its fallback exists', () => {
264
+ const ctx = contextFor(1); // no isolated tier, and no public fallback requested that helps
265
+ assert.throws(
266
+ () => ctx.selectSubnets({ fullId: 'app/db' }, 'isolated'),
267
+ /app\/db needs a 'isolated' subnet.*has none/s,
268
+ );
269
+ });
270
+ });
271
+
272
+ // We can't fully test CDK constructs without a Stack, but we can test
273
+ // the getVpcContext logic with mock constructs.
274
+
275
+ describe('VPC utilities', () => {
276
+ it('getVpcContext returns undefined when no context set', () => {
277
+ const fakeScope: any = { node: { scope: undefined } };
278
+ assert.equal(getVpcContext(fakeScope), undefined);
279
+ });
280
+
281
+ it('getVpcContext walks up the scope tree', () => {
282
+ const vpcContext = { vpc: 'mock-vpc', computeSecurityGroup: 'mock-sg', computeSubnets: {} };
283
+ const parent: any = { node: { scope: undefined } };
284
+ setVpcContext(parent, vpcContext as any);
285
+
286
+ const child: any = { node: { scope: parent } };
287
+ assert.equal(getVpcContext(child), vpcContext);
288
+ });
289
+
290
+ it('setVpcContext stores context on the scope', () => {
291
+ const fakeScope: any = { node: { scope: undefined } };
292
+ const vpcContext = { vpc: 'mock-vpc', computeSecurityGroup: 'mock-sg', computeSubnets: {} };
293
+ setVpcContext(fakeScope, vpcContext as any);
294
+ assert.equal(getVpcContext(fakeScope), vpcContext);
295
+ });
296
+ });
297
+
298
+ describe('lazy VPC derivation', () => {
299
+ function stackWith(bbs: (scope: Construct) => void): { stack: cdk.Stack; scope: Construct } {
300
+ const app = new cdk.App();
301
+ const stack = new cdk.Stack(app, 'LazyStack', { env: { account: '123456789012', region: 'us-east-1' } });
302
+ bbs(stack);
303
+ return { stack, scope: stack };
304
+ }
305
+
306
+ it('anyRequirementNeedsVpc is false for BBs that only declare endpoints', () => {
307
+ const { scope } = stackWith((s) => {
308
+ new FakeBB(s, 'Kv', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] });
309
+ new FakeBB(s, 'Job', { interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SQS] });
310
+ });
311
+ assert.equal(anyRequirementNeedsVpc(scope), false);
312
+ });
313
+
314
+ it('anyRequirementNeedsVpc is true when a BB declares requiresVpc', () => {
315
+ const { scope } = stackWith((s) => {
316
+ new FakeBB(s, 'Kv', { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] });
317
+ new FakeBB(s, 'Container', { requiresVpc: true });
318
+ });
319
+ assert.equal(anyRequirementNeedsVpc(scope), true);
320
+ });
321
+
322
+ it('getOrCreateVpc returns the same VPC on repeated calls (singleton)', () => {
323
+ const { scope } = stackWith(() => {});
324
+ const a = getOrCreateVpc(scope);
325
+ const b = getOrCreateVpc(scope);
326
+ assert.equal(a, b, 'lazy VPC is a per-stack singleton');
327
+ });
328
+
329
+ it('a derived VPC + finalizeVpc provisions endpoints for a requiresVpc app', () => {
330
+ const app = new cdk.App();
331
+ const stack = new cdk.Stack(app, 'DerivedStack', { env: { account: '123456789012', region: 'us-east-1' } });
332
+ new FakeBB(stack, 'Container', {
333
+ requiresVpc: true,
334
+ interfaceEndpoints: [ec2.InterfaceVpcEndpointAwsService.SQS],
335
+ });
336
+ assert.equal(anyRequirementNeedsVpc(stack), true);
337
+ const vpc = getOrCreateVpc(stack);
338
+ const options = { network: vpc };
339
+ initializeVpc(stack, options);
340
+ finalizeVpc(stack, options);
341
+ const template = Template.fromStack(stack);
342
+ // SQS (from the BB) + always-on CloudWatch Logs interface endpoints, plus
343
+ // the always-on S3 gateway endpoint.
344
+ const { iface, gateway } = endpointCounts(template);
345
+ assert.equal(iface, 2, 'SQS + Logs');
346
+ assert.equal(gateway, 1, 'S3 gateway');
347
+ });
348
+ });