@fjall/components-infrastructure 14.2.0 → 14.3.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
|
@@ -8,9 +8,9 @@ import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
|
8
8
|
import { DomainCertificate } from "../networking/domainCertificate.js";
|
|
9
9
|
import { AliasRecord } from "../networking/dnsRecord/aliasRecord.js";
|
|
10
10
|
import { SecurityGroup } from "../networking/securityGroup.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { computeListenerDefault404 } from "./ingressProfile.js";
|
|
11
|
+
import { isManagedDomainBinding, isWithinZone, recordLabelWithin } from "../../../utils/domainTypes.js";
|
|
12
|
+
import { readInjectedManagedDomainCoverage, resolveEffectiveManagedDomain } from "../../../utils/managedDomainContext.js";
|
|
13
|
+
import { computeListenerDefault404, planListenerRules } from "./ingressProfile.js";
|
|
14
14
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
15
15
|
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
16
16
|
import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
|
|
@@ -182,12 +182,6 @@ function preWrapperLogicalId(scope, id) {
|
|
|
182
182
|
.slice(0, 240);
|
|
183
183
|
return human + hash;
|
|
184
184
|
}
|
|
185
|
-
/** Record label for `fqdn` relative to `zoneName` (apex → `@`). */
|
|
186
|
-
function recordLabelWithin(fqdn, zoneName) {
|
|
187
|
-
return fqdn === zoneName
|
|
188
|
-
? DNS_APEX
|
|
189
|
-
: fqdn.slice(0, fqdn.length - zoneName.length - 1);
|
|
190
|
-
}
|
|
191
185
|
export function addHostedZone(ctx, loadBalancer) {
|
|
192
186
|
const props = ctx.props;
|
|
193
187
|
const domainConfig = props.cluster?.domainConfig;
|
|
@@ -198,31 +192,25 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
198
192
|
let hostedZone;
|
|
199
193
|
let zoneName;
|
|
200
194
|
let certificate;
|
|
201
|
-
// D2 —
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
if (managed) {
|
|
207
|
-
zoneName =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// Export-name fallback (bare-CDK synth): same account, same region only.
|
|
221
|
-
hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
|
|
222
|
-
hostedZoneId: Fn.importValue(managed.hostedZoneIdExport),
|
|
223
|
-
zoneName: managed.zoneName
|
|
224
|
-
});
|
|
225
|
-
certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, Fn.importValue(managed.certificateArnExport));
|
|
195
|
+
// D2 — the shared precedence and zone-id branch live in
|
|
196
|
+
// resolveEffectiveManagedDomain (explicit beats injected; binding literal
|
|
197
|
+
// beats exports Fn.importValue). Bare-CDK synth has no context entry.
|
|
198
|
+
const effectiveManaged = resolveEffectiveManagedDomain(ctx.scope.node, domainConfig?.managedDomain, domainName, `Cluster '${props.clusterName}'`);
|
|
199
|
+
const managed = effectiveManaged?.managed;
|
|
200
|
+
if (effectiveManaged !== undefined && managed !== undefined) {
|
|
201
|
+
zoneName = effectiveManaged.zoneName;
|
|
202
|
+
hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
|
|
203
|
+
hostedZoneId: effectiveManaged.hostedZoneId,
|
|
204
|
+
zoneName: effectiveManaged.zoneName
|
|
205
|
+
});
|
|
206
|
+
// The regional-certificate lane stays per-form: a binding may omit the
|
|
207
|
+
// ARN (no regional certificate declared), the exports form always
|
|
208
|
+
// imports it.
|
|
209
|
+
const certificateArn = isManagedDomainBinding(managed)
|
|
210
|
+
? managed.certificateArn
|
|
211
|
+
: Fn.importValue(managed.certificateArnExport);
|
|
212
|
+
if (certificateArn !== undefined) {
|
|
213
|
+
certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, certificateArn);
|
|
226
214
|
}
|
|
227
215
|
}
|
|
228
216
|
else if (domainConfig?.hostedZone) {
|
|
@@ -328,30 +316,31 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
328
316
|
exportCertificateArn: false
|
|
329
317
|
}).certificate;
|
|
330
318
|
}
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// a false "covered" — consumers (the Cdn origin lane) error only on
|
|
339
|
-
// enumerated non-coverage and warn on opacity.
|
|
340
|
-
//
|
|
341
|
-
// Provenance gate: the coverage context describes the zone's CURRENT
|
|
342
|
-
// certificates — the same D2 resolution that produces an injected binding.
|
|
343
|
-
// An EXPLICIT `domainConfig.managedDomain` BINDING may pin an OLDER
|
|
344
|
-
// certificateArn (or usEast1CertificateArn) the current coverage does not
|
|
345
|
-
// describe, so folding it would over-claim and invert P3 (TLS fails at
|
|
346
|
-
// runtime after E6 passes the hostname as covered). Explicit bindings
|
|
347
|
-
// therefore stay opaque, exactly as if no coverage context existed. The
|
|
319
|
+
// D5 provenance gate (design 2026-08-18 cdn-app-origin): the coverage
|
|
320
|
+
// context describes the zone's CURRENT certificates — the same D2
|
|
321
|
+
// resolution that produces an injected binding. An EXPLICIT pinned BINDING
|
|
322
|
+
// may name OLDER ARNs the current coverage does not describe, so folding
|
|
323
|
+
// it would over-claim and invert P3 (TLS fails at runtime after E6 passes
|
|
324
|
+
// the hostname as covered); it stays opaque, exactly as if no coverage
|
|
325
|
+
// context existed (see EffectiveManagedDomain.explicitPinnedBinding). The
|
|
348
326
|
// explicit EXPORTS form is unaffected: Fn.importValue resolves at deploy
|
|
349
327
|
// to the zone's current certificate — the one the coverage describes.
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
const injectedCoverage = managed && !explicitPinnedBinding
|
|
353
|
-
? readInjectedManagedDomainCoverage(ctx.scope.node, managed.zoneName, `Cluster '${props.clusterName}'`)
|
|
328
|
+
const injectedCoverage = effectiveManaged !== undefined && !effectiveManaged.explicitPinnedBinding
|
|
329
|
+
? readInjectedManagedDomainCoverage(ctx.scope.node, effectiveManaged.zoneName, `Cluster '${props.clusterName}'`)
|
|
354
330
|
: undefined;
|
|
331
|
+
// One walk over the additional-certificate sources yields both the
|
|
332
|
+
// listener attachments and their coverage facts — the fold below never
|
|
333
|
+
// re-discriminates the source list.
|
|
334
|
+
const additional = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed, injectedCoverage?.usEast1CertificateHosts);
|
|
335
|
+
const additionalListenerCertificates = additional.certificates;
|
|
336
|
+
// D5 certificate coverage: fold the SAN set of every certificate the
|
|
337
|
+
// listener will carry, where knowable at synth. The cluster-minted
|
|
338
|
+
// certificate's set is the literal minted above; managed certificates are
|
|
339
|
+
// described by the CLI-injected coverage context (absent → opaque, a
|
|
340
|
+
// domain stack predating the hosts outputs); BYO imports are opaque by
|
|
341
|
+
// nature. Fail-open to "partial"/"unknown", never to a false "covered" —
|
|
342
|
+
// consumers (the Cdn origin lane) error only on enumerated non-coverage
|
|
343
|
+
// and warn on opacity.
|
|
355
344
|
const knownCertificateHosts = [];
|
|
356
345
|
let anyOpaqueCertificate = false;
|
|
357
346
|
if (domainConfig?.certificate) {
|
|
@@ -368,17 +357,8 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
368
357
|
else {
|
|
369
358
|
knownCertificateHosts.push(domainName, ...subjectAlternativeNames);
|
|
370
359
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
anyOpaqueCertificate = true;
|
|
374
|
-
}
|
|
375
|
-
else if (injectedCoverage?.usEast1CertificateHosts !== undefined) {
|
|
376
|
-
knownCertificateHosts.push(...injectedCoverage.usEast1CertificateHosts);
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
anyOpaqueCertificate = true;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
360
|
+
knownCertificateHosts.push(...additional.knownHosts);
|
|
361
|
+
anyOpaqueCertificate = anyOpaqueCertificate || additional.anyOpaque;
|
|
382
362
|
const dedupedCertificateHosts = [...new Set(knownCertificateHosts)];
|
|
383
363
|
const certificateCoverage = anyOpaqueCertificate
|
|
384
364
|
? dedupedCertificateHosts.length > 0
|
|
@@ -503,13 +483,18 @@ const US_EAST_1 = "us-east-1";
|
|
|
503
483
|
* anything else fails at synth rather than as CloudFormation's opaque
|
|
504
484
|
* deploy-time rejection.
|
|
505
485
|
*/
|
|
506
|
-
function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed) {
|
|
507
|
-
if (sources === undefined || sources.length === 0)
|
|
508
|
-
return [];
|
|
486
|
+
function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed, usEast1CertificateHosts) {
|
|
509
487
|
const certificates = [];
|
|
488
|
+
const knownHosts = [];
|
|
489
|
+
let anyOpaque = false;
|
|
490
|
+
if (sources === undefined || sources.length === 0) {
|
|
491
|
+
return { certificates, knownHosts, anyOpaque };
|
|
492
|
+
}
|
|
510
493
|
for (const source of sources) {
|
|
511
494
|
if ("certificateArn" in source) {
|
|
495
|
+
// BYO import — opaque by nature.
|
|
512
496
|
certificates.push(ListenerCertificate.fromArn(source.certificateArn));
|
|
497
|
+
anyOpaque = true;
|
|
513
498
|
continue;
|
|
514
499
|
}
|
|
515
500
|
if (managed === undefined ||
|
|
@@ -543,8 +528,16 @@ function resolveAdditionalListenerCertificates(scope, clusterName, sources, mana
|
|
|
543
528
|
"stack, or supply certificateArn for a same-region certificate.");
|
|
544
529
|
}
|
|
545
530
|
certificates.push(ListenerCertificate.fromArn(arn));
|
|
531
|
+
// The managed us-east-1 certificate's hosts, when the coverage context
|
|
532
|
+
// describes them (absent → a domain stack predating the hosts outputs).
|
|
533
|
+
if (usEast1CertificateHosts !== undefined) {
|
|
534
|
+
knownHosts.push(...usEast1CertificateHosts);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
anyOpaque = true;
|
|
538
|
+
}
|
|
546
539
|
}
|
|
547
|
-
return certificates;
|
|
540
|
+
return { certificates, knownHosts, anyOpaque };
|
|
548
541
|
}
|
|
549
542
|
export function addDirectAccessOutputs(ctx, autoScalingGroup) {
|
|
550
543
|
if (!ctx.directAccessEnabled || !autoScalingGroup)
|
|
@@ -567,15 +560,17 @@ export function addDirectAccessOutputs(ctx, autoScalingGroup) {
|
|
|
567
560
|
}
|
|
568
561
|
export function registerServiceWithALB(ctx, listener, serviceName, serviceProps, service, primaryContainer, priorityState) {
|
|
569
562
|
const containerPort = primaryContainer.containerPort;
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
563
|
+
// The single home of the listener branching (P5, ingressProfile.ts): the
|
|
564
|
+
// ingress profile's rule model derives from this same plan, so what the
|
|
565
|
+
// profile reports is what this emitter builds.
|
|
566
|
+
const servicePlan = planListenerRules(ctx.props.services, []).services.find((plan) => plan.service === serviceProps);
|
|
567
|
+
if (servicePlan === undefined) {
|
|
568
|
+
throw new Error(`Service '${serviceName}' is not in the listener rule plan — ` +
|
|
569
|
+
"registerServiceWithALB was called for a service with no container " +
|
|
570
|
+
"ports, which can never receive listener traffic.");
|
|
571
|
+
}
|
|
572
|
+
const { routingRules } = servicePlan;
|
|
576
573
|
const healthCheckPath = routingRules.find((r) => r.healthCheckPath)?.healthCheckPath ?? "/";
|
|
577
|
-
const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
578
|
-
const isSingleService = servicesWithPorts.length === 1;
|
|
579
574
|
const healthCheckConfig = isServiceEc2(serviceProps)
|
|
580
575
|
? {
|
|
581
576
|
interval: Duration.seconds(30),
|
|
@@ -591,7 +586,7 @@ export function registerServiceWithALB(ctx, listener, serviceName, serviceProps,
|
|
|
591
586
|
port: `${containerPort}`,
|
|
592
587
|
timeout: Duration.seconds(10)
|
|
593
588
|
};
|
|
594
|
-
if (
|
|
589
|
+
if (servicePlan.mode === "default") {
|
|
595
590
|
return listener.addTargets(`${serviceName}TargetGroup`, {
|
|
596
591
|
targets: [
|
|
597
592
|
service.loadBalancerTarget({
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* P5 (model the machinery, don't paraphrase it): every fact here is exported
|
|
10
10
|
* from the SAME computation the emitting code uses — `default404` is the
|
|
11
11
|
* predicate `addLoadBalancerListener` passes to the listener factory, the
|
|
12
|
-
* rule structure
|
|
13
|
-
* (including the single-service
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* rule structure derives from the `planListenerRules` plan that
|
|
13
|
+
* `registerServiceWithALB` itself emits from (including the single-service
|
|
14
|
+
* conditions-dropped branch), and the covered SAN set is the one
|
|
15
|
+
* `addHostedZone` mints. Never recompute these facts from cluster props
|
|
16
|
+
* elsewhere; extend the plan or the profile instead.
|
|
16
17
|
*/
|
|
17
18
|
import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
18
19
|
import type { IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
19
|
-
import type { EcsServiceProps } from "./ecsTypes.js";
|
|
20
|
+
import type { EcsRoutingConfig, EcsServiceProps } from "./ecsTypes.js";
|
|
20
21
|
/**
|
|
21
22
|
* What the certificates ATTACHED to the cluster's listener are known to
|
|
22
23
|
* cover at synth:
|
|
@@ -96,20 +97,48 @@ export interface EcsIngressZoneFacts {
|
|
|
96
97
|
certificateCoverage: CertificateCoverage;
|
|
97
98
|
}
|
|
98
99
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
100
|
+
* One ports-bearing service's listener treatment. `mode: "default"` — the
|
|
101
|
+
* sole ports-bearing service with ≤1 routing rule: its target group is
|
|
102
|
+
* added UNCONDITIONED and becomes the listener default action (even a
|
|
103
|
+
* declared `routing.host` does not gate it). `mode: "rules"` — every
|
|
104
|
+
* routing rule becomes a conditioned, prioritised listener rule; the first
|
|
105
|
+
* carries the target group.
|
|
106
|
+
*/
|
|
107
|
+
export interface ListenerServicePlan {
|
|
108
|
+
service: EcsServiceProps;
|
|
109
|
+
/** The service's `routing` config, normalised to an array. */
|
|
110
|
+
routingRules: EcsRoutingConfig[];
|
|
111
|
+
mode: "default" | "rules";
|
|
112
|
+
}
|
|
113
|
+
export interface ListenerRulePlan {
|
|
114
|
+
/** Ports-bearing services in declaration order (portless services never
|
|
115
|
+
* reach the listener). */
|
|
116
|
+
services: ListenerServicePlan[];
|
|
117
|
+
redirectHosts: string[];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The listener's rule structure, planned once (P5). This is the SINGLE home
|
|
121
|
+
* of the branching: `registerServiceWithALB` emits from a service's plan
|
|
122
|
+
* entry, and the ingress-profile facts below (`computeListenerDefault404`,
|
|
123
|
+
* `enumerateListenerRules`) derive from the same plan — the emitter and the
|
|
124
|
+
* model cannot drift because neither re-derives the decisions.
|
|
125
|
+
*/
|
|
126
|
+
export declare function planListenerRules(services: EcsServiceProps[], redirectHosts: string[]): ListenerRulePlan;
|
|
127
|
+
/**
|
|
128
|
+
* The listener's default-action predicate — the value
|
|
129
|
+
* `addLoadBalancerListener` passes to the listener factory as `default404`.
|
|
130
|
+
* `true` exactly when no service plan supplies an unconditioned default
|
|
131
|
+
* target group: ≥2 routes will exist (the emitter then attaches a fixed-404
|
|
132
|
+
* default and conditions every rule), or no service has a port (CDK rejects
|
|
133
|
+
* a listener with neither a default action nor targets).
|
|
104
134
|
*/
|
|
105
135
|
export declare function computeListenerDefault404(services: EcsServiceProps[]): boolean;
|
|
106
136
|
/**
|
|
107
137
|
* The listener's rule structure as `registerServiceWithALB` and
|
|
108
|
-
* `addRedirectHostRules` will emit it
|
|
109
|
-
*
|
|
110
|
-
* conditioned rule (its target group is the listener
|
|
111
|
-
*
|
|
112
|
-
* contributes a host-matched 301 rule.
|
|
138
|
+
* `addRedirectHostRules` will emit it — derived from the same
|
|
139
|
+
* `planListenerRules` plan the emitters consume: a `mode: "default"`
|
|
140
|
+
* service contributes NO conditioned rule (its target group is the listener
|
|
141
|
+
* default), and every redirect host contributes a host-matched 301 rule.
|
|
113
142
|
*/
|
|
114
143
|
export declare function enumerateListenerRules(services: EcsServiceProps[], redirectHosts: string[]): EcsIngressRule[];
|
|
115
144
|
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* P5 (model the machinery, don't paraphrase it): every fact here is exported
|
|
10
10
|
* from the SAME computation the emitting code uses — `default404` is the
|
|
11
11
|
* predicate `addLoadBalancerListener` passes to the listener factory, the
|
|
12
|
-
* rule structure
|
|
13
|
-
* (including the single-service
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* rule structure derives from the `planListenerRules` plan that
|
|
13
|
+
* `registerServiceWithALB` itself emits from (including the single-service
|
|
14
|
+
* conditions-dropped branch), and the covered SAN set is the one
|
|
15
|
+
* `addHostedZone` mints. Never recompute these facts from cluster props
|
|
16
|
+
* elsewhere; extend the plan or the profile instead.
|
|
16
17
|
*/
|
|
17
18
|
import { FjallLogger } from "../../../utils/validationLogger.js";
|
|
18
19
|
function normaliseRoutingRules(routing) {
|
|
@@ -22,35 +23,54 @@ function servicesWithPorts(services) {
|
|
|
22
23
|
return services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
|
-
* The listener's
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* The listener's rule structure, planned once (P5). This is the SINGLE home
|
|
27
|
+
* of the branching: `registerServiceWithALB` emits from a service's plan
|
|
28
|
+
* entry, and the ingress-profile facts below (`computeListenerDefault404`,
|
|
29
|
+
* `enumerateListenerRules`) derive from the same plan — the emitter and the
|
|
30
|
+
* model cannot drift because neither re-derives the decisions.
|
|
30
31
|
*/
|
|
31
|
-
export function
|
|
32
|
+
export function planListenerRules(services, redirectHosts) {
|
|
32
33
|
const withPorts = servicesWithPorts(services);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const isSingleService = withPorts.length === 1;
|
|
35
|
+
return {
|
|
36
|
+
services: withPorts.map((service) => {
|
|
37
|
+
const routingRules = normaliseRoutingRules(service.routing);
|
|
38
|
+
return {
|
|
39
|
+
service,
|
|
40
|
+
routingRules,
|
|
41
|
+
mode: isSingleService && routingRules.length <= 1
|
|
42
|
+
? "default"
|
|
43
|
+
: "rules"
|
|
44
|
+
};
|
|
45
|
+
}),
|
|
46
|
+
redirectHosts
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The listener's default-action predicate — the value
|
|
51
|
+
* `addLoadBalancerListener` passes to the listener factory as `default404`.
|
|
52
|
+
* `true` exactly when no service plan supplies an unconditioned default
|
|
53
|
+
* target group: ≥2 routes will exist (the emitter then attaches a fixed-404
|
|
54
|
+
* default and conditions every rule), or no service has a port (CDK rejects
|
|
55
|
+
* a listener with neither a default action nor targets).
|
|
56
|
+
*/
|
|
57
|
+
export function computeListenerDefault404(services) {
|
|
58
|
+
return !planListenerRules(services, []).services.some((plan) => plan.mode === "default");
|
|
36
59
|
}
|
|
37
60
|
/**
|
|
38
61
|
* The listener's rule structure as `registerServiceWithALB` and
|
|
39
|
-
* `addRedirectHostRules` will emit it
|
|
40
|
-
*
|
|
41
|
-
* conditioned rule (its target group is the listener
|
|
42
|
-
*
|
|
43
|
-
* contributes a host-matched 301 rule.
|
|
62
|
+
* `addRedirectHostRules` will emit it — derived from the same
|
|
63
|
+
* `planListenerRules` plan the emitters consume: a `mode: "default"`
|
|
64
|
+
* service contributes NO conditioned rule (its target group is the listener
|
|
65
|
+
* default), and every redirect host contributes a host-matched 301 rule.
|
|
44
66
|
*/
|
|
45
67
|
export function enumerateListenerRules(services, redirectHosts) {
|
|
46
|
-
const
|
|
47
|
-
const isSingleService = withPorts.length === 1;
|
|
68
|
+
const plan = planListenerRules(services, redirectHosts);
|
|
48
69
|
const rules = [];
|
|
49
|
-
for (const
|
|
50
|
-
|
|
51
|
-
if (isSingleService && routingRules.length <= 1)
|
|
70
|
+
for (const servicePlan of plan.services) {
|
|
71
|
+
if (servicePlan.mode === "default")
|
|
52
72
|
continue;
|
|
53
|
-
for (const rule of routingRules) {
|
|
73
|
+
for (const rule of servicePlan.routingRules) {
|
|
54
74
|
if (rule.host === undefined && rule.path === undefined)
|
|
55
75
|
continue;
|
|
56
76
|
rules.push({
|
|
@@ -60,7 +80,7 @@ export function enumerateListenerRules(services, redirectHosts) {
|
|
|
60
80
|
});
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
|
-
for (const host of redirectHosts) {
|
|
83
|
+
for (const host of plan.redirectHosts) {
|
|
64
84
|
rules.push({ host, kind: "redirect" });
|
|
65
85
|
}
|
|
66
86
|
return rules;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { ARecord as CdkARecord, type GeoLocation, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
|
|
2
|
+
import { ARecord as CdkARecord, AaaaRecord as CdkAaaaRecord, type GeoLocation, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
|
|
3
3
|
import { type DnsRecordCommonProps } from "./dnsRecordBase.js";
|
|
4
4
|
export interface AliasRecordProps extends DnsRecordCommonProps {
|
|
5
5
|
readonly target: IAliasRecordTarget;
|
|
6
|
+
/**
|
|
7
|
+
* Record-set type the alias deploys as: `"A"` (IPv4, the default — every
|
|
8
|
+
* pattern-internal alias predates this knob and is a deployed A record)
|
|
9
|
+
* or `"AAAA"` (IPv6). A declared AAAA alias MUST deploy as AAAA — mapping
|
|
10
|
+
* it to A silently answers IPv4 for an IPv6 declaration and collides with
|
|
11
|
+
* a legal dual-stack sibling in the DNS claim registry.
|
|
12
|
+
*/
|
|
13
|
+
readonly recordType?: "A" | "AAAA";
|
|
6
14
|
/**
|
|
7
15
|
* Latency routing region (CDK `RecordSetOptions.region`). Route53 requires
|
|
8
16
|
* a `setIdentifier` alongside it — callers derive one when unset.
|
|
@@ -27,7 +35,7 @@ export interface AliasRecordProps extends DnsRecordCommonProps {
|
|
|
27
35
|
readonly omitComment?: boolean;
|
|
28
36
|
}
|
|
29
37
|
export declare class AliasRecord extends Construct {
|
|
30
|
-
readonly record: CdkARecord;
|
|
38
|
+
readonly record: CdkARecord | CdkAaaaRecord;
|
|
31
39
|
readonly description: string;
|
|
32
40
|
readonly fqdn: string;
|
|
33
41
|
constructor(scope: Construct, id: string, props: AliasRecordProps);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { ARecord as CdkARecord, RecordTarget } from "aws-cdk-lib/aws-route53";
|
|
2
|
+
import { ARecord as CdkARecord, AaaaRecord as CdkAaaaRecord, RecordTarget } from "aws-cdk-lib/aws-route53";
|
|
3
3
|
import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn } from "./dnsRecordBase.js";
|
|
4
4
|
export class AliasRecord extends Construct {
|
|
5
5
|
record;
|
|
@@ -7,14 +7,17 @@ export class AliasRecord extends Construct {
|
|
|
7
7
|
fqdn;
|
|
8
8
|
constructor(scope, id, props) {
|
|
9
9
|
super(scope, id);
|
|
10
|
+
const recordType = props.recordType ?? "A";
|
|
10
11
|
this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
|
|
11
12
|
this.description =
|
|
12
13
|
props.description ?? defaultDnsComment("alias", this.fqdn);
|
|
13
|
-
// An alias record IS an A record set in Route53 — it claims
|
|
14
|
-
// an alias and a plain
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// An alias record IS an A (or AAAA) record set in Route53 — it claims
|
|
15
|
+
// its deployed type so an alias and a plain record of that type on the
|
|
16
|
+
// same name collide at synth, while dual-stack A + AAAA siblings
|
|
17
|
+
// register cleanly. The routing variant rides along: policy siblings
|
|
18
|
+
// with distinct setIdentifiers (e.g. a compute apex alias and a CDN
|
|
19
|
+
// apex alias during an ingress migration) are legal and register
|
|
20
|
+
// cleanly.
|
|
18
21
|
const routingPolicy = props.region !== undefined
|
|
19
22
|
? "latency"
|
|
20
23
|
: props.weight !== undefined
|
|
@@ -34,7 +37,7 @@ export class AliasRecord extends Construct {
|
|
|
34
37
|
.filter((part) => part !== undefined)
|
|
35
38
|
.join("/") || "*"
|
|
36
39
|
: undefined;
|
|
37
|
-
claimDnsRecord(this, props,
|
|
40
|
+
claimDnsRecord(this, props, recordType, this.fqdn, {
|
|
38
41
|
setIdentifier: props.setIdentifier,
|
|
39
42
|
routingPolicy,
|
|
40
43
|
region: props.region,
|
|
@@ -42,7 +45,9 @@ export class AliasRecord extends Construct {
|
|
|
42
45
|
});
|
|
43
46
|
// Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
|
|
44
47
|
// so we deliberately do not forward props.ttl to the underlying CDK resource.
|
|
45
|
-
|
|
48
|
+
// Both branches share the "Record" child id: an existing A alias's
|
|
49
|
+
// logical ID is byte-frozen, and a declared type is the ONLY delta.
|
|
50
|
+
const recordProps = {
|
|
46
51
|
zone: props.zone,
|
|
47
52
|
recordName: this.fqdn,
|
|
48
53
|
target: RecordTarget.fromAlias(props.target),
|
|
@@ -55,7 +60,11 @@ export class AliasRecord extends Construct {
|
|
|
55
60
|
...(props.setIdentifier !== undefined && {
|
|
56
61
|
setIdentifier: props.setIdentifier
|
|
57
62
|
})
|
|
58
|
-
}
|
|
63
|
+
};
|
|
64
|
+
this.record =
|
|
65
|
+
recordType === "AAAA"
|
|
66
|
+
? new CdkAaaaRecord(this, "Record", recordProps)
|
|
67
|
+
: new CdkARecord(this, "Record", recordProps);
|
|
59
68
|
applyDnsRecordTags(this, props);
|
|
60
69
|
}
|
|
61
70
|
}
|
|
@@ -46,11 +46,9 @@ export interface DnsRecordClaim {
|
|
|
46
46
|
*/
|
|
47
47
|
readonly recordType: string;
|
|
48
48
|
/**
|
|
49
|
-
* Routing-variant discriminator
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* presence alone makes a legal variant); a claim with neither is a simple
|
|
53
|
-
* record that tolerates no siblings.
|
|
49
|
+
* Routing-variant discriminator among sibling variants. Variant-ness
|
|
50
|
+
* itself is decided by `routingPolicy` alone (see {@link isVariantEntry});
|
|
51
|
+
* a claim without one is a simple record that tolerates no siblings.
|
|
54
52
|
*/
|
|
55
53
|
readonly setIdentifier?: string;
|
|
56
54
|
/** Policy type of a variant claim — siblings must all match. */
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { Token } from "aws-cdk-lib";
|
|
2
2
|
/**
|
|
3
|
-
* A claim is a policy variant when it declares a routing policy
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* classifying it as simple would refuse shapes Route53 holds happily.
|
|
8
|
-
*
|
|
9
|
-
* so
|
|
3
|
+
* A claim is a policy variant exactly when it declares a routing policy:
|
|
4
|
+
* CDK's RecordSet auto-generates a SetIdentifier whenever a policy
|
|
5
|
+
* (region/weight/geoLocation) is set without one, so a policy claim with no
|
|
6
|
+
* explicit setIdentifier still deploys as a legal sibling variant —
|
|
7
|
+
* classifying it as simple would refuse shapes Route53 holds happily. A
|
|
8
|
+
* setIdentifier WITHOUT a policy cannot deploy at all (CDK rejects it at
|
|
9
|
+
* synth), so it classifies as simple and the simple-vs-variant refusal —
|
|
10
|
+
* whose cure names the missing routing policy — fires first.
|
|
10
11
|
*/
|
|
11
12
|
function isVariantEntry(entry) {
|
|
12
|
-
return entry.routingPolicy !== undefined
|
|
13
|
+
return entry.routingPolicy !== undefined;
|
|
13
14
|
}
|
|
14
15
|
/** Claims per App root: claim key → claimant entries (variants share a key). */
|
|
15
16
|
let claimsByRoot = new WeakMap();
|
|
@@ -87,12 +88,8 @@ export function registerDnsRecordClaim(scope, claim) {
|
|
|
87
88
|
other.setIdentifier === entry.setIdentifier) {
|
|
88
89
|
throw collisionError(claim, recordType, other, claimantPath, `Both records carry setIdentifier '${String(entry.setIdentifier)}' — sibling routing-policy variants need distinct setIdentifiers.`);
|
|
89
90
|
}
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
// CDK rejects setIdentifier on simple records at synth).
|
|
93
|
-
if (other.routingPolicy !== undefined &&
|
|
94
|
-
entry.routingPolicy !== undefined &&
|
|
95
|
-
other.routingPolicy !== entry.routingPolicy) {
|
|
91
|
+
// Both sides passed isVariantEntry, so both declare a policy.
|
|
92
|
+
if (other.routingPolicy !== entry.routingPolicy) {
|
|
96
93
|
throw collisionError(claim, recordType, other, claimantPath, `Sibling routing-policy variants must share one policy type (got '${String(other.routingPolicy)}' vs '${String(entry.routingPolicy)}') — Route53 rejects mixed-policy siblings.`);
|
|
97
94
|
}
|
|
98
95
|
if (entry.routingPolicy === "latency" &&
|
|
@@ -8,6 +8,17 @@ export { DNS_APEX, getDomainExportNames, isManagedDomainBinding, type ManagedDom
|
|
|
8
8
|
* § Infrastructure Layer Boundaries).
|
|
9
9
|
*/
|
|
10
10
|
export declare function isWithinZone(candidate: string, zoneName: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Relative record label for `domain` within `zoneName` — the inverse of
|
|
13
|
+
* `resolveRecordFqdn` for names already known to sit inside the zone: the
|
|
14
|
+
* zone apex maps to the canonical apex label, sub-names drop the zone
|
|
15
|
+
* suffix. A name outside the zone passes through unchanged (callers
|
|
16
|
+
* validate zone membership with `isWithinZone` first). Single home shared
|
|
17
|
+
* by the patterns-layer CDN origin lane and the resources-layer ECS
|
|
18
|
+
* networking (lowest common layer per generator-standards § Infrastructure
|
|
19
|
+
* Layer Boundaries).
|
|
20
|
+
*/
|
|
21
|
+
export declare function recordLabelWithin(domain: string, zoneName: string): string;
|
|
11
22
|
/**
|
|
12
23
|
* Canonical BIND-semantics resolver for DNS record names — the single
|
|
13
24
|
* authority shared by the patterns-layer domain validation and the
|
|
@@ -14,6 +14,23 @@ export function isWithinZone(candidate, zoneName) {
|
|
|
14
14
|
}
|
|
15
15
|
return candidate.endsWith(`.${zoneName}`);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Relative record label for `domain` within `zoneName` — the inverse of
|
|
19
|
+
* `resolveRecordFqdn` for names already known to sit inside the zone: the
|
|
20
|
+
* zone apex maps to the canonical apex label, sub-names drop the zone
|
|
21
|
+
* suffix. A name outside the zone passes through unchanged (callers
|
|
22
|
+
* validate zone membership with `isWithinZone` first). Single home shared
|
|
23
|
+
* by the patterns-layer CDN origin lane and the resources-layer ECS
|
|
24
|
+
* networking (lowest common layer per generator-standards § Infrastructure
|
|
25
|
+
* Layer Boundaries).
|
|
26
|
+
*/
|
|
27
|
+
export function recordLabelWithin(domain, zoneName) {
|
|
28
|
+
if (domain === zoneName) {
|
|
29
|
+
return DNS_APEX;
|
|
30
|
+
}
|
|
31
|
+
const suffix = `.${zoneName}`;
|
|
32
|
+
return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
|
|
33
|
+
}
|
|
17
34
|
/**
|
|
18
35
|
* Canonical BIND-semantics resolver for DNS record names — the single
|
|
19
36
|
* authority shared by the patterns-layer domain validation and the
|