@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.
- package/README.md +25 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +32 -0
- package/dist/api.test.js +22 -0
- package/dist/bb-utils.d.ts +1 -0
- package/dist/bb-utils.d.ts.map +1 -1
- package/dist/bb-utils.js +3 -0
- package/dist/cdk/apigateway-account.d.ts +33 -0
- package/dist/cdk/apigateway-account.d.ts.map +1 -0
- package/dist/cdk/apigateway-account.js +60 -0
- package/dist/cdk/blocks-backend.d.ts +11 -0
- package/dist/cdk/blocks-backend.d.ts.map +1 -1
- package/dist/cdk/blocks-backend.js +72 -13
- package/dist/cdk/blocks-backend.test.js +88 -1
- package/dist/cdk/blocks-defaults.d.ts +76 -1
- package/dist/cdk/blocks-defaults.d.ts.map +1 -1
- package/dist/cdk/blocks-defaults.js +17 -1
- package/dist/cdk/blocks-defaults.test.js +17 -0
- package/dist/cdk/blocks-stack.test.js +44 -3
- package/dist/cdk/compute/compute-registry.d.ts +19 -0
- package/dist/cdk/compute/compute-registry.d.ts.map +1 -0
- package/dist/cdk/compute/compute-registry.js +38 -0
- package/dist/cdk/compute/compute.d.ts +82 -2
- package/dist/cdk/compute/compute.d.ts.map +1 -1
- package/dist/cdk/compute/compute.js +64 -2
- package/dist/cdk/compute/default-compute-factory.d.ts +1 -0
- package/dist/cdk/compute/default-compute-factory.d.ts.map +1 -1
- package/dist/cdk/config-registry.d.ts +34 -4
- package/dist/cdk/config-registry.d.ts.map +1 -1
- package/dist/cdk/config-registry.js +83 -25
- package/dist/cdk/config-registry.test.d.ts +2 -0
- package/dist/cdk/config-registry.test.d.ts.map +1 -0
- package/dist/cdk/config-registry.test.js +127 -0
- package/dist/cdk/dashboard-registry.d.ts +41 -0
- package/dist/cdk/dashboard-registry.d.ts.map +1 -0
- package/dist/cdk/dashboard-registry.js +61 -0
- package/dist/cdk/index.d.ts +90 -6
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +135 -13
- package/dist/cdk/internal.d.ts +3 -1
- package/dist/cdk/internal.d.ts.map +1 -1
- package/dist/cdk/internal.js +4 -1
- package/dist/cdk/tracer-registry.d.ts +31 -0
- package/dist/cdk/tracer-registry.d.ts.map +1 -0
- package/dist/cdk/tracer-registry.js +49 -0
- package/dist/cdk/vpc-requirements-registry.d.ts +33 -0
- package/dist/cdk/vpc-requirements-registry.d.ts.map +1 -0
- package/dist/cdk/vpc-requirements-registry.js +46 -0
- package/dist/cdk/vpc-types.d.ts +151 -0
- package/dist/cdk/vpc-types.d.ts.map +1 -0
- package/dist/cdk/vpc-types.js +3 -0
- package/dist/cdk/vpc.d.ts +59 -0
- package/dist/cdk/vpc.d.ts.map +1 -0
- package/dist/cdk/vpc.js +298 -0
- package/dist/cdk/vpc.test.d.ts +2 -0
- package/dist/cdk/vpc.test.d.ts.map +1 -0
- package/dist/cdk/vpc.test.js +285 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/common/config.d.ts +34 -0
- package/dist/common/config.d.ts.map +1 -1
- package/dist/common/config.js +45 -3
- package/dist/common/config.test.js +19 -0
- package/dist/common/index.d.ts +8 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +25 -0
- package/dist/hosting.d.ts +9 -0
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +12 -1
- package/dist/hosting.test.js +63 -1
- package/dist/index.cdk.d.ts +3 -2
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lambda-handler.d.ts +17 -0
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +61 -5
- package/dist/lambda-handler.test.js +191 -2
- package/dist/raw-route.d.ts +15 -1
- package/dist/raw-route.d.ts.map +1 -1
- package/dist/raw-route.js +96 -12
- package/dist/raw-route.test.js +332 -1
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +11 -0
- package/dist/scripts/extract-ts-types.d.ts.map +1 -1
- package/dist/scripts/extract-ts-types.js +107 -23
- package/dist/scripts/extract-ts-types.test.js +225 -26
- package/dist/scripts/generate-spec.d.ts.map +1 -1
- package/dist/scripts/generate-spec.js +14 -5
- package/dist/scripts/generate-spec.test.js +93 -0
- package/dist/scripts/sandbox-empty-buckets.test.d.ts +2 -0
- package/dist/scripts/sandbox-empty-buckets.test.d.ts.map +1 -0
- package/dist/scripts/sandbox-empty-buckets.test.js +171 -0
- package/dist/scripts/sandbox.d.ts +54 -0
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +163 -25
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -1
- package/src/api.test.ts +25 -0
- package/src/api.ts +39 -0
- package/src/bb-utils.ts +3 -0
- package/src/cdk/apigateway-account.ts +66 -0
- package/src/cdk/blocks-backend.test.ts +168 -60
- package/src/cdk/blocks-backend.ts +299 -227
- package/src/cdk/blocks-defaults.test.ts +21 -0
- package/src/cdk/blocks-defaults.ts +79 -1
- package/src/cdk/blocks-stack.test.ts +57 -13
- package/src/cdk/compute/compute-registry.ts +45 -0
- package/src/cdk/compute/compute.ts +114 -2
- package/src/cdk/compute/default-compute-factory.ts +1 -0
- package/src/cdk/config-registry.test.ts +149 -0
- package/src/cdk/config-registry.ts +92 -34
- package/src/cdk/dashboard-registry.ts +68 -0
- package/src/cdk/index.ts +427 -256
- package/src/cdk/internal.ts +6 -2
- package/src/cdk/tracer-registry.ts +54 -0
- package/src/cdk/vpc-requirements-registry.ts +63 -0
- package/src/cdk/vpc-types.ts +158 -0
- package/src/cdk/vpc.test.ts +348 -0
- package/src/cdk/vpc.ts +336 -0
- package/src/client/index.ts +1 -1
- package/src/common/config.test.ts +21 -0
- package/src/common/config.ts +47 -3
- package/src/common/index.ts +8 -0
- package/src/errors.ts +26 -0
- package/src/hosting.test.ts +87 -1
- package/src/hosting.ts +24 -1
- package/src/index.cdk.ts +11 -1
- package/src/index.ts +1 -1
- package/src/lambda-handler.test.ts +220 -2
- package/src/lambda-handler.ts +65 -4
- package/src/raw-route.test.ts +427 -1
- package/src/raw-route.ts +125 -12
- package/src/scripts/dev-server.ts +12 -1
- package/src/scripts/extract-ts-types.test.ts +228 -26
- package/src/scripts/extract-ts-types.ts +104 -20
- package/src/scripts/generate-spec.test.ts +101 -0
- package/src/scripts/generate-spec.ts +15 -5
- package/src/scripts/sandbox-empty-buckets.test.ts +191 -0
- package/src/scripts/sandbox.ts +185 -24
- package/src/version.ts +1 -1
package/src/cdk/vpc.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { Annotations } from 'aws-cdk-lib';
|
|
5
|
+
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
6
|
+
import type { Construct } from 'constructs';
|
|
7
|
+
import { getVpcRequirements as getRegisteredVpcRequirements } from './vpc-requirements-registry.js';
|
|
8
|
+
import type { BlocksVpcOptions, SubnetRole, SubnetScope, VpcContext, VpcRequirements } from './vpc-types.js';
|
|
9
|
+
|
|
10
|
+
const VPC_CONTEXT_KEY = Symbol.for('BLOCKS_VPC_CONTEXT');
|
|
11
|
+
|
|
12
|
+
/** Map a subnet role to its concrete CDK subnet type. */
|
|
13
|
+
function subnetTypeForRole(role: SubnetRole): ec2.SubnetType {
|
|
14
|
+
switch (role) {
|
|
15
|
+
case 'isolated':
|
|
16
|
+
return ec2.SubnetType.PRIVATE_ISOLATED;
|
|
17
|
+
case 'public':
|
|
18
|
+
return ec2.SubnetType.PUBLIC;
|
|
19
|
+
case 'private-with-egress':
|
|
20
|
+
return ec2.SubnetType.PRIVATE_WITH_EGRESS;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Does the VPC actually contain at least one subnet of the given role? */
|
|
25
|
+
function vpcHasRole(vpc: ec2.IVpc, role: SubnetRole): boolean {
|
|
26
|
+
switch (role) {
|
|
27
|
+
case 'isolated':
|
|
28
|
+
return vpc.isolatedSubnets.length > 0;
|
|
29
|
+
case 'public':
|
|
30
|
+
return vpc.publicSubnets.length > 0;
|
|
31
|
+
case 'private-with-egress':
|
|
32
|
+
return vpc.privateSubnets.length > 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Set the VPC context on a scope (BlocksStack or BlocksBackend).
|
|
38
|
+
* Called during stack creation when `vpc` prop is provided.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export function setVpcContext(scope: Construct, context: VpcContext): void {
|
|
42
|
+
(scope as any)[VPC_CONTEXT_KEY] = context;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the VPC context from a scope by walking up the construct tree.
|
|
47
|
+
* Used by BBs (e.g., bb-data) to discover the shared VPC.
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export function getVpcContext(scope: Construct): VpcContext | undefined {
|
|
51
|
+
let current: Construct | undefined = scope;
|
|
52
|
+
while (current) {
|
|
53
|
+
const ctx = (current as any)[VPC_CONTEXT_KEY] as VpcContext | undefined;
|
|
54
|
+
if (ctx) return ctx;
|
|
55
|
+
current = current.node.scope as Construct | undefined;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const LAZY_VPC_KEY = Symbol.for('BLOCKS_LAZY_VPC');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Does any registered Building Block *require* a VPC (i.e. cannot function
|
|
64
|
+
* without one)? Used to decide whether to lazily derive a VPC when the customer
|
|
65
|
+
* didn't provide one. A BB that merely benefits from endpoints does NOT count —
|
|
66
|
+
* only `requiresVpc: true`.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export function anyRequirementNeedsVpc(scope: Construct): boolean {
|
|
70
|
+
return getRegisteredVpcRequirements(scope).some((r) => r.requirements.requiresVpc === true);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the framework-owned VPC for a stack, creating one on first use.
|
|
75
|
+
*
|
|
76
|
+
* VPC is a **derived** resource: when a Building Block (or, later, a container
|
|
77
|
+
* compute) requires one and the customer didn't bring their own, Blocks
|
|
78
|
+
* materializes a sensible default here — the same create-if-absent pattern
|
|
79
|
+
* `bb-data` uses for Aurora, lifted to the framework so the whole app shares a
|
|
80
|
+
* single VPC. Keyed by a Symbol on the stack so it's a true singleton: the first
|
|
81
|
+
* caller creates, everyone else reuses. Passing a customer VPC (`vpc:` prop)
|
|
82
|
+
* pre-seeds this via {@link initializeVpc}, so this default is only built when
|
|
83
|
+
* nothing was provided.
|
|
84
|
+
*
|
|
85
|
+
* The default has both a `private-with-egress` tier (for a runtime that needs
|
|
86
|
+
* outbound access — the derivation trigger) and public subnets for the NAT
|
|
87
|
+
* gateway. NAT has a real cost, so this is only created on genuine need, and
|
|
88
|
+
* `create()` emits a notice when it does.
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
export function getOrCreateVpc(scope: Construct): ec2.IVpc {
|
|
92
|
+
const holder = scope as any;
|
|
93
|
+
const existing = holder[LAZY_VPC_KEY] as ec2.IVpc | undefined;
|
|
94
|
+
if (existing) return existing;
|
|
95
|
+
const vpc = new ec2.Vpc(scope, 'BlocksVpc', { maxAzs: 2, natGateways: 1 });
|
|
96
|
+
holder[LAZY_VPC_KEY] = vpc;
|
|
97
|
+
return vpc;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Initialize VPC support on a Blocks scope (BlocksStack or BlocksBackend).
|
|
102
|
+
* Creates the security group, sets VPC context, and returns the VpcContext
|
|
103
|
+
* that is used for Lambda placement configuration.
|
|
104
|
+
*
|
|
105
|
+
* Called during setupBlocksInfra when `vpc` prop is present.
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export function initializeVpc(scope: Construct, options: BlocksVpcOptions): VpcContext {
|
|
109
|
+
const { network: vpc, subnets } = options;
|
|
110
|
+
|
|
111
|
+
// Default Lambda placement to private-with-egress. Guard the default: if the
|
|
112
|
+
// caller didn't specify `subnets` and the VPC has no such tier, fail now with
|
|
113
|
+
// an actionable message instead of letting Lambda placement throw a cryptic
|
|
114
|
+
// CDK error later. (A caller who *explicitly* passes an isolated selection is
|
|
115
|
+
// honored — per-BB `requiresEgress` validation catches BBs that can't run there.)
|
|
116
|
+
if (!subnets && !vpcHasRole(vpc, 'private-with-egress')) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`VPC '${vpc.vpcId}' has no private-with-egress subnets, which Blocks uses for Lambda ` +
|
|
119
|
+
`placement by default. Add a PRIVATE_WITH_EGRESS subnet tier (a private subnet with a ` +
|
|
120
|
+
`NAT gateway), or pass 'vpc.subnets' to choose a different placement explicitly. ` +
|
|
121
|
+
`See packages/blocks/VPC.md.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const resolvedSubnets = subnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
|
|
126
|
+
|
|
127
|
+
const computeSecurityGroup = new ec2.SecurityGroup(scope, 'BlocksLambdaSg', {
|
|
128
|
+
vpc,
|
|
129
|
+
description: 'Security group for Blocks Lambda functions in VPC',
|
|
130
|
+
allowAllOutbound: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const context: VpcContext = {
|
|
134
|
+
vpc,
|
|
135
|
+
computeSecurityGroup,
|
|
136
|
+
computeSubnets: resolvedSubnets,
|
|
137
|
+
selectSubnets(scope: SubnetScope, role: SubnetRole, opts?: { fallback?: SubnetRole }): ec2.SubnetSelection {
|
|
138
|
+
if (vpcHasRole(vpc, role)) {
|
|
139
|
+
return { subnetType: subnetTypeForRole(role) };
|
|
140
|
+
}
|
|
141
|
+
// Requested role is absent. Fall back only if the BB explicitly opted in
|
|
142
|
+
// AND the fallback role actually exists — never downgrade silently.
|
|
143
|
+
if (opts?.fallback && vpcHasRole(vpc, opts.fallback)) {
|
|
144
|
+
return { subnetType: subnetTypeForRole(opts.fallback) };
|
|
145
|
+
}
|
|
146
|
+
const wanted = opts?.fallback ? `'${role}' (or '${opts.fallback}')` : `'${role}'`;
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${scope.fullId} needs a ${wanted} subnet, but VPC '${vpc.vpcId}' has none. ` +
|
|
149
|
+
`Add a matching subnet tier to your VPC (e.g. a 'subnetConfiguration' entry of the ` +
|
|
150
|
+
`required type), or place the Building Block differently. ` +
|
|
151
|
+
`See packages/blocks/VPC.md for guidance.`,
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
setVpcContext(scope, context);
|
|
157
|
+
return context;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Finalize VPC: query all BuildingBlockScope children for their VPC requirements,
|
|
162
|
+
* deduplicate, and provision endpoints.
|
|
163
|
+
* Called after all BBs are constructed (alongside finalizeConfigRegistry).
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
export function finalizeVpc(scope: Construct, options: BlocksVpcOptions): void {
|
|
167
|
+
const { network: vpc, subnets } = options;
|
|
168
|
+
|
|
169
|
+
// Whether the runtime's placement provides egress (outbound internet). This is
|
|
170
|
+
// the only capability the requirement check needs — a BB either needs egress
|
|
171
|
+
// or it doesn't. Resolved from the actual selected subnets rather than guessed
|
|
172
|
+
// from a subnet role, so an explicit subnet *list* (no subnetType) is handled
|
|
173
|
+
// correctly. `undefined` = couldn't determine (e.g. an imported VPC whose
|
|
174
|
+
// subnets aren't known at synth) → we skip validation with a warning rather
|
|
175
|
+
// than fabricate an answer.
|
|
176
|
+
const placementHasEgress: boolean | undefined = placementProvidesEgress(vpc, subnets);
|
|
177
|
+
|
|
178
|
+
const gatewayEndpoints: ec2.GatewayVpcEndpointAwsService[] = [];
|
|
179
|
+
const interfaceEndpoints: ec2.InterfaceVpcEndpointAwsService[] = [];
|
|
180
|
+
|
|
181
|
+
// Pull requirements from the central registry — every BuildingBlockScope
|
|
182
|
+
// self-registers in its constructor, so this is a faithful census without a
|
|
183
|
+
// separate tree walk (mirrors how config/compute registries are consumed).
|
|
184
|
+
for (const { fullId, requirements: reqs } of getRegisteredVpcRequirements(scope)) {
|
|
185
|
+
if (reqs.gatewayEndpoints) {
|
|
186
|
+
gatewayEndpoints.push(...reqs.gatewayEndpoints);
|
|
187
|
+
}
|
|
188
|
+
if (reqs.interfaceEndpoints) {
|
|
189
|
+
interfaceEndpoints.push(...reqs.interfaceEndpoints);
|
|
190
|
+
}
|
|
191
|
+
// Validate the BB's runtime egress need against where the runtime is
|
|
192
|
+
// actually placed. We never move the runtime (that's the customer's
|
|
193
|
+
// explicit choice) — an unsatisfiable requirement is a synth error, not a
|
|
194
|
+
// silent relocation. This turns an otherwise-silent runtime failure (e.g.
|
|
195
|
+
// DSQL in an isolated Lambda: deploys clean, times out on every call) into
|
|
196
|
+
// an actionable build error.
|
|
197
|
+
if (reqs.requiresEgress) {
|
|
198
|
+
if (placementHasEgress === false) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`${fullId} requires its runtime to reach the internet (outbound egress), but the ` +
|
|
201
|
+
`Blocks runtime is placed in subnets with no egress route. ` +
|
|
202
|
+
`Set 'vpc.subnets' to a 'private-with-egress' (or 'public') selection, or remove ` +
|
|
203
|
+
`the Building Block that needs it. See packages/blocks/VPC.md.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (placementHasEgress === undefined) {
|
|
207
|
+
// Couldn't determine egress (e.g. imported VPC with unknown subnets).
|
|
208
|
+
// Don't fabricate a pass/fail — warn so a real mismatch isn't silent.
|
|
209
|
+
Annotations.of(scope).addWarningV2(
|
|
210
|
+
'blocks:vpc:egress-unverified',
|
|
211
|
+
`${fullId} requires runtime egress, but Blocks couldn't determine whether the ` +
|
|
212
|
+
`configured subnets provide it (e.g. an imported VPC). Ensure the runtime's subnets ` +
|
|
213
|
+
`have an outbound internet route. See packages/blocks/VPC.md.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (options.provisionEndpoints === false) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Always add the S3 gateway endpoint. The runtime pulls config/secrets and
|
|
224
|
+
// migrations from S3 at cold start, so an in-VPC runtime needs it. Gateway
|
|
225
|
+
// endpoints are free (route-table entries, no ENI), so this is unconditional.
|
|
226
|
+
gatewayEndpoints.push(ec2.GatewayVpcEndpointAwsService.S3);
|
|
227
|
+
|
|
228
|
+
// Provision gateway endpoints (deduplicated). Gateway endpoints attach to
|
|
229
|
+
// route tables, not ENIs, so they have no security-group layer.
|
|
230
|
+
const provisionedGateway = new Set<string>();
|
|
231
|
+
|
|
232
|
+
for (const service of gatewayEndpoints) {
|
|
233
|
+
const key = service.name;
|
|
234
|
+
if (provisionedGateway.has(key)) continue;
|
|
235
|
+
provisionedGateway.add(key);
|
|
236
|
+
|
|
237
|
+
const constructId = `VpcGw${key.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
238
|
+
new ec2.GatewayVpcEndpoint(scope, constructId, { vpc, service });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Always add CloudWatch Logs — every in-VPC Lambda needs it for log delivery,
|
|
242
|
+
// regardless of which Building Blocks are present. SSM is NOT added here: it
|
|
243
|
+
// flows from BB requirements (AppSetting and the auth blocks, which compose
|
|
244
|
+
// AppSetting, declare it), so an app that uses neither doesn't pay for an
|
|
245
|
+
// unused interface endpoint.
|
|
246
|
+
interfaceEndpoints.push(ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS);
|
|
247
|
+
|
|
248
|
+
// Dedicated security group for the interface endpoints. Without an explicit
|
|
249
|
+
// SG, CDK creates a default that allows 443 from the entire VPC CIDR — on a
|
|
250
|
+
// bring-your-own VPC that exposes every endpoint to unrelated workloads. Scope
|
|
251
|
+
// ingress to just the Blocks Lambda SG so only our functions can reach them.
|
|
252
|
+
const ctx = getVpcContext(scope);
|
|
253
|
+
const endpointSecurityGroup = new ec2.SecurityGroup(scope, 'BlocksVpcEndpointSg', {
|
|
254
|
+
vpc,
|
|
255
|
+
description: 'Blocks interface VPC endpoints — 443 from the Blocks Lambda only',
|
|
256
|
+
allowAllOutbound: true,
|
|
257
|
+
});
|
|
258
|
+
if (ctx) {
|
|
259
|
+
endpointSecurityGroup.addIngressRule(
|
|
260
|
+
ec2.Peer.securityGroupId(ctx.computeSecurityGroup.securityGroupId),
|
|
261
|
+
ec2.Port.tcp(443),
|
|
262
|
+
'HTTPS from Blocks Lambda',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Provision interface endpoints (deduplicated)
|
|
267
|
+
const provisionedInterface = new Set<string>();
|
|
268
|
+
|
|
269
|
+
for (const service of interfaceEndpoints) {
|
|
270
|
+
const key = service.name;
|
|
271
|
+
if (provisionedInterface.has(key)) continue;
|
|
272
|
+
provisionedInterface.add(key);
|
|
273
|
+
|
|
274
|
+
const constructId = `VpcIf${key.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
275
|
+
new ec2.InterfaceVpcEndpoint(scope, constructId, {
|
|
276
|
+
vpc,
|
|
277
|
+
service,
|
|
278
|
+
privateDnsEnabled: true,
|
|
279
|
+
securityGroups: [endpointSecurityGroup],
|
|
280
|
+
// `open: false` suppresses CDK's default "allow 443 from the whole VPC
|
|
281
|
+
// CIDR" ingress rule. Our dedicated SG already allows 443 from just the
|
|
282
|
+
// Blocks Lambda SG; without this, CDK would re-widen access to the entire
|
|
283
|
+
// VPC — the exact broadening this dedicated SG exists to prevent.
|
|
284
|
+
open: false,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Does the runtime's subnet placement provide egress (an outbound internet
|
|
291
|
+
* route)? Returns `true`/`false` when determinable, or `undefined` when it
|
|
292
|
+
* can't be determined at synth (e.g. an imported VPC whose subnets aren't
|
|
293
|
+
* known) — callers should warn rather than assume.
|
|
294
|
+
*
|
|
295
|
+
* Resolved from the actual subnets, not from a subnet *role*, so an explicit
|
|
296
|
+
* subnet **list** (which carries no `subnetType`) is handled correctly. For a
|
|
297
|
+
* multi-subnet selection, egress is only reported when **every** selected
|
|
298
|
+
* subnet has it (a runtime may land in any of them).
|
|
299
|
+
*/
|
|
300
|
+
function placementProvidesEgress(vpc: ec2.IVpc, selection?: ec2.SubnetSelection): boolean | undefined {
|
|
301
|
+
// Default placement (no selection) is PRIVATE_WITH_EGRESS — see initializeVpc.
|
|
302
|
+
if (!selection) {
|
|
303
|
+
return vpc.privateSubnets.length > 0 ? true : undefined;
|
|
304
|
+
}
|
|
305
|
+
// Explicit type: egress iff it's the egress tier; isolated/public are known.
|
|
306
|
+
if (selection.subnetType !== undefined) {
|
|
307
|
+
switch (selection.subnetType) {
|
|
308
|
+
case ec2.SubnetType.PRIVATE_WITH_EGRESS:
|
|
309
|
+
return true;
|
|
310
|
+
case ec2.SubnetType.PUBLIC:
|
|
311
|
+
return true; // public subnets route to an internet gateway
|
|
312
|
+
case ec2.SubnetType.PRIVATE_ISOLATED:
|
|
313
|
+
return false;
|
|
314
|
+
default:
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Explicit subnet list (or filter): resolve the concrete subnets and check
|
|
319
|
+
// that all of them are egress-capable. If resolution yields nothing usable,
|
|
320
|
+
// we can't tell.
|
|
321
|
+
try {
|
|
322
|
+
const { subnets: selected } = vpc.selectSubnets(selection);
|
|
323
|
+
if (selected.length === 0) return undefined;
|
|
324
|
+
// A subnet is egress-capable if it's one of the VPC's private (with-egress)
|
|
325
|
+
// or public subnets. Isolated subnets are in neither list.
|
|
326
|
+
const egressCapable = new Set<string>([
|
|
327
|
+
...vpc.privateSubnets.map((s) => s.subnetId),
|
|
328
|
+
...vpc.publicSubnets.map((s) => s.subnetId),
|
|
329
|
+
]);
|
|
330
|
+
// If the VPC exposes no subnet inventory (imported VPC), we can't classify.
|
|
331
|
+
if (egressCapable.size === 0 && vpc.isolatedSubnets.length === 0) return undefined;
|
|
332
|
+
return selected.every((s) => egressCapable.has(s.subnetId));
|
|
333
|
+
} catch {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -316,5 +316,5 @@ export function ApiNamespaceClient<T extends Record<string, (...args: any[]) =>
|
|
|
316
316
|
});
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
|
|
319
|
+
export { ApiError, blocksError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
|
|
320
320
|
export { Scope, type ScopeOptions } from '../common/index.js';
|
|
@@ -131,6 +131,27 @@ describe('config — S3 errors', () => {
|
|
|
131
131
|
assert.strictEqual(result, '', 'Should return empty string (empty config) on 404');
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
it('does not cache a not-found result — a later call re-fetches once the config exists', async () => {
|
|
135
|
+
process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
|
|
136
|
+
process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
|
|
137
|
+
|
|
138
|
+
// Simulate the transient post-deploy window: the object isn't written yet on the first
|
|
139
|
+
// fetch, then becomes available. A not-found result must NOT be cached, or the container
|
|
140
|
+
// would be poisoned with empty config for its lifetime.
|
|
141
|
+
let call = 0;
|
|
142
|
+
const notFound = new Error('The specified key does not exist.');
|
|
143
|
+
(notFound as any).name = 'NoSuchKey';
|
|
144
|
+
_setS3Fetcher(async () => {
|
|
145
|
+
call++;
|
|
146
|
+
if (call === 1) throw notFound;
|
|
147
|
+
return JSON.stringify({ READY_KEY: 'ready-value' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.strictEqual(await getConfig('READY_KEY'), '', 'first call sees the not-yet-written config as empty');
|
|
151
|
+
assert.strictEqual(await getConfig('READY_KEY'), 'ready-value', 'later call re-fetches once the config exists');
|
|
152
|
+
assert.strictEqual(call, 2, 'a not-found result must not be cached — S3 is retried');
|
|
153
|
+
});
|
|
154
|
+
|
|
134
155
|
it('throws with clear message on S3 network error', async () => {
|
|
135
156
|
process.env.BLOCKS_CONFIG_BUCKET = 'test-bucket';
|
|
136
157
|
process.env.BLOCKS_CONFIG_KEY = 'blocks-config.json';
|
package/src/common/config.ts
CHANGED
|
@@ -60,9 +60,13 @@ async function loadConfigFromS3(): Promise<Record<string, string>> {
|
|
|
60
60
|
|| error?.Code === 'NoSuchKey'
|
|
61
61
|
|| error?.$metadata?.httpStatusCode === 404;
|
|
62
62
|
if (isNotFound) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// Do NOT cache the empty result: a 404 right after deploy is usually the config file
|
|
64
|
+
// not being readable yet (the transient window before the BucketDeployment settles), not
|
|
65
|
+
// a genuinely config-less app. Caching {} here would poison the container for its whole
|
|
66
|
+
// lifetime. Returning without caching lets the next invocation re-fetch and pick up the
|
|
67
|
+
// real config once it's present. (Apps that truly have no config just re-check cheaply.)
|
|
68
|
+
console.warn(`[Blocks] Config file not found in S3 (${bucket}/${key}), proceeding with empty config (will retry on next request)`);
|
|
69
|
+
return {};
|
|
66
70
|
}
|
|
67
71
|
const msg = error instanceof Error ? error.message : String(error);
|
|
68
72
|
throw new Error(`[Blocks] Failed to load config from S3 (${bucket}/${key}): ${msg}`);
|
|
@@ -152,6 +156,46 @@ export async function loadConfigToProcessEnv(): Promise<void> {
|
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether a config load has resolved and cached a result.
|
|
161
|
+
*
|
|
162
|
+
* Returns `true` once `loadConfigFromS3()` has cached a config object — either a
|
|
163
|
+
* successful S3 load, a genuinely-empty `{}` config, or the no-bucket local-dev
|
|
164
|
+
* path (both of which cache `{}`). Returns `false` in the initial state AND
|
|
165
|
+
* after a transient not-found miss: the 404 path deliberately does NOT cache its
|
|
166
|
+
* empty result (see `loadConfigFromS3`), so a `false` return immediately after
|
|
167
|
+
* awaiting a load uniquely identifies the post-deploy S3 window where
|
|
168
|
+
* blocks-config.json isn't readable yet.
|
|
169
|
+
*
|
|
170
|
+
* Callers use this to tell a transient-empty load (retry on the next request)
|
|
171
|
+
* apart from a legitimately-config-less app (do nothing) without reaching into
|
|
172
|
+
* the module's private cache.
|
|
173
|
+
*/
|
|
174
|
+
export function isConfigResolved(): boolean {
|
|
175
|
+
return configCache !== null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Sanitize a Blocks identifier into a valid config/env-var key segment:
|
|
180
|
+
* uppercase, with every non-alphanumeric character replaced by `_`.
|
|
181
|
+
*
|
|
182
|
+
* Config entries are loaded into `process.env` at runtime (see
|
|
183
|
+
* {@link loadConfigToProcessEnv}), so a key must be a valid env-var name. This
|
|
184
|
+
* is the single rule both sides of a key share: whoever *writes* a config key
|
|
185
|
+
* (`registerConfig(\`PREFIX_${sanitizeConfigKey(id)}\`, …)`) and whoever *reads*
|
|
186
|
+
* it (`getConfigSync(\`PREFIX_${sanitizeConfigKey(id)}\`)`) MUST go through this
|
|
187
|
+
* function so they reconstruct a byte-identical key — a hand-inlined variant
|
|
188
|
+
* that drifts silently misses the lookup at runtime.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* registerConfig(this, `BLOCKS_QUEUE_URL_${sanitizeConfigKey(this.fullId)}`, url);
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
export function sanitizeConfigKey(id: string): string {
|
|
196
|
+
return id.toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
197
|
+
}
|
|
198
|
+
|
|
155
199
|
/**
|
|
156
200
|
* Reset the config cache. **For testing only.**
|
|
157
201
|
*/
|
package/src/common/index.ts
CHANGED
|
@@ -338,6 +338,14 @@ export interface BlocksStackProps extends StackProps {
|
|
|
338
338
|
|
|
339
339
|
export class BlocksStack {
|
|
340
340
|
public readonly id: string;
|
|
341
|
+
/**
|
|
342
|
+
* Path to the app's backend module (`props.backendCDKPath`). A typed contract for Building
|
|
343
|
+
* Blocks that co-bundle the backend at synth time and read it off
|
|
344
|
+
* `globalThis.CURRENT_BLOCKS_STACK` (the CDK `BlocksStack` / `BlocksBackend` set it). Declared
|
|
345
|
+
* here so those consumers can type against this shared `BlocksStack` contract — which the CDK
|
|
346
|
+
* `BlocksStack` / `BlocksBackend` implements — instead of `any`.
|
|
347
|
+
*/
|
|
348
|
+
declare readonly backendModulePath: string;
|
|
341
349
|
constructor(scope: Construct, id: string, props: BlocksStackProps) {
|
|
342
350
|
this.id = id;
|
|
343
351
|
}
|
package/src/errors.ts
CHANGED
|
@@ -48,6 +48,11 @@ export class ApiError extends Error {
|
|
|
48
48
|
* when `retriable === true`; non-retriable errors (expired session,
|
|
49
49
|
* tampered envelope, too-many-attempts lockouts) require restarting the
|
|
50
50
|
* flow. Defaults to `false` when unspecified.
|
|
51
|
+
*
|
|
52
|
+
* This marks whether the *kind* of failure is retriable in principle, not a
|
|
53
|
+
* guarantee that a given retry will succeed — e.g. an optimistic-lock
|
|
54
|
+
* conflict against a missing row is flagged retriable, yet a blind retry
|
|
55
|
+
* fails identically.
|
|
51
56
|
*/
|
|
52
57
|
readonly retriable: boolean;
|
|
53
58
|
|
|
@@ -78,6 +83,27 @@ export function isBlocksError<N extends string>(e: unknown, name: N): e is Error
|
|
|
78
83
|
return e instanceof Error && e.name === name;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Build a named `Error` whose `name` is a BB error constant, so it is matchable
|
|
88
|
+
* with {@link isBlocksError} on both server and client. The name is also
|
|
89
|
+
* prefixed into the message for readable logs.
|
|
90
|
+
*
|
|
91
|
+
* This is the producer half of the {@link isBlocksError} contract: throw via
|
|
92
|
+
* this helper so the `name` a consumer matches on is set consistently. It has
|
|
93
|
+
* no runtime dependencies, so it is safe to use in every bundle — mock,
|
|
94
|
+
* aws-runtime, and CDK synth.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* throw blocksError(KVStoreErrors.ConditionalCheckFailed, 'Key already exists');
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function blocksError(name: string, message: string): Error {
|
|
102
|
+
const err = new Error(`${name}: ${message}`);
|
|
103
|
+
err.name = name;
|
|
104
|
+
return err;
|
|
105
|
+
}
|
|
106
|
+
|
|
81
107
|
/**
|
|
82
108
|
* Type guard for branching on a failed `AuthState` (the recommended
|
|
83
109
|
* `setAuthState` client path) by its structured `errorName`.
|
package/src/hosting.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { App, Duration, Stack } from 'aws-cdk-lib';
|
|
|
13
13
|
import { Match, Template } from 'aws-cdk-lib/assertions';
|
|
14
14
|
import { BLOCKS_RPC_PREFIX } from './constants.js';
|
|
15
15
|
import { type BlocksStackApi, Hosting } from './hosting.js';
|
|
16
|
-
import { clearRouteRegistry, registerRoute } from './raw-route.js';
|
|
16
|
+
import { clearRouteRegistry, compilePath, registerRoute, type RegisteredRoute } from './raw-route.js';
|
|
17
17
|
|
|
18
18
|
// ================================================================
|
|
19
19
|
// Hosting construct tests
|
|
@@ -484,6 +484,34 @@ describe('Hosting', () => {
|
|
|
484
484
|
}
|
|
485
485
|
assert.ok(foundRetain, 'At least one bucket should have Retain deletion policy');
|
|
486
486
|
});
|
|
487
|
+
|
|
488
|
+
it('forwards buildRetentionDays to the hosting bucket lifecycle rule (#480)', () => {
|
|
489
|
+
createSpaBuildOutput(tmpDir);
|
|
490
|
+
|
|
491
|
+
const app = new App();
|
|
492
|
+
const stack = new Stack(app, 'RetentionStack');
|
|
493
|
+
|
|
494
|
+
new Hosting(stack, 'Hosting', {
|
|
495
|
+
root: tmpDir,
|
|
496
|
+
api: MOCK_API,
|
|
497
|
+
buildRetentionDays: 90,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const template = Template.fromStack(stack);
|
|
501
|
+
// Before the fix, core dropped buildRetentionDays (only retainOnDelete was
|
|
502
|
+
// forwarded), so this asserted 30. It must now reach the DeleteOldBuilds
|
|
503
|
+
// rule as 90.
|
|
504
|
+
template.hasResourceProperties('AWS::S3::Bucket', {
|
|
505
|
+
LifecycleConfiguration: Match.objectLike({
|
|
506
|
+
Rules: Match.arrayWith([
|
|
507
|
+
Match.objectLike({
|
|
508
|
+
Id: 'DeleteOldBuilds',
|
|
509
|
+
ExpirationInDays: 90,
|
|
510
|
+
}),
|
|
511
|
+
]),
|
|
512
|
+
}),
|
|
513
|
+
});
|
|
514
|
+
});
|
|
487
515
|
});
|
|
488
516
|
|
|
489
517
|
// ── API integration tests ────────────────────────────────────
|
|
@@ -708,6 +736,50 @@ describe('Hosting', () => {
|
|
|
708
736
|
assert.ok(patterns.includes('/users/*'), 'Should have /users/* behavior for parameterized RawRoute');
|
|
709
737
|
});
|
|
710
738
|
|
|
739
|
+
it('adds a CloudFront behavior for a route another core copy registered', () => {
|
|
740
|
+
createSpaBuildOutput(tmpDir);
|
|
741
|
+
|
|
742
|
+
// Synth has the same split as dispatch: a bundle can hold more than one
|
|
743
|
+
// copy of @aws-blocks/core, and this loop reads whichever route table
|
|
744
|
+
// its own copy owns. Register the way a second copy would — straight
|
|
745
|
+
// into the shared state, not through this copy's registerRoute() — and
|
|
746
|
+
// the behavior must still be emitted. With a module-local registry it
|
|
747
|
+
// never is, and the route 404s at CloudFront before Lambda is reached.
|
|
748
|
+
const REGISTRY_KEY = '__AWS_BLOCKS_RAW_ROUTE_REGISTRY_V1__';
|
|
749
|
+
const state = (globalThis as typeof globalThis & { [REGISTRY_KEY]?: { routes: RegisteredRoute[] } })[
|
|
750
|
+
REGISTRY_KEY
|
|
751
|
+
];
|
|
752
|
+
assert.ok(state, `route registry state must live on globalThis['${REGISTRY_KEY}']`);
|
|
753
|
+
|
|
754
|
+
const { pattern, paramNames } = compilePath('/from-other-copy');
|
|
755
|
+
state.routes.push({
|
|
756
|
+
method: 'GET',
|
|
757
|
+
path: '/from-other-copy',
|
|
758
|
+
pattern,
|
|
759
|
+
paramNames,
|
|
760
|
+
handler: async () => {},
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const app = new App();
|
|
764
|
+
const stack = new Stack(app, 'ForeignRouteBehaviorStack');
|
|
765
|
+
|
|
766
|
+
new Hosting(stack, 'Hosting', {
|
|
767
|
+
root: tmpDir,
|
|
768
|
+
api: MOCK_API,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const template = Template.fromStack(stack);
|
|
772
|
+
const distributions = template.findResources('AWS::CloudFront::Distribution');
|
|
773
|
+
const distKeys = Object.keys(distributions);
|
|
774
|
+
const distConfig = (distributions[distKeys[0]] as any).Properties.DistributionConfig;
|
|
775
|
+
const patterns = (distConfig.CacheBehaviors ?? []).map((b: any) => b.PathPattern);
|
|
776
|
+
|
|
777
|
+
assert.ok(
|
|
778
|
+
patterns.includes('/from-other-copy'),
|
|
779
|
+
`synth must see routes from every core copy, got: ${JSON.stringify(patterns)}`,
|
|
780
|
+
);
|
|
781
|
+
});
|
|
782
|
+
|
|
711
783
|
it('proxies the reserved /aws-blocks/auth subtree with a single behavior', () => {
|
|
712
784
|
createSpaBuildOutput(tmpDir);
|
|
713
785
|
|
|
@@ -1540,6 +1612,20 @@ describe('Hosting', () => {
|
|
|
1540
1612
|
`BlocksConfigDeployment must DependsOn the asset deployment(s); ` +
|
|
1541
1613
|
`found DependsOn=${JSON.stringify(dependsOn)}`,
|
|
1542
1614
|
);
|
|
1615
|
+
|
|
1616
|
+
const routeCutoverId = Object.entries(tpl.Resources).find(
|
|
1617
|
+
([id, resource]) =>
|
|
1618
|
+
resource.Type === 'AWS::CloudFormation::CustomResource' &&
|
|
1619
|
+
/RouteStoreKeys/.test(id),
|
|
1620
|
+
)?.[0];
|
|
1621
|
+
assert.ok(routeCutoverId, 'expected a RouteStoreKeys cutover resource');
|
|
1622
|
+
|
|
1623
|
+
const routeCutoverDeps = tpl.Resources[routeCutoverId].DependsOn ?? [];
|
|
1624
|
+
assert.ok(
|
|
1625
|
+
routeCutoverDeps.includes(configId),
|
|
1626
|
+
`RouteStoreKeys must DependsOn BlocksConfigDeployment; ` +
|
|
1627
|
+
`found DependsOn=${JSON.stringify(routeCutoverDeps)}`,
|
|
1628
|
+
);
|
|
1543
1629
|
});
|
|
1544
1630
|
});
|
|
1545
1631
|
|
package/src/hosting.ts
CHANGED
|
@@ -264,6 +264,16 @@ export interface HostingProps {
|
|
|
264
264
|
/** Retain the S3 bucket when the stack is deleted. Default: false. */
|
|
265
265
|
retainOnDelete?: boolean;
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Days to retain SUPERSEDED build artifacts in S3 before they are expired.
|
|
269
|
+
* Default: 30. The build currently referenced by CloudFront KVS (`meta.b`)
|
|
270
|
+
* is NEVER expired regardless of this value — only builds that have been
|
|
271
|
+
* replaced by a newer deploy are cleaned up (issue #480). Raise this to keep
|
|
272
|
+
* a longer rollback window. Must be at least `skewProtection.maxAge`
|
|
273
|
+
* (converted to days), or synth throws `InvalidSkewProtectionMaxAgeError`.
|
|
274
|
+
*/
|
|
275
|
+
buildRetentionDays?: number;
|
|
276
|
+
|
|
267
277
|
/** Custom Content-Security-Policy header value. */
|
|
268
278
|
contentSecurityPolicy?: string;
|
|
269
279
|
|
|
@@ -671,7 +681,17 @@ export class Hosting extends Construct {
|
|
|
671
681
|
environment: Object.keys(plainEnv).length ? plainEnv : undefined,
|
|
672
682
|
domain: resolvedDomain,
|
|
673
683
|
waf: props.waf,
|
|
674
|
-
storage:
|
|
684
|
+
storage:
|
|
685
|
+
props.retainOnDelete != null || props.buildRetentionDays != null
|
|
686
|
+
? {
|
|
687
|
+
...(props.retainOnDelete != null
|
|
688
|
+
? { retainOnDelete: props.retainOnDelete }
|
|
689
|
+
: {}),
|
|
690
|
+
...(props.buildRetentionDays != null
|
|
691
|
+
? { buildRetentionDays: props.buildRetentionDays }
|
|
692
|
+
: {}),
|
|
693
|
+
}
|
|
694
|
+
: undefined,
|
|
675
695
|
cdn:
|
|
676
696
|
props.contentSecurityPolicy || props.priceClass || props.geoRestriction || props.quotas
|
|
677
697
|
? {
|
|
@@ -768,6 +788,9 @@ export class Hosting extends Construct {
|
|
|
768
788
|
for (const dep of assetDeployments) {
|
|
769
789
|
configDeployment.node.addDependency(dep);
|
|
770
790
|
}
|
|
791
|
+
|
|
792
|
+
// Delay route cutover until resolved config replaces the static placeholder.
|
|
793
|
+
hosting.addBuildAssetDependency(configDeployment);
|
|
771
794
|
}
|
|
772
795
|
|
|
773
796
|
// ── 9. Register public origin + CORS hosting origin into S3 config ──
|