@fjall/components-infrastructure 14.0.0 → 14.2.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/dist/lib/patterns/aws/cdn.d.ts +143 -2
- package/dist/lib/patterns/aws/cdn.js +369 -28
- package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +74 -0
- package/dist/lib/patterns/aws/cdnAppOrigin.js +251 -0
- package/dist/lib/patterns/aws/computeEcs.d.ts +9 -1
- package/dist/lib/patterns/aws/computeEcs.js +10 -0
- package/dist/lib/patterns/aws/domainCertificateComposer.js +69 -0
- package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -0
- package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +7 -3
- package/dist/lib/patterns/aws/targets/fjallTargets.js +7 -3
- package/dist/lib/resources/aws/compute/ecs.d.ts +11 -0
- package/dist/lib/resources/aws/compute/ecs.js +27 -0
- package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -0
- package/dist/lib/resources/aws/compute/ecsNetworking.js +98 -31
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +10 -0
- package/dist/lib/resources/aws/compute/ingressProfile.d.ts +174 -0
- package/dist/lib/resources/aws/compute/ingressProfile.js +195 -0
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +29 -2
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +19 -5
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +17 -6
- package/dist/lib/utils/dnsRecordRegistry.d.ts +42 -11
- package/dist/lib/utils/dnsRecordRegistry.js +68 -14
- package/dist/lib/utils/managedDomainContext.d.ts +26 -1
- package/dist/lib/utils/managedDomainContext.js +69 -0
- package/package.json +3 -3
|
@@ -1,14 +1,105 @@
|
|
|
1
1
|
import { Fn, Token } from "aws-cdk-lib";
|
|
2
2
|
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
3
|
+
import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
3
4
|
import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
|
|
5
|
+
import { AliasRecord } from "../../resources/aws/networking/index.js";
|
|
6
|
+
import { getSafeZoneName, toPascalCase } from "../../utils/capitaliseString.js";
|
|
7
|
+
import { resolvePatternZone, resolvePatternCloudFrontCertificate } from "./patternDomain.js";
|
|
4
8
|
import { isStorage } from "./storage.js";
|
|
9
|
+
import { CdnEcsOriginResolver, recordLabelWithin, normaliseDnsName } from "./cdnAppOrigin.js";
|
|
5
10
|
import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
|
|
11
|
+
/**
|
|
12
|
+
* The distribution's own alias names, normalised — consumed by the
|
|
13
|
+
* origin-loop guard and by the ECS origin resolver (auto-resolution excludes
|
|
14
|
+
* them from routed-host candidates, and a resolved origin equal to one is
|
|
15
|
+
* refused as a loop).
|
|
16
|
+
*/
|
|
17
|
+
function collectAliasNames(props) {
|
|
18
|
+
const aliasNames = new Set();
|
|
19
|
+
if (props.domainConfig !== undefined) {
|
|
20
|
+
aliasNames.add(normaliseDnsName(props.domainConfig.domainName));
|
|
21
|
+
}
|
|
22
|
+
for (const name of props.domainNames ?? []) {
|
|
23
|
+
if (!Token.isUnresolved(name)) {
|
|
24
|
+
aliasNames.add(normaliseDnsName(name));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return aliasNames;
|
|
28
|
+
}
|
|
6
29
|
/**
|
|
7
30
|
* Validates CDN props — synth-time hard errors for shapes CloudFormation
|
|
8
31
|
* would only reject at deploy.
|
|
9
32
|
*/
|
|
10
33
|
function validateCdnProps(props) {
|
|
11
34
|
const hasCertificate = !!(props.certificate || props.certificateArn);
|
|
35
|
+
// domainConfig is the managed-domain authority for the viewer config —
|
|
36
|
+
// mixing it with the literal surface would give the alias list and the
|
|
37
|
+
// certificate two owners with no defined precedence.
|
|
38
|
+
if (props.domainConfig !== undefined) {
|
|
39
|
+
if (props.domainNames !== undefined || hasCertificate) {
|
|
40
|
+
throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') cannot be ` +
|
|
41
|
+
"combined with 'domainNames', 'certificate', or 'certificateArn'. " +
|
|
42
|
+
"'domainConfig' derives the alternate domain names and resolves " +
|
|
43
|
+
"the us-east-1 viewer certificate itself — drop the literal props, " +
|
|
44
|
+
"or drop 'domainConfig' and manage both by hand.");
|
|
45
|
+
}
|
|
46
|
+
if (props.app === undefined) {
|
|
47
|
+
throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') requires ` +
|
|
48
|
+
"the App for certificate placement, but 'app' is unset. Create the " +
|
|
49
|
+
"CDN through the factory path — app.addCdn(CdnFactory.build(...)) " +
|
|
50
|
+
"— which fills it, or pass 'app' explicitly.");
|
|
51
|
+
}
|
|
52
|
+
// Latency region and setIdentifier are literals by contract — inspect
|
|
53
|
+
// them at synth so a config slip fails here with the cure, not at
|
|
54
|
+
// CloudFormation with a raw Route53 InvalidChangeBatch (or, for the
|
|
55
|
+
// empty-string cases, a CDK error about a prop the user never set).
|
|
56
|
+
const region = props.domainConfig.routingPolicy?.region;
|
|
57
|
+
if (region !== undefined && !/^[a-z]{2,4}(-[a-z]+)+-\d+$/.test(region)) {
|
|
58
|
+
throw new Error(`CDN 'domainConfig.routingPolicy.region' ('${region}') is not an ` +
|
|
59
|
+
"AWS region name (expected e.g. 'us-east-1'). Route53 latency " +
|
|
60
|
+
"records carry a literal region label — fix the region string.");
|
|
61
|
+
}
|
|
62
|
+
const setIdentifier = props.domainConfig.setIdentifier;
|
|
63
|
+
if (setIdentifier !== undefined &&
|
|
64
|
+
(setIdentifier.trim() === "" || setIdentifier.length > 128)) {
|
|
65
|
+
throw new Error(`CDN 'domainConfig.setIdentifier' (${JSON.stringify(setIdentifier)}) ` +
|
|
66
|
+
"must be 1-128 non-blank characters (Route53 SetIdentifier limit). " +
|
|
67
|
+
"Omit it to use the derived '<id><region>' value.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Origin-loop guard (design 2026-08-17 cdn-domain-ownership C4): an HTTP
|
|
71
|
+
// origin pointing at one of the distribution's own domain names sends
|
|
72
|
+
// every request CloudFront → DNS → CloudFront until the request-depth
|
|
73
|
+
// limit 508s. Synth can see this shape whenever the origin hostname is a
|
|
74
|
+
// literal, so refuse it with the cure.
|
|
75
|
+
const aliasNames = collectAliasNames(props);
|
|
76
|
+
const defaultOriginHostname = props.originType === "http"
|
|
77
|
+
? props.domainName
|
|
78
|
+
: props.originType === "auto" && typeof props.origin === "string"
|
|
79
|
+
? props.origin
|
|
80
|
+
: undefined;
|
|
81
|
+
// Behaviour origins are the same literal-HTTP-origin surface: a string
|
|
82
|
+
// behaviour origin becomes an HTTP origin via detectOriginFromResource, so
|
|
83
|
+
// a self-referential one loops exactly like the default origin — but only
|
|
84
|
+
// for its path pattern, a PARTIAL outage that is even harder to spot.
|
|
85
|
+
const literalOriginHostnames = [
|
|
86
|
+
...(defaultOriginHostname !== undefined ? [defaultOriginHostname] : []),
|
|
87
|
+
...(props.behaviours ?? [])
|
|
88
|
+
.map((behaviour) => behaviour.origin)
|
|
89
|
+
.filter((origin) => typeof origin === "string")
|
|
90
|
+
];
|
|
91
|
+
for (const originHostname of literalOriginHostnames) {
|
|
92
|
+
if (!Token.isUnresolved(originHostname) &&
|
|
93
|
+
aliasNames.has(normaliseDnsName(originHostname))) {
|
|
94
|
+
throw new Error(`CDN origin '${originHostname}' is also one of the distribution's own ` +
|
|
95
|
+
"domain names — once DNS points that name at the distribution, every " +
|
|
96
|
+
"request loops CloudFront → CloudFront. Point the origin at a " +
|
|
97
|
+
"dedicated origin hostname instead (e.g. an ECS service " +
|
|
98
|
+
"'routing[].host' such as 'origin.<zone>' resolving to the load " +
|
|
99
|
+
"balancer, or a domain-stack record targeting the origin resource) " +
|
|
100
|
+
"and keep the public name on the CDN.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
12
103
|
// CloudFront accepts viewer certificates from us-east-1 only. A literal
|
|
13
104
|
// ARN is inspectable at synth (design H3/D3 — the old advisory log is
|
|
14
105
|
// replaced by real us-east-1 provisioning in the patterns; this assert
|
|
@@ -34,18 +125,66 @@ function validateCdnProps(props) {
|
|
|
34
125
|
"'certificateArn', or configure the pattern's 'domain' with a zone " +
|
|
35
126
|
"identity so it provisions one.");
|
|
36
127
|
}
|
|
37
|
-
// Validate ALB origin
|
|
128
|
+
// Validate ALB origin (design 2026-08-18 cdn-app-origin, D4): CloudFront
|
|
129
|
+
// validates the origin certificate against the raw `*.elb.amazonaws.com`
|
|
130
|
+
// hostname, which no ACM certificate ever covers — an effective-HTTPS alb
|
|
131
|
+
// origin can never complete a TLS handshake. E9, with the cure conditional
|
|
132
|
+
// on the listener protocol: HTTP_ONLY only works against a port-80
|
|
133
|
+
// listener, and a cert-bearing cluster has exactly one listener, on 443.
|
|
38
134
|
if (props.originType === "alb") {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
135
|
+
const computeRef = isCompute(props.loadBalancer) && isEcsCompute(props.loadBalancer)
|
|
136
|
+
? props.loadBalancer
|
|
137
|
+
: undefined;
|
|
138
|
+
if (computeRef !== undefined && !computeRef.getLoadBalancer()) {
|
|
139
|
+
throw new Error("Compute resource does not have a load balancer. " +
|
|
140
|
+
"Ensure ECS compute has loadBalancer enabled for CDN origin.");
|
|
141
|
+
}
|
|
142
|
+
const profile = computeRef?.getIngressProfile();
|
|
143
|
+
if (profile?.internal) {
|
|
144
|
+
// E2 — same defect as the construct-origin lane: no public path in.
|
|
145
|
+
throw new Error("CDN alb origin: the load balancer is internal — CloudFront " +
|
|
146
|
+
"reaches origins over the public internet and cannot address an " +
|
|
147
|
+
"internal ALB. Make the load balancer internet-facing.");
|
|
148
|
+
}
|
|
149
|
+
const effectiveProtocol = props.protocolPolicy ?? "HTTPS_ONLY";
|
|
150
|
+
if (effectiveProtocol !== "HTTP_ONLY") {
|
|
151
|
+
// E9 — effective HTTPS to the raw ELB hostname.
|
|
152
|
+
const cure = profile === undefined
|
|
153
|
+
? 'If the ALB has an HTTP (port-80) listener, set protocolPolicy: "HTTP_ONLY"; ' +
|
|
154
|
+
'otherwise use originType "auto" with the compute as origin, ' +
|
|
155
|
+
"which resolves a certificate-covered origin hostname."
|
|
156
|
+
: profile.listenerPort === 80
|
|
157
|
+
? 'Set protocolPolicy: "HTTP_ONLY" (the cluster\'s listener serves HTTP), ' +
|
|
158
|
+
'or use originType "auto" with the compute as origin.'
|
|
159
|
+
: 'Use originType "auto" with the compute as origin — the ' +
|
|
160
|
+
"cluster's only listener is HTTPS (443), so the sole working " +
|
|
161
|
+
"path is a certificate-covered origin hostname.";
|
|
162
|
+
throw new Error(`CDN alb origin with protocolPolicy '${effectiveProtocol}' can ` +
|
|
163
|
+
"never complete a TLS handshake: CloudFront validates the origin " +
|
|
164
|
+
"certificate against the raw ELB hostname " +
|
|
165
|
+
"(*.elb.amazonaws.com), which no ACM certificate covers — every " +
|
|
166
|
+
`origin fetch fails with a 502. ${cure}`);
|
|
167
|
+
}
|
|
168
|
+
if (profile !== undefined && profile.listenerPort === 443) {
|
|
169
|
+
// E9 (HTTP_ONLY variant) — nothing listens on 80: a connect-timeout
|
|
170
|
+
// 504 instead of a TLS 502, equally broken.
|
|
171
|
+
throw new Error('CDN alb origin with protocolPolicy "HTTP_ONLY": the cluster\'s ' +
|
|
172
|
+
"only listener is HTTPS (443) — a certificate resolved, so " +
|
|
173
|
+
"nothing listens on port 80 and every origin fetch times out. " +
|
|
174
|
+
'Use originType "auto" with the compute as origin instead.');
|
|
45
175
|
}
|
|
46
176
|
}
|
|
47
177
|
// Validate smart origin
|
|
48
178
|
if (props.originType === "auto") {
|
|
179
|
+
if (!(isCompute(props.origin) && isEcsCompute(props.origin)) &&
|
|
180
|
+
(props.originHostname !== undefined || props.originRecord !== undefined)) {
|
|
181
|
+
throw new Error("CDN 'originHostname'/'originRecord' configure the distribution's " +
|
|
182
|
+
"DEFAULT origin, and only when it is an ECS compute — they " +
|
|
183
|
+
"resolve the origin hostname against that compute's ingress " +
|
|
184
|
+
"profile. Drop them for storage, Lambda, and literal-hostname " +
|
|
185
|
+
"default origins; a behaviour's ECS origin takes the same " +
|
|
186
|
+
"overrides on the behaviour entry itself.");
|
|
187
|
+
}
|
|
49
188
|
if (isCompute(props.origin)) {
|
|
50
189
|
if (isEcsCompute(props.origin)) {
|
|
51
190
|
if (!props.origin.getLoadBalancer()) {
|
|
@@ -73,20 +212,203 @@ function validateCdnProps(props) {
|
|
|
73
212
|
export class Cdn extends CloudFrontDistribution {
|
|
74
213
|
constructor(scope, id, props) {
|
|
75
214
|
validateCdnProps(props);
|
|
76
|
-
|
|
215
|
+
// Zone + certificate resolve BEFORE super() — the viewer certificate is
|
|
216
|
+
// part of the distribution's props — so their constructs land on the CDN
|
|
217
|
+
// stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
|
|
218
|
+
const domain = props.domainConfig !== undefined && props.app !== undefined
|
|
219
|
+
? Cdn.resolveDomain(scope, id, props.domainConfig, props.app)
|
|
220
|
+
: undefined;
|
|
221
|
+
// ECS construct-reference origins resolve through one memoized resolver
|
|
222
|
+
// (design 2026-08-18 cdn-app-origin, D3): the default origin and every
|
|
223
|
+
// behaviour referencing the same compute share one resolution and at
|
|
224
|
+
// most one origin record.
|
|
225
|
+
const ecsOriginResolver = Cdn.createEcsOriginResolver(id, props);
|
|
226
|
+
const resolvedProps = Cdn.resolveProps(scope, id, props, domain, ecsOriginResolver);
|
|
77
227
|
super(scope, id, resolvedProps);
|
|
228
|
+
if (domain !== undefined && props.domainConfig !== undefined) {
|
|
229
|
+
this.createDomainRecord(id, props.domainConfig, domain);
|
|
230
|
+
}
|
|
231
|
+
this.createOriginRecords(ecsOriginResolver.getRecordPlans());
|
|
232
|
+
}
|
|
233
|
+
static createEcsOriginResolver(id, props) {
|
|
234
|
+
return new CdnEcsOriginResolver({
|
|
235
|
+
cdnId: id,
|
|
236
|
+
aliasNames: collectAliasNames(props),
|
|
237
|
+
overrides: Cdn.collectEcsOriginOverrides(id, props)
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Assemble the per-compute `originHostname`/`originRecord` overrides the
|
|
242
|
+
* resolver binds by compute identity: the distribution-level props apply
|
|
243
|
+
* to the DEFAULT origin's compute, each behaviour's own overrides apply
|
|
244
|
+
* to that behaviour's compute. Two entries naming the same compute must
|
|
245
|
+
* agree — one alias record cannot carry two spellings.
|
|
246
|
+
*/
|
|
247
|
+
static collectEcsOriginOverrides(id, props) {
|
|
248
|
+
const overrides = new Map();
|
|
249
|
+
const claim = (compute, override, site) => {
|
|
250
|
+
const existing = overrides.get(compute) ?? {};
|
|
251
|
+
for (const key of ["originHostname", "originRecord"]) {
|
|
252
|
+
const incoming = override[key];
|
|
253
|
+
if (incoming === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
const held = existing[key];
|
|
256
|
+
if (held !== undefined && held !== incoming) {
|
|
257
|
+
throw new Error(`CDN '${id}': conflicting ${key} overrides for the same ECS ` +
|
|
258
|
+
`compute origin ('${held}' vs '${incoming}' from ${site}) — ` +
|
|
259
|
+
"overrides bind per compute, so every entry naming it must " +
|
|
260
|
+
"agree. Keep one spelling.");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
overrides.set(compute, { ...existing, ...override });
|
|
264
|
+
};
|
|
265
|
+
if (props.originType === "auto") {
|
|
266
|
+
const override = {
|
|
267
|
+
...(props.originHostname !== undefined && {
|
|
268
|
+
originHostname: props.originHostname
|
|
269
|
+
}),
|
|
270
|
+
...(props.originRecord !== undefined && {
|
|
271
|
+
originRecord: props.originRecord
|
|
272
|
+
})
|
|
273
|
+
};
|
|
274
|
+
if ((override.originHostname !== undefined ||
|
|
275
|
+
override.originRecord !== undefined) &&
|
|
276
|
+
isCompute(props.origin) &&
|
|
277
|
+
isEcsCompute(props.origin)) {
|
|
278
|
+
claim(props.origin, override, "the distribution props");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const behaviour of props.behaviours ?? []) {
|
|
282
|
+
if (behaviour.originHostname === undefined &&
|
|
283
|
+
behaviour.originRecord === undefined) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (typeof behaviour.origin === "string" ||
|
|
287
|
+
!isCompute(behaviour.origin) ||
|
|
288
|
+
!isEcsCompute(behaviour.origin)) {
|
|
289
|
+
throw new Error(`CDN '${id}': behaviour '${behaviour.pathPattern}' sets ` +
|
|
290
|
+
"'originHostname'/'originRecord', but its origin is not an ECS " +
|
|
291
|
+
"compute — they configure how the origin hostname resolves " +
|
|
292
|
+
"against the compute's ingress profile. Drop them for storage, " +
|
|
293
|
+
"Lambda, and literal-hostname origins.");
|
|
294
|
+
}
|
|
295
|
+
claim(behaviour.origin, {
|
|
296
|
+
...(behaviour.originHostname !== undefined && {
|
|
297
|
+
originHostname: behaviour.originHostname
|
|
298
|
+
}),
|
|
299
|
+
...(behaviour.originRecord !== undefined && {
|
|
300
|
+
originRecord: behaviour.originRecord
|
|
301
|
+
})
|
|
302
|
+
}, `behaviour '${behaviour.pathPattern}'`);
|
|
303
|
+
}
|
|
304
|
+
return overrides;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Mint the Cdn-owned origin alias records (P1) after the distribution
|
|
308
|
+
* exists, using the compute profile's own zone instance so the DNS claim
|
|
309
|
+
* registry sees the same zone identity as the cluster's records. The
|
|
310
|
+
* distribution depends on each record: the hostname must resolve before a
|
|
311
|
+
* distribution update referencing it propagates.
|
|
312
|
+
*/
|
|
313
|
+
createOriginRecords(plans) {
|
|
314
|
+
// Plans are memoized per compute, so two entries with one hostname mean
|
|
315
|
+
// two DIFFERENT computes resolved the same name — an inherently invalid
|
|
316
|
+
// shape (one alias record cannot target two load balancers). Refuse it
|
|
317
|
+
// with the cure instead of letting CDK's duplicate-construct-id throw.
|
|
318
|
+
const byHostname = new Map();
|
|
319
|
+
for (const plan of plans) {
|
|
320
|
+
const key = normaliseDnsName(plan.hostname);
|
|
321
|
+
if (byHostname.has(key)) {
|
|
322
|
+
throw new Error(`CDN '${this.node.id}': two ECS compute origins resolved the ` +
|
|
323
|
+
`same origin hostname '${plan.hostname}' — one alias record ` +
|
|
324
|
+
"cannot target two load balancers. Give one of them a " +
|
|
325
|
+
"distinct originHostname.");
|
|
326
|
+
}
|
|
327
|
+
byHostname.set(key, plan);
|
|
328
|
+
}
|
|
329
|
+
for (const plan of plans) {
|
|
330
|
+
const safeHost = toPascalCase(getSafeZoneName(plan.hostname));
|
|
331
|
+
const record = new AliasRecord(this, `${safeHost}OriginRecord`, {
|
|
332
|
+
zone: plan.hostedZone,
|
|
333
|
+
zoneName: plan.zoneName,
|
|
334
|
+
recordName: plan.recordName,
|
|
335
|
+
target: new LoadBalancerTarget(plan.loadBalancer)
|
|
336
|
+
});
|
|
337
|
+
this.getDistribution().node.addDependency(record);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Resolve a `domainConfig` through the shared pattern-domain helpers
|
|
342
|
+
* (design C1): zone identity via `resolvePatternZone` (explicit
|
|
343
|
+
* `managedDomain` → CLI-injected binding → BYO `zoneName`/`hostedZoneId`),
|
|
344
|
+
* viewer certificate via `resolvePatternCloudFrontCertificate` (binding's
|
|
345
|
+
* literal us-east-1 ARN, else app-owned provisioning). The exact resolution
|
|
346
|
+
* chain staticSite and payload already use — the CDN stops being the one
|
|
347
|
+
* CloudFront-fronted pattern without it.
|
|
348
|
+
*/
|
|
349
|
+
static resolveDomain(scope, id, domainConfig, app) {
|
|
350
|
+
const request = {
|
|
351
|
+
scope,
|
|
352
|
+
app,
|
|
353
|
+
idPrefix: id,
|
|
354
|
+
context: `CDN '${id}'`,
|
|
355
|
+
domain: domainConfig.domainName,
|
|
356
|
+
identity: domainConfig
|
|
357
|
+
};
|
|
358
|
+
const zone = resolvePatternZone(request);
|
|
359
|
+
const certificate = resolvePatternCloudFrontCertificate(request, zone);
|
|
360
|
+
return { zone, certificate };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Own the domain's DNS record in the CDN stack (satellite doctrine — the
|
|
364
|
+
* app owns its records, the domain stack owns zone-level records), exactly
|
|
365
|
+
* as the ECS cluster owns its apex alias. Latency routing gets the
|
|
366
|
+
* cluster's setIdentifier derivation (`${id}${region}`) unless overridden;
|
|
367
|
+
* `record: "none"` skips this entirely (pre-flip validation state).
|
|
368
|
+
*/
|
|
369
|
+
createDomainRecord(id, domainConfig, domain) {
|
|
370
|
+
if (domainConfig.record === "none") {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const region = domainConfig.routingPolicy?.region;
|
|
374
|
+
const setIdentifier = domainConfig.setIdentifier ??
|
|
375
|
+
(region !== undefined ? `${id}${region}` : undefined);
|
|
376
|
+
new AliasRecord(this, "AliasRecord", {
|
|
377
|
+
zone: domain.zone.hostedZone,
|
|
378
|
+
zoneName: domain.zone.zoneName,
|
|
379
|
+
recordName: recordLabelWithin(domainConfig.domainName, domain.zone.zoneName),
|
|
380
|
+
// CloudFrontTarget sets no EvaluateTargetHealth — Route53 requires it
|
|
381
|
+
// absent for CloudFront alias targets.
|
|
382
|
+
target: new CloudFrontTarget(this.getDistribution()),
|
|
383
|
+
...(region !== undefined && { region }),
|
|
384
|
+
...(setIdentifier !== undefined && { setIdentifier })
|
|
385
|
+
});
|
|
78
386
|
}
|
|
79
387
|
/**
|
|
80
388
|
* Resolve ICdnProps to CloudFrontDistributionProps.
|
|
81
389
|
*/
|
|
82
|
-
static resolveProps(scope, id, props) {
|
|
83
|
-
const defaultOrigin = Cdn.resolveDefaultOrigin(props);
|
|
84
|
-
const behaviours = Cdn.resolveBehaviours(props.behaviours);
|
|
390
|
+
static resolveProps(scope, id, props, domain, ecsOriginResolver) {
|
|
391
|
+
const defaultOrigin = Cdn.resolveDefaultOrigin(props, ecsOriginResolver);
|
|
392
|
+
const behaviours = Cdn.resolveBehaviours(props.behaviours, ecsOriginResolver);
|
|
85
393
|
const appName = props.appName;
|
|
394
|
+
const domainNames = props.domainNames ??
|
|
395
|
+
(props.domainConfig !== undefined
|
|
396
|
+
? [props.domainConfig.domainName]
|
|
397
|
+
: undefined);
|
|
398
|
+
// A default origin resolved through the ECS profile lane serves a
|
|
399
|
+
// hostname that differs from the viewer-facing aliases, so host
|
|
400
|
+
// fidelity via x-forwarded-host is the sensible default (design D3
|
|
401
|
+
// step 9). Explicit `forwardHostHeader: false` wins.
|
|
402
|
+
const impliedForwardHostHeader = props.originType === "auto" &&
|
|
403
|
+
isCompute(props.origin) &&
|
|
404
|
+
isEcsCompute(props.origin) &&
|
|
405
|
+
(domainNames?.length ?? 0) > 0
|
|
406
|
+
? true
|
|
407
|
+
: undefined;
|
|
86
408
|
const certificate = props.certificate ??
|
|
87
409
|
(props.certificateArn
|
|
88
410
|
? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
|
|
89
|
-
:
|
|
411
|
+
: domain?.certificate);
|
|
90
412
|
const s3Routing = props.originType === "s3"
|
|
91
413
|
? {
|
|
92
414
|
defaultRootObject: props.defaultRootObject,
|
|
@@ -102,13 +424,13 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
102
424
|
defaultCachePolicy: props.cachePolicy,
|
|
103
425
|
defaultAllowedMethods: props.defaultAllowedMethods,
|
|
104
426
|
behaviours,
|
|
105
|
-
domainNames
|
|
427
|
+
domainNames,
|
|
106
428
|
certificate,
|
|
107
429
|
comment: props.comment,
|
|
108
430
|
enableLogging: props.enableLogging,
|
|
109
431
|
logBucket: props.logBucket,
|
|
110
432
|
priceClass: props.priceClass,
|
|
111
|
-
forwardHostHeader: props.forwardHostHeader,
|
|
433
|
+
forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
|
|
112
434
|
accessGate: props.accessGate,
|
|
113
435
|
...s3Routing
|
|
114
436
|
};
|
|
@@ -116,13 +438,13 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
116
438
|
/**
|
|
117
439
|
* Transform smart behaviours to CdnBehaviour objects.
|
|
118
440
|
*/
|
|
119
|
-
static resolveBehaviours(behaviours) {
|
|
441
|
+
static resolveBehaviours(behaviours, ecsOriginResolver) {
|
|
120
442
|
if (!behaviours || behaviours.length === 0) {
|
|
121
443
|
return undefined;
|
|
122
444
|
}
|
|
123
445
|
return behaviours.map((behaviour) => ({
|
|
124
446
|
pathPattern: behaviour.pathPattern,
|
|
125
|
-
origin: Cdn.detectOriginFromResource(behaviour.origin),
|
|
447
|
+
origin: Cdn.detectOriginFromResource(behaviour.origin, ecsOriginResolver),
|
|
126
448
|
cachePolicy: behaviour.cachePolicy,
|
|
127
449
|
allowedMethods: behaviour.allowedMethods
|
|
128
450
|
}));
|
|
@@ -130,7 +452,7 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
130
452
|
/**
|
|
131
453
|
* Resolve the default origin from ICdnProps.
|
|
132
454
|
*/
|
|
133
|
-
static resolveDefaultOrigin(props) {
|
|
455
|
+
static resolveDefaultOrigin(props, ecsOriginResolver) {
|
|
134
456
|
switch (props.originType) {
|
|
135
457
|
case "s3": {
|
|
136
458
|
const bucket = isStorage(props.bucket)
|
|
@@ -176,7 +498,7 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
176
498
|
protocolPolicy: props.protocolPolicy
|
|
177
499
|
};
|
|
178
500
|
case "auto":
|
|
179
|
-
return Cdn.detectOriginFromResource(props.origin);
|
|
501
|
+
return Cdn.detectOriginFromResource(props.origin, ecsOriginResolver);
|
|
180
502
|
default: {
|
|
181
503
|
const _exhaustive = props;
|
|
182
504
|
throw new Error(`Unsupported CDN origin type: ${props.originType}`);
|
|
@@ -186,7 +508,7 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
186
508
|
/**
|
|
187
509
|
* Auto-detect origin configuration from a Fjall resource.
|
|
188
510
|
*/
|
|
189
|
-
static detectOriginFromResource(resource) {
|
|
511
|
+
static detectOriginFromResource(resource, ecsOriginResolver) {
|
|
190
512
|
// String → HTTP origin
|
|
191
513
|
if (typeof resource === "string") {
|
|
192
514
|
return {
|
|
@@ -201,15 +523,13 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
201
523
|
bucket: resource.getBucket()
|
|
202
524
|
};
|
|
203
525
|
}
|
|
204
|
-
// ECS Compute →
|
|
526
|
+
// ECS Compute → HTTP origin on a resolved, certificate-covered origin
|
|
527
|
+
// hostname (design 2026-08-18 cdn-app-origin, D3). The former raw-ELB
|
|
528
|
+
// alb-origin emission was TLS-broken by construction: the resource
|
|
529
|
+
// layer's HTTPS_ONLY default validated the certificate against
|
|
530
|
+
// *.elb.amazonaws.com, which no ACM certificate covers.
|
|
205
531
|
if (isEcsCompute(resource)) {
|
|
206
|
-
|
|
207
|
-
if (loadBalancer) {
|
|
208
|
-
return {
|
|
209
|
-
type: "alb",
|
|
210
|
-
loadBalancer
|
|
211
|
-
};
|
|
212
|
-
}
|
|
532
|
+
return ecsOriginResolver.resolve(resource).originConfig;
|
|
213
533
|
}
|
|
214
534
|
// Lambda Compute → HTTP origin (function URL)
|
|
215
535
|
if (isLambdaCompute(resource)) {
|
|
@@ -226,6 +546,10 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
226
546
|
domainName
|
|
227
547
|
};
|
|
228
548
|
}
|
|
549
|
+
// Targeted error (design D3): a behaviour origin used to fall through
|
|
550
|
+
// to the generic detect throw for this shape.
|
|
551
|
+
throw new Error("Lambda compute must have a function URL for CDN origin. " +
|
|
552
|
+
"Enable functionUrl in your Lambda compute configuration.");
|
|
229
553
|
}
|
|
230
554
|
throw new Error(`Unable to detect CDN origin from resource: ${typeof resource}. ` +
|
|
231
555
|
"Provide explicit origin configuration using originType.");
|
|
@@ -265,6 +589,20 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
265
589
|
* domainNames: ["app.example.com"],
|
|
266
590
|
* certificate: myCert
|
|
267
591
|
* }));
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* // Managed domain — the CDN resolves the zone + us-east-1 certificate and
|
|
595
|
+
* // owns the alias record in its own stack (satellite doctrine), like the
|
|
596
|
+
* // ECS cluster's domainConfig.
|
|
597
|
+
* app.addCdn(CdnFactory.build("AppCdn", {
|
|
598
|
+
* originType: "http",
|
|
599
|
+
* domainName: "origin.example.com",
|
|
600
|
+
* forwardHostHeader: true,
|
|
601
|
+
* domainConfig: {
|
|
602
|
+
* domainName: "example.com",
|
|
603
|
+
* routingPolicy: { type: "latency", region: "us-east-1" }
|
|
604
|
+
* }
|
|
605
|
+
* }));
|
|
268
606
|
*/
|
|
269
607
|
export class CdnFactory {
|
|
270
608
|
/**
|
|
@@ -278,9 +616,12 @@ export class CdnFactory {
|
|
|
278
616
|
return (app, scope) => {
|
|
279
617
|
// The App is the only place the app name is known here; without it the
|
|
280
618
|
// export falls back to the construct id and the Domain import misses.
|
|
619
|
+
// The App instance itself rides along for domainConfig resolution
|
|
620
|
+
// (certificate placement needs app.getUsEast1CertificateStack()).
|
|
281
621
|
return new Cdn(scope, id, {
|
|
282
622
|
...props,
|
|
283
|
-
appName: props.appName ?? app.getName()
|
|
623
|
+
appName: props.appName ?? app.getName(),
|
|
624
|
+
app: props.app ?? app
|
|
284
625
|
});
|
|
285
626
|
};
|
|
286
627
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECS construct-reference origin resolution for the `Cdn` pattern (design
|
|
3
|
+
* 2026-08-18 cdn-app-origin, D3). Given a fronted ECS compute, resolve a
|
|
4
|
+
* TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile, decide
|
|
5
|
+
* who owns its DNS record (P1: the construct that declares a hostname owns
|
|
6
|
+
* its record — a `routing[].host` hostname is the compute's; a derived or
|
|
7
|
+
* free hostname is the Cdn's), and validate certificate coverage and
|
|
8
|
+
* listener forwarding at synth — fail closed where decidable, warn where
|
|
9
|
+
* opaque or partial (P3).
|
|
10
|
+
*
|
|
11
|
+
* The resolver is memoized per compute within one `Cdn`: the default origin
|
|
12
|
+
* and every behaviour referencing the same compute share one resolution and
|
|
13
|
+
* at most one record.
|
|
14
|
+
*/
|
|
15
|
+
import type { IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
16
|
+
import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
17
|
+
import type { CdnOriginConfig } from "../../resources/aws/cdn/index.js";
|
|
18
|
+
import type { IEcsCompute } from "./interfaces/compute.js";
|
|
19
|
+
import { normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
|
|
20
|
+
export { normaliseDnsName };
|
|
21
|
+
/**
|
|
22
|
+
* Relative record label for `domain` within `zoneName` (the staticSite
|
|
23
|
+
* `recordLabelFor` convention): the zone apex maps to the canonical apex
|
|
24
|
+
* label, sub-names drop the zone suffix.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recordLabelWithin(domain: string, zoneName: string): string;
|
|
27
|
+
/** A Cdn-owned origin alias record to mint after the distribution exists. */
|
|
28
|
+
export interface EcsOriginRecordPlan {
|
|
29
|
+
hostname: string;
|
|
30
|
+
hostedZone: IHostedZone;
|
|
31
|
+
zoneName: string;
|
|
32
|
+
recordName: string;
|
|
33
|
+
loadBalancer: IApplicationLoadBalancer;
|
|
34
|
+
}
|
|
35
|
+
export interface ResolvedEcsOrigin {
|
|
36
|
+
hostname: string;
|
|
37
|
+
originConfig: CdnOriginConfig;
|
|
38
|
+
/** Present when the Cdn owns the record and `originRecord` is not "none". */
|
|
39
|
+
recordPlan?: EcsOriginRecordPlan;
|
|
40
|
+
/** True when the hostname is a compute-declared routing host (P1). */
|
|
41
|
+
computeOwnsRecord: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Explicit per-compute origin overrides: the distribution-level
|
|
45
|
+
* `originHostname`/`originRecord` for the default origin's compute, and each
|
|
46
|
+
* behaviour's own `originHostname`/`originRecord` for its compute — so every
|
|
47
|
+
* lane that can throw E3 can also cure it (design D3: behaviour origins get
|
|
48
|
+
* the same targeted validation AND the same override surface).
|
|
49
|
+
*/
|
|
50
|
+
export interface EcsOriginOverride {
|
|
51
|
+
originHostname?: string;
|
|
52
|
+
originRecord?: "alias" | "none";
|
|
53
|
+
}
|
|
54
|
+
export interface CdnEcsOriginResolverOptions {
|
|
55
|
+
cdnId: string;
|
|
56
|
+
/** The distribution's own alias names, normalised — the origin-loop guard. */
|
|
57
|
+
aliasNames: ReadonlySet<string>;
|
|
58
|
+
/** Per-compute explicit overrides — assembled by the Cdn from the
|
|
59
|
+
* distribution props (default origin) and each behaviour entry. */
|
|
60
|
+
overrides?: ReadonlyMap<IEcsCompute, EcsOriginOverride>;
|
|
61
|
+
}
|
|
62
|
+
export declare class CdnEcsOriginResolver {
|
|
63
|
+
private readonly options;
|
|
64
|
+
private readonly resolutions;
|
|
65
|
+
private warnedUnknownCoverage;
|
|
66
|
+
constructor(options: CdnEcsOriginResolverOptions);
|
|
67
|
+
resolve(compute: IEcsCompute): ResolvedEcsOrigin;
|
|
68
|
+
/** Every Cdn-owned record the resolutions so far require, one per compute. */
|
|
69
|
+
getRecordPlans(): EcsOriginRecordPlan[];
|
|
70
|
+
private resolveFresh;
|
|
71
|
+
private explicitHostnameFor;
|
|
72
|
+
private effectiveOriginRecord;
|
|
73
|
+
private selectHostname;
|
|
74
|
+
}
|