@fjall/components-infrastructure 14.2.0 → 15.0.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/apexDomainPattern.js +1 -1
- package/dist/lib/patterns/aws/cdn.d.ts +28 -3
- package/dist/lib/patterns/aws/cdn.js +93 -51
- package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +16 -7
- package/dist/lib/patterns/aws/cdnAppOrigin.js +43 -42
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +1 -1
- package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +13 -4
- package/dist/lib/patterns/aws/dnsRecordComposer.js +88 -6
- package/dist/lib/patterns/aws/domainCertificateComposer.js +35 -39
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +32 -0
- package/dist/lib/patterns/aws/patternDomain.d.ts +9 -0
- package/dist/lib/patterns/aws/patternDomain.js +60 -27
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +37 -0
- package/dist/lib/resources/aws/cdn/cloudFront.js +96 -4
- package/dist/lib/resources/aws/compute/ecsNetworking.js +74 -79
- package/dist/lib/resources/aws/compute/ingressProfile.d.ts +44 -15
- package/dist/lib/resources/aws/compute/ingressProfile.js +45 -25
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +10 -2
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +18 -9
- package/dist/lib/utils/dnsRecordRegistry.d.ts +3 -5
- package/dist/lib/utils/dnsRecordRegistry.js +11 -14
- package/dist/lib/utils/domainTypes.d.ts +11 -0
- package/dist/lib/utils/domainTypes.js +17 -0
- package/dist/lib/utils/managedDomainContext.d.ts +33 -1
- package/dist/lib/utils/managedDomainContext.js +29 -2
- package/package.json +3 -3
|
@@ -44,7 +44,7 @@ export function composeApexDomain(scope, props) {
|
|
|
44
44
|
costAllocationDomain: props.zoneName
|
|
45
45
|
});
|
|
46
46
|
if (props.records && props.records.length > 0) {
|
|
47
|
-
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records);
|
|
47
|
+
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records, props.recordIds);
|
|
48
48
|
}
|
|
49
49
|
return {
|
|
50
50
|
hostedZone: hostedZoneConstruct.hostedZone,
|
|
@@ -168,7 +168,16 @@ export type ICdnProps = S3CdnProps | AlbCdnProps | HttpCdnProps | SmartCdnProps;
|
|
|
168
168
|
*/
|
|
169
169
|
export interface SmartCdnBehaviour {
|
|
170
170
|
pathPattern: string;
|
|
171
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Origin for this path pattern. Omit to route the pattern to the
|
|
173
|
+
* distribution's DEFAULT origin — the common shape where behaviours exist
|
|
174
|
+
* to vary caching or allowed methods per path, not to change origins. An
|
|
175
|
+
* origin-less behaviour is the default behaviour varied per path, so its
|
|
176
|
+
* omitted `cachePolicy`/`allowedMethods` inherit the distribution's
|
|
177
|
+
* `cachePolicy`/`defaultAllowedMethods` (a behaviour with its own origin
|
|
178
|
+
* is an independent config and keeps the resource-layer fallbacks).
|
|
179
|
+
*/
|
|
180
|
+
origin?: Storage | AnyCompute | string;
|
|
172
181
|
cachePolicy?: CachePolicyPreset | ICachePolicy;
|
|
173
182
|
allowedMethods?: "GET_HEAD" | "GET_HEAD_OPTIONS" | "ALL";
|
|
174
183
|
/**
|
|
@@ -225,11 +234,27 @@ export declare class Cdn extends CloudFrontDistribution implements ICdn {
|
|
|
225
234
|
*/
|
|
226
235
|
private createDomainRecord;
|
|
227
236
|
/**
|
|
228
|
-
* Resolve ICdnProps to
|
|
237
|
+
* Resolve ICdnProps to the distribution props PLUS the Cdn-owned origin
|
|
238
|
+
* record plans the resolution produced — one resolution pass, one result.
|
|
229
239
|
*/
|
|
230
240
|
private static resolveProps;
|
|
231
241
|
/**
|
|
232
|
-
* Transform smart behaviours to CdnBehaviour objects.
|
|
242
|
+
* Transform smart behaviours to CdnBehaviour objects. A behaviour with no
|
|
243
|
+
* origin routes to the distribution's default origin — the same resolved
|
|
244
|
+
* config, so the resource layer memoizes them onto one distribution
|
|
245
|
+
* origin entry.
|
|
246
|
+
*
|
|
247
|
+
* Knob inheritance is asymmetric BY CONTRACT. An origin-less behaviour IS
|
|
248
|
+
* the default behaviour varied per path (the shape
|
|
249
|
+
* `SmartCdnBehaviour.origin` advertises), so its omitted
|
|
250
|
+
* `cachePolicy`/`allowedMethods` inherit the DISTRIBUTION's defaults —
|
|
251
|
+
* otherwise a caching-only behaviour on an `allowedMethods: "ALL"` API
|
|
252
|
+
* distribution would snap its path back to the resource layer's GET/HEAD
|
|
253
|
+
* fallback and 403 every POST at the edge. A behaviour WITH its own
|
|
254
|
+
* origin keeps the legacy resource-layer fallbacks (GET_HEAD /
|
|
255
|
+
* CACHING_OPTIMIZED): those deployed behaviours are byte-frozen, and an
|
|
256
|
+
* independent origin is an independent config, not a variation of the
|
|
257
|
+
* default behaviour.
|
|
233
258
|
*/
|
|
234
259
|
private static resolveBehaviours;
|
|
235
260
|
/**
|
|
@@ -4,9 +4,10 @@ import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-ta
|
|
|
4
4
|
import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
|
|
5
5
|
import { AliasRecord } from "../../resources/aws/networking/index.js";
|
|
6
6
|
import { getSafeZoneName, toPascalCase } from "../../utils/capitaliseString.js";
|
|
7
|
+
import { recordLabelWithin } from "../../utils/domainTypes.js";
|
|
7
8
|
import { resolvePatternZone, resolvePatternCloudFrontCertificate } from "./patternDomain.js";
|
|
8
9
|
import { isStorage } from "./storage.js";
|
|
9
|
-
import { CdnEcsOriginResolver,
|
|
10
|
+
import { CdnEcsOriginResolver, cdnOriginRefusal, internalAlbMessage, normaliseDnsName } from "./cdnAppOrigin.js";
|
|
10
11
|
import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
|
|
11
12
|
/**
|
|
12
13
|
* The distribution's own alias names, normalised — consumed by the
|
|
@@ -91,7 +92,7 @@ function validateCdnProps(props) {
|
|
|
91
92
|
for (const originHostname of literalOriginHostnames) {
|
|
92
93
|
if (!Token.isUnresolved(originHostname) &&
|
|
93
94
|
aliasNames.has(normaliseDnsName(originHostname))) {
|
|
94
|
-
throw
|
|
95
|
+
throw cdnOriginRefusal("C4", `CDN origin '${originHostname}' is also one of the distribution's own ` +
|
|
95
96
|
"domain names — once DNS points that name at the distribution, every " +
|
|
96
97
|
"request loops CloudFront → CloudFront. Point the origin at a " +
|
|
97
98
|
"dedicated origin hostname instead (e.g. an ECS service " +
|
|
@@ -141,10 +142,8 @@ function validateCdnProps(props) {
|
|
|
141
142
|
}
|
|
142
143
|
const profile = computeRef?.getIngressProfile();
|
|
143
144
|
if (profile?.internal) {
|
|
144
|
-
// E2 — same defect as the construct-origin lane
|
|
145
|
-
throw
|
|
146
|
-
"reaches origins over the public internet and cannot address an " +
|
|
147
|
-
"internal ALB. Make the load balancer internet-facing.");
|
|
145
|
+
// E2 — same defect as the construct-origin lane, same shared message.
|
|
146
|
+
throw cdnOriginRefusal("E2", internalAlbMessage("CDN alb origin: the load balancer", "Make the load balancer internet-facing."));
|
|
148
147
|
}
|
|
149
148
|
const effectiveProtocol = props.protocolPolicy ?? "HTTPS_ONLY";
|
|
150
149
|
if (effectiveProtocol !== "HTTP_ONLY") {
|
|
@@ -159,16 +158,16 @@ function validateCdnProps(props) {
|
|
|
159
158
|
: 'Use originType "auto" with the compute as origin — the ' +
|
|
160
159
|
"cluster's only listener is HTTPS (443), so the sole working " +
|
|
161
160
|
"path is a certificate-covered origin hostname.";
|
|
162
|
-
throw
|
|
161
|
+
throw cdnOriginRefusal("E9", `CDN alb origin with protocolPolicy '${effectiveProtocol}' can ` +
|
|
163
162
|
"never complete a TLS handshake: CloudFront validates the origin " +
|
|
164
163
|
"certificate against the raw ELB hostname " +
|
|
165
164
|
"(*.elb.amazonaws.com), which no ACM certificate covers — every " +
|
|
166
165
|
`origin fetch fails with a 502. ${cure}`);
|
|
167
166
|
}
|
|
168
167
|
if (profile !== undefined && profile.listenerPort === 443) {
|
|
169
|
-
//
|
|
168
|
+
// E9b (HTTP_ONLY variant) — nothing listens on 80: a connect-timeout
|
|
170
169
|
// 504 instead of a TLS 502, equally broken.
|
|
171
|
-
throw
|
|
170
|
+
throw cdnOriginRefusal("E9b", 'CDN alb origin with protocolPolicy "HTTP_ONLY": the cluster\'s ' +
|
|
172
171
|
"only listener is HTTPS (443) — a certificate resolved, so " +
|
|
173
172
|
"nothing listens on port 80 and every origin fetch times out. " +
|
|
174
173
|
'Use originType "auto" with the compute as origin instead.');
|
|
@@ -221,14 +220,16 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
221
220
|
// ECS construct-reference origins resolve through one memoized resolver
|
|
222
221
|
// (design 2026-08-18 cdn-app-origin, D3): the default origin and every
|
|
223
222
|
// behaviour referencing the same compute share one resolution and at
|
|
224
|
-
// most one origin record.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
223
|
+
// most one origin record. The record plans ride the resolution RESULT —
|
|
224
|
+
// they exist only once every origin has resolved, so returning them from
|
|
225
|
+
// resolveProps makes the ordering a property of the data flow rather
|
|
226
|
+
// than a call-sequence convention on the resolver instance.
|
|
227
|
+
const resolved = Cdn.resolveProps(scope, id, props, domain);
|
|
228
|
+
super(scope, id, resolved.distributionProps);
|
|
228
229
|
if (domain !== undefined && props.domainConfig !== undefined) {
|
|
229
230
|
this.createDomainRecord(id, props.domainConfig, domain);
|
|
230
231
|
}
|
|
231
|
-
this.createOriginRecords(
|
|
232
|
+
this.createOriginRecords(resolved.recordPlans);
|
|
232
233
|
}
|
|
233
234
|
static createEcsOriginResolver(id, props) {
|
|
234
235
|
return new CdnEcsOriginResolver({
|
|
@@ -283,6 +284,13 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
283
284
|
behaviour.originRecord === undefined) {
|
|
284
285
|
continue;
|
|
285
286
|
}
|
|
287
|
+
if (behaviour.origin === undefined) {
|
|
288
|
+
throw new Error(`CDN '${id}': behaviour '${behaviour.pathPattern}' sets ` +
|
|
289
|
+
"'originHostname'/'originRecord' but no 'origin' — an " +
|
|
290
|
+
"origin-less behaviour routes to the distribution's default " +
|
|
291
|
+
"origin, whose overrides live on the distribution props. Move " +
|
|
292
|
+
"the override there, or give the behaviour its own origin.");
|
|
293
|
+
}
|
|
286
294
|
if (typeof behaviour.origin === "string" ||
|
|
287
295
|
!isCompute(behaviour.origin) ||
|
|
288
296
|
!isEcsCompute(behaviour.origin)) {
|
|
@@ -315,16 +323,16 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
315
323
|
// two DIFFERENT computes resolved the same name — an inherently invalid
|
|
316
324
|
// shape (one alias record cannot target two load balancers). Refuse it
|
|
317
325
|
// with the cure instead of letting CDK's duplicate-construct-id throw.
|
|
318
|
-
const
|
|
326
|
+
const seenHostnames = new Set();
|
|
319
327
|
for (const plan of plans) {
|
|
320
328
|
const key = normaliseDnsName(plan.hostname);
|
|
321
|
-
if (
|
|
329
|
+
if (seenHostnames.has(key)) {
|
|
322
330
|
throw new Error(`CDN '${this.node.id}': two ECS compute origins resolved the ` +
|
|
323
331
|
`same origin hostname '${plan.hostname}' — one alias record ` +
|
|
324
332
|
"cannot target two load balancers. Give one of them a " +
|
|
325
333
|
"distinct originHostname.");
|
|
326
334
|
}
|
|
327
|
-
|
|
335
|
+
seenHostnames.add(key);
|
|
328
336
|
}
|
|
329
337
|
for (const plan of plans) {
|
|
330
338
|
const safeHost = toPascalCase(getSafeZoneName(plan.hostname));
|
|
@@ -385,26 +393,35 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
385
393
|
});
|
|
386
394
|
}
|
|
387
395
|
/**
|
|
388
|
-
* Resolve ICdnProps to
|
|
396
|
+
* Resolve ICdnProps to the distribution props PLUS the Cdn-owned origin
|
|
397
|
+
* record plans the resolution produced — one resolution pass, one result.
|
|
389
398
|
*/
|
|
390
|
-
static resolveProps(scope, id, props, domain
|
|
399
|
+
static resolveProps(scope, id, props, domain) {
|
|
400
|
+
const ecsOriginResolver = Cdn.createEcsOriginResolver(id, props);
|
|
391
401
|
const defaultOrigin = Cdn.resolveDefaultOrigin(props, ecsOriginResolver);
|
|
392
|
-
const behaviours = Cdn.resolveBehaviours(props.behaviours, ecsOriginResolver
|
|
402
|
+
const behaviours = Cdn.resolveBehaviours(props.behaviours, defaultOrigin, ecsOriginResolver, {
|
|
403
|
+
cachePolicy: props.cachePolicy,
|
|
404
|
+
allowedMethods: props.defaultAllowedMethods
|
|
405
|
+
});
|
|
393
406
|
const appName = props.appName;
|
|
394
407
|
const domainNames = props.domainNames ??
|
|
395
408
|
(props.domainConfig !== undefined
|
|
396
409
|
? [props.domainConfig.domainName]
|
|
397
410
|
: undefined);
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
411
|
+
// ANY origin resolved through the ECS profile lane serves a hostname
|
|
412
|
+
// that differs from the viewer-facing aliases, so host fidelity via
|
|
413
|
+
// x-forwarded-host is the sensible default (design D3 step 9) — for a
|
|
414
|
+
// behaviour's ECS origin exactly as for the default origin, since the
|
|
415
|
+
// one viewer-request function rides every behaviour. Explicit
|
|
416
|
+
// `forwardHostHeader: false` wins.
|
|
417
|
+
const hasEcsOrigin = (props.originType === "auto" &&
|
|
403
418
|
isCompute(props.origin) &&
|
|
404
|
-
isEcsCompute(props.origin)
|
|
405
|
-
(
|
|
406
|
-
|
|
407
|
-
|
|
419
|
+
isEcsCompute(props.origin)) ||
|
|
420
|
+
(props.behaviours ?? []).some((behaviour) => behaviour.origin !== undefined &&
|
|
421
|
+
typeof behaviour.origin !== "string" &&
|
|
422
|
+
isCompute(behaviour.origin) &&
|
|
423
|
+
isEcsCompute(behaviour.origin));
|
|
424
|
+
const impliedForwardHostHeader = hasEcsOrigin && (domainNames?.length ?? 0) > 0 ? true : undefined;
|
|
408
425
|
const certificate = props.certificate ??
|
|
409
426
|
(props.certificateArn
|
|
410
427
|
? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
|
|
@@ -419,35 +436,60 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
419
436
|
}
|
|
420
437
|
: {};
|
|
421
438
|
return {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
439
|
+
distributionProps: {
|
|
440
|
+
appName,
|
|
441
|
+
defaultOrigin,
|
|
442
|
+
defaultCachePolicy: props.cachePolicy,
|
|
443
|
+
defaultAllowedMethods: props.defaultAllowedMethods,
|
|
444
|
+
behaviours,
|
|
445
|
+
domainNames,
|
|
446
|
+
certificate,
|
|
447
|
+
comment: props.comment,
|
|
448
|
+
enableLogging: props.enableLogging,
|
|
449
|
+
logBucket: props.logBucket,
|
|
450
|
+
priceClass: props.priceClass,
|
|
451
|
+
forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
|
|
452
|
+
accessGate: props.accessGate,
|
|
453
|
+
...s3Routing
|
|
454
|
+
},
|
|
455
|
+
recordPlans: ecsOriginResolver.getRecordPlans()
|
|
436
456
|
};
|
|
437
457
|
}
|
|
438
458
|
/**
|
|
439
|
-
* Transform smart behaviours to CdnBehaviour objects.
|
|
459
|
+
* Transform smart behaviours to CdnBehaviour objects. A behaviour with no
|
|
460
|
+
* origin routes to the distribution's default origin — the same resolved
|
|
461
|
+
* config, so the resource layer memoizes them onto one distribution
|
|
462
|
+
* origin entry.
|
|
463
|
+
*
|
|
464
|
+
* Knob inheritance is asymmetric BY CONTRACT. An origin-less behaviour IS
|
|
465
|
+
* the default behaviour varied per path (the shape
|
|
466
|
+
* `SmartCdnBehaviour.origin` advertises), so its omitted
|
|
467
|
+
* `cachePolicy`/`allowedMethods` inherit the DISTRIBUTION's defaults —
|
|
468
|
+
* otherwise a caching-only behaviour on an `allowedMethods: "ALL"` API
|
|
469
|
+
* distribution would snap its path back to the resource layer's GET/HEAD
|
|
470
|
+
* fallback and 403 every POST at the edge. A behaviour WITH its own
|
|
471
|
+
* origin keeps the legacy resource-layer fallbacks (GET_HEAD /
|
|
472
|
+
* CACHING_OPTIMIZED): those deployed behaviours are byte-frozen, and an
|
|
473
|
+
* independent origin is an independent config, not a variation of the
|
|
474
|
+
* default behaviour.
|
|
440
475
|
*/
|
|
441
|
-
static resolveBehaviours(behaviours, ecsOriginResolver) {
|
|
476
|
+
static resolveBehaviours(behaviours, defaultOrigin, ecsOriginResolver, distributionDefaults) {
|
|
442
477
|
if (!behaviours || behaviours.length === 0) {
|
|
443
478
|
return undefined;
|
|
444
479
|
}
|
|
445
|
-
return behaviours.map((behaviour) =>
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
480
|
+
return behaviours.map((behaviour) => behaviour.origin === undefined
|
|
481
|
+
? {
|
|
482
|
+
pathPattern: behaviour.pathPattern,
|
|
483
|
+
origin: defaultOrigin,
|
|
484
|
+
cachePolicy: behaviour.cachePolicy ?? distributionDefaults.cachePolicy,
|
|
485
|
+
allowedMethods: behaviour.allowedMethods ?? distributionDefaults.allowedMethods
|
|
486
|
+
}
|
|
487
|
+
: {
|
|
488
|
+
pathPattern: behaviour.pathPattern,
|
|
489
|
+
origin: Cdn.detectOriginFromResource(behaviour.origin, ecsOriginResolver),
|
|
490
|
+
cachePolicy: behaviour.cachePolicy,
|
|
491
|
+
allowedMethods: behaviour.allowedMethods
|
|
492
|
+
});
|
|
451
493
|
}
|
|
452
494
|
/**
|
|
453
495
|
* Resolve the default origin from ICdnProps.
|
|
@@ -18,12 +18,6 @@ import type { CdnOriginConfig } from "../../resources/aws/cdn/index.js";
|
|
|
18
18
|
import type { IEcsCompute } from "./interfaces/compute.js";
|
|
19
19
|
import { normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
|
|
20
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
21
|
/** A Cdn-owned origin alias record to mint after the distribution exists. */
|
|
28
22
|
export interface EcsOriginRecordPlan {
|
|
29
23
|
hostname: string;
|
|
@@ -59,10 +53,25 @@ export interface CdnEcsOriginResolverOptions {
|
|
|
59
53
|
* distribution props (default origin) and each behaviour entry. */
|
|
60
54
|
overrides?: ReadonlyMap<IEcsCompute, EcsOriginOverride>;
|
|
61
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The origin-lane refusal/warning catalogue (design 2026-08-18
|
|
58
|
+
* cdn-app-origin § E-catalogue; C4 is the origin-loop guard from design
|
|
59
|
+
* 2026-08-17 cdn-domain-ownership). Every catalogue verdict is thrown or
|
|
60
|
+
* logged through the helpers below so its code rides at the head of the
|
|
61
|
+
* message — a synth or deploy log line maps straight back to the catalogue
|
|
62
|
+
* entry, and its cure, without a source dive.
|
|
63
|
+
*/
|
|
64
|
+
export type CdnOriginIssueCode = "C4" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6" | "E7" | "E8" | "E9" | "E9b" | "E10" | "E11" | "W1" | "W2";
|
|
65
|
+
export declare function cdnOriginRefusal(code: CdnOriginIssueCode, message: string): Error;
|
|
66
|
+
export declare function cdnOriginWarning(code: CdnOriginIssueCode, message: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* E2's invariant middle, shared by the construct-origin and alb lanes —
|
|
69
|
+
* the lanes differ only in subject and cure, never in the defect statement.
|
|
70
|
+
*/
|
|
71
|
+
export declare function internalAlbMessage(subject: string, cure: string): string;
|
|
62
72
|
export declare class CdnEcsOriginResolver {
|
|
63
73
|
private readonly options;
|
|
64
74
|
private readonly resolutions;
|
|
65
|
-
private warnedUnknownCoverage;
|
|
66
75
|
constructor(options: CdnEcsOriginResolverOptions);
|
|
67
76
|
resolve(compute: IEcsCompute): ResolvedEcsOrigin;
|
|
68
77
|
/** Every Cdn-owned record the resolutions so far require, one per compute. */
|
|
@@ -15,21 +15,8 @@
|
|
|
15
15
|
import { Token } from "aws-cdk-lib";
|
|
16
16
|
import { certificateCovers, forwardingVerdict, normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
|
|
17
17
|
export { normaliseDnsName };
|
|
18
|
-
import {
|
|
19
|
-
import { isWithinZone } from "../../utils/domainTypes.js";
|
|
18
|
+
import { isWithinZone, recordLabelWithin } from "../../utils/domainTypes.js";
|
|
20
19
|
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
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 function recordLabelWithin(domain, zoneName) {
|
|
27
|
-
if (domain === zoneName) {
|
|
28
|
-
return DNS_APEX;
|
|
29
|
-
}
|
|
30
|
-
const suffix = `.${zoneName}`;
|
|
31
|
-
return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
|
|
32
|
-
}
|
|
33
20
|
const SHARED_CURE = "declare the hostname as a routing host on the receiving service " +
|
|
34
21
|
"(services[].routing[].host) — one declaration adds the certificate SAN " +
|
|
35
22
|
"(cluster-minted certificates), the listener rule, and the DNS record. " +
|
|
@@ -37,10 +24,23 @@ const SHARED_CURE = "declare the hostname as a routing host on the receiving ser
|
|
|
37
24
|
"default action from forward to fixed-404, so a cluster serving its " +
|
|
38
25
|
"domain through the default action must keep a forwarding path for it, " +
|
|
39
26
|
'e.g. routing: [{ path: "/*" }, { host: "<origin hostname>" }].';
|
|
27
|
+
export function cdnOriginRefusal(code, message) {
|
|
28
|
+
return new Error(`[${code}] ${message}`);
|
|
29
|
+
}
|
|
30
|
+
export function cdnOriginWarning(code, message) {
|
|
31
|
+
FjallLogger.warn(`[${code}] ${message}`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* E2's invariant middle, shared by the construct-origin and alb lanes —
|
|
35
|
+
* the lanes differ only in subject and cure, never in the defect statement.
|
|
36
|
+
*/
|
|
37
|
+
export function internalAlbMessage(subject, cure) {
|
|
38
|
+
return (`${subject} is internal — CloudFront reaches origins over the public ` +
|
|
39
|
+
`internet and cannot address an internal ALB. ${cure}`);
|
|
40
|
+
}
|
|
40
41
|
export class CdnEcsOriginResolver {
|
|
41
42
|
options;
|
|
42
43
|
resolutions = new Map();
|
|
43
|
-
warnedUnknownCoverage = false;
|
|
44
44
|
constructor(options) {
|
|
45
45
|
this.options = options;
|
|
46
46
|
}
|
|
@@ -68,32 +68,31 @@ export class CdnEcsOriginResolver {
|
|
|
68
68
|
const profile = compute.getIngressProfile();
|
|
69
69
|
if (profile === undefined) {
|
|
70
70
|
// E1 — pre-existing shape, kept: a cluster with no ALB cannot origin.
|
|
71
|
-
throw
|
|
71
|
+
throw cdnOriginRefusal("E1", `CDN '${cdnId}': the ECS compute origin has no load balancer. ` +
|
|
72
72
|
"Enable loadBalancer in the compute configuration, or front a " +
|
|
73
73
|
"different resource.");
|
|
74
74
|
}
|
|
75
75
|
if (profile.internal) {
|
|
76
76
|
// E2 — CloudFront has no path into a VPC-internal ALB.
|
|
77
|
-
throw
|
|
78
|
-
"
|
|
79
|
-
"address an internal ALB. Make the cluster's load balancer " +
|
|
80
|
-
'internet-facing (drop loadBalancer: "internal"), or front a ' +
|
|
81
|
-
"different resource.");
|
|
77
|
+
throw cdnOriginRefusal("E2", internalAlbMessage(`CDN '${cdnId}': the ECS compute origin's load balancer`, "Make the cluster's load balancer internet-facing (drop " +
|
|
78
|
+
'loadBalancer: "internal"), or front a different resource.'));
|
|
82
79
|
}
|
|
83
80
|
const { hostname, computeOwnsRecord } = this.selectHostname(compute, profile);
|
|
84
81
|
// Origin-loop guard (C4, extended): the resolved hostname must not be
|
|
85
82
|
// one of the distribution's own alias names.
|
|
86
83
|
if (this.options.aliasNames.has(normaliseDnsName(hostname))) {
|
|
87
|
-
throw
|
|
84
|
+
throw cdnOriginRefusal("C4", `CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
|
|
88
85
|
"of the distribution's own domain names — once DNS points that " +
|
|
89
86
|
"name at the distribution, every request loops CloudFront → " +
|
|
90
87
|
"CloudFront. Pick a dedicated origin hostname (originHostname), " +
|
|
91
88
|
"or serve the alias from the compute directly.");
|
|
92
89
|
}
|
|
93
90
|
if (profile.listenerPort === 80) {
|
|
94
|
-
// E4
|
|
95
|
-
//
|
|
96
|
-
|
|
91
|
+
// E4 (port-80 arm) — the same designed condition as the no-domain arm
|
|
92
|
+
// in selectHostname: no domain anywhere ⇒ no certificate ⇒ the
|
|
93
|
+
// listener serves HTTP only, and a CloudFront origin fetch over HTTPS
|
|
94
|
+
// has nothing to shake hands with. One code, one cure.
|
|
95
|
+
throw cdnOriginRefusal("E4", `CDN '${cdnId}': the ECS compute origin's listener serves HTTP only ` +
|
|
97
96
|
"(the cluster has no domain, so no certificate resolved). A " +
|
|
98
97
|
"CloudFront origin needs a TLS-valid HTTPS origin. Give the " +
|
|
99
98
|
"cluster a domain (cluster.domainConfig), or use originType " +
|
|
@@ -112,7 +111,7 @@ export class CdnEcsOriginResolver {
|
|
|
112
111
|
profile.zoneName === undefined ||
|
|
113
112
|
!isWithinZone(hostname, profile.zoneName)) {
|
|
114
113
|
// E5 — a record we cannot mint: outside the cluster's zone.
|
|
115
|
-
throw
|
|
114
|
+
throw cdnOriginRefusal("E5", `CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
|
|
116
115
|
`origin's hosted zone ('${profile.zoneName ?? "none"}'), so its ` +
|
|
117
116
|
"record cannot be minted here. Use a hostname inside the zone, " +
|
|
118
117
|
'or set originRecord: "none" and manage the record where the ' +
|
|
@@ -130,15 +129,17 @@ export class CdnEcsOriginResolver {
|
|
|
130
129
|
if (coverage === "not-covered") {
|
|
131
130
|
// E6 — provably uncovered: every attached certificate is enumerated
|
|
132
131
|
// and none matches. TLS to the origin fails on every request.
|
|
133
|
-
throw
|
|
132
|
+
throw cdnOriginRefusal("E6", `CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
|
|
134
133
|
`'${hostname}' (attached certificates cover: ` +
|
|
135
134
|
`${profile.certificateCoverage.kind === "unknown" ? "unknown" : profile.certificateCoverage.hostnames.join(", ") || "nothing"}). ` +
|
|
136
135
|
`Every origin fetch would fail TLS validation. Cure: ${SHARED_CURE}`);
|
|
137
136
|
}
|
|
138
|
-
if (coverage === "unknown"
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
137
|
+
if (coverage === "unknown") {
|
|
138
|
+
// W1 — opaque certificates: not decidable at synth. Once per compute
|
|
139
|
+
// by construction: resolveFresh is memoized per compute, so every
|
|
140
|
+
// opaque compute names its own hostname exactly once (the old
|
|
141
|
+
// resolver-wide latch swallowed every compute after the first).
|
|
142
|
+
cdnOriginWarning("W1", `CDN '${cdnId}': cannot verify that the ECS origin's listener ` +
|
|
142
143
|
`certificates cover '${hostname}' (imported certificate ARNs are ` +
|
|
143
144
|
"opaque at synth). If the cluster uses a managed domain, redeploy " +
|
|
144
145
|
"the domain stack with engine >= 14.2.0 so it publishes " +
|
|
@@ -150,13 +151,13 @@ export class CdnEcsOriginResolver {
|
|
|
150
151
|
if (verdict.verdict === "none") {
|
|
151
152
|
// E7 — the listener's default action is a fixed 404 and no rule
|
|
152
153
|
// forwards the hostname: every origin fetch answers 404.
|
|
153
|
-
throw
|
|
154
|
+
throw cdnOriginRefusal("E7", `CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
|
|
154
155
|
`'${hostname}' — its default action is a fixed 404 and no routing ` +
|
|
155
156
|
`rule forwards that hostname. Cure: ${SHARED_CURE}`);
|
|
156
157
|
}
|
|
157
158
|
if (verdict.verdict === "partial") {
|
|
158
159
|
// W2 — some paths forward, others hit the fixed-404 default.
|
|
159
|
-
|
|
160
|
+
cdnOriginWarning("W2", `CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
|
|
160
161
|
`some paths (matching rules: ${verdict.patterns.join("; ")}); ` +
|
|
161
162
|
"requests outside those patterns answer the listener's fixed-404 " +
|
|
162
163
|
"default. Verify the patterns against the distribution's " +
|
|
@@ -195,7 +196,7 @@ export class CdnEcsOriginResolver {
|
|
|
195
196
|
// E10 — redirect hosts never serve traffic: the listener answers
|
|
196
197
|
// them with a 301 to the cluster domain, so an origin pointed there
|
|
197
198
|
// loops every viewer request through CloudFront → 301 → CloudFront.
|
|
198
|
-
throw
|
|
199
|
+
throw cdnOriginRefusal("E10", `CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
|
|
199
200
|
"entry on the ECS origin — the listener 301s it to " +
|
|
200
201
|
`'${profile.domainName ?? "the cluster domain"}' and it never ` +
|
|
201
202
|
"serves traffic. Use a served hostname: a routing host, or the " +
|
|
@@ -204,7 +205,7 @@ export class CdnEcsOriginResolver {
|
|
|
204
205
|
const computeOwnsRecord = profile.routedHosts.some((host) => normaliseDnsName(host) === explicitNormalised);
|
|
205
206
|
if (computeOwnsRecord && originRecord !== undefined) {
|
|
206
207
|
// E8 — the compute already owns that hostname's record.
|
|
207
|
-
throw
|
|
208
|
+
throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but originHostname ` +
|
|
208
209
|
`'${explicit}' is a routing host — the compute already owns its ` +
|
|
209
210
|
"record (P1: the construct that declares a hostname owns its " +
|
|
210
211
|
"record). Drop originRecord.");
|
|
@@ -218,7 +219,7 @@ export class CdnEcsOriginResolver {
|
|
|
218
219
|
if (candidates.length === 1 && candidates[0] !== undefined) {
|
|
219
220
|
if (originRecord !== undefined) {
|
|
220
221
|
// E8 — same ownership rule as the explicit arm.
|
|
221
|
-
throw
|
|
222
|
+
throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
|
|
222
223
|
`routing host '${candidates[0]}' — the compute already owns its ` +
|
|
223
224
|
"record. Drop originRecord.");
|
|
224
225
|
}
|
|
@@ -226,18 +227,18 @@ export class CdnEcsOriginResolver {
|
|
|
226
227
|
}
|
|
227
228
|
if (candidates.length > 1) {
|
|
228
229
|
// E3 — ambiguous: several served hostnames could be the origin.
|
|
229
|
-
throw
|
|
230
|
+
throw cdnOriginRefusal("E3", `CDN '${cdnId}': the ECS origin serves several hostnames ` +
|
|
230
231
|
`(${candidates.join(", ")}) and the origin is ambiguous. Set ` +
|
|
231
232
|
"originHostname — on the distribution for its default origin, or " +
|
|
232
233
|
"on the behaviour entry — to the one the distribution should " +
|
|
233
234
|
"fetch from.");
|
|
234
235
|
}
|
|
235
236
|
if (profile.domainName === undefined) {
|
|
236
|
-
// E4 — nothing to derive from (and nothing a viewer
|
|
237
|
-
// TLS anyway; the no-domain listener is HTTP-only,
|
|
238
|
-
// originHostname would only trade this error for the
|
|
239
|
-
// the catalogue cures are the honest ones).
|
|
240
|
-
throw
|
|
237
|
+
// E4 (no-domain arm) — nothing to derive from (and nothing a viewer
|
|
238
|
+
// could reach over TLS anyway; the no-domain listener is HTTP-only,
|
|
239
|
+
// so an explicit originHostname would only trade this error for the
|
|
240
|
+
// port-80 arm — the catalogue cures are the honest ones).
|
|
241
|
+
throw cdnOriginRefusal("E4", `CDN '${cdnId}': the ECS compute origin has no domain to derive an ` +
|
|
241
242
|
"origin hostname from (and no certificate, so nothing serves " +
|
|
242
243
|
"HTTPS). Give the cluster a domain (cluster.domainConfig), or " +
|
|
243
244
|
'use originType "alb" with protocolPolicy "HTTP_ONLY" if ' +
|
|
@@ -76,7 +76,7 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
76
76
|
const nameServers = hostedZoneConstruct.nameServers ?? props.adoptedNameServers ?? [];
|
|
77
77
|
const records = props.records ?? [];
|
|
78
78
|
if (records.length > 0) {
|
|
79
|
-
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
|
|
79
|
+
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records, props.recordIds);
|
|
80
80
|
}
|
|
81
81
|
// Step 2 of the R2 two-step gate: issuing the cert before the delegation
|
|
82
82
|
// NS has propagated hangs ACM DNS-validation, so "zone" stops here.
|
|
@@ -11,8 +11,17 @@ import type { DnsRecord } from "./interfaces/domain.js";
|
|
|
11
11
|
* runtime guard catches user-crafted `{ kind, ... }` literals that lack
|
|
12
12
|
* `bind()`.
|
|
13
13
|
*
|
|
14
|
-
* Construct-id
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Construct-id formulas (`recordIds` on `DomainCommonProps`):
|
|
15
|
+
* - `"indexed"` (default): `${safeZone}${safeName}${type}Record${index}` —
|
|
16
|
+
* byte-identical to the legacy composer, an eject-contract invariant
|
|
17
|
+
* (Phase 3 depends on stable IDs). Position-coupled, so the records list
|
|
18
|
+
* is append-only for deployed zones.
|
|
19
|
+
* - `"stable"`: `${safeZone}${safeName}${type}Record` — position-free.
|
|
20
|
+
* {@link assertStableIdsDerivable} refuses the two shapes that would
|
|
21
|
+
* collide, with cures, before CDK's opaque duplicate-construct-id error
|
|
22
|
+
* can fire.
|
|
23
|
+
* A per-record `id` (PascalCase alphanumeric) replaces the `safeName`
|
|
24
|
+
* segment in either mode — the escape hatch the stable-mode collision
|
|
25
|
+
* refusal names.
|
|
17
26
|
*/
|
|
18
|
-
export declare function composeTypedDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecord[]): void;
|
|
27
|
+
export declare function composeTypedDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecord[], recordIds?: "indexed" | "stable"): void;
|