@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
|
@@ -9,7 +9,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
11
|
import { DNS_APEX, isManagedDomainBinding, isWithinZone } from "../../../utils/domainTypes.js";
|
|
12
|
-
import { readInjectedManagedDomainBinding } from "../../../utils/managedDomainContext.js";
|
|
12
|
+
import { readInjectedManagedDomainBinding, readInjectedManagedDomainCoverage } from "../../../utils/managedDomainContext.js";
|
|
13
|
+
import { computeListenerDefault404 } from "./ingressProfile.js";
|
|
13
14
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
14
15
|
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
15
16
|
import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
|
|
@@ -99,24 +100,15 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
|
|
|
99
100
|
}
|
|
100
101
|
export function addLoadBalancerListener(ctx, loadBalancer, certificate, additionalCertificates) {
|
|
101
102
|
const port = certificate ? 443 : 80;
|
|
102
|
-
const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
103
|
-
const willHaveMultipleRoutes = servicesWithPorts.length > 1 ||
|
|
104
|
-
servicesWithPorts.some((s) => {
|
|
105
|
-
const rules = Array.isArray(s.routing)
|
|
106
|
-
? s.routing
|
|
107
|
-
: s.routing
|
|
108
|
-
? [s.routing]
|
|
109
|
-
: [];
|
|
110
|
-
return rules.length > 1;
|
|
111
|
-
});
|
|
112
|
-
// CDK rejects listeners with neither a default action nor target groups.
|
|
113
|
-
const noServicePorts = servicesWithPorts.length === 0;
|
|
114
103
|
return addRoutingListener(loadBalancer, `${ctx.props.clusterName}Listener`, {
|
|
115
104
|
port,
|
|
116
105
|
...(certificate && { certificate }),
|
|
117
106
|
...(additionalCertificates !== undefined &&
|
|
118
107
|
additionalCertificates.length > 0 && { additionalCertificates }),
|
|
119
|
-
|
|
108
|
+
// Single home for the default-action predicate (ingressProfile.ts): the
|
|
109
|
+
// ingress profile exports the same value, so downstream verdicts model
|
|
110
|
+
// the listener the emitter actually built (design cdn-app-origin, P5).
|
|
111
|
+
default404: computeListenerDefault404(ctx.props.services)
|
|
120
112
|
});
|
|
121
113
|
}
|
|
122
114
|
/**
|
|
@@ -302,6 +294,10 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
302
294
|
}
|
|
303
295
|
}
|
|
304
296
|
const subjectAlternativeNames = [...routedHosts, ...redirectHosts].filter((h) => h !== domainName);
|
|
297
|
+
// Captured BEFORE the BYO override below: whether the managed lane
|
|
298
|
+
// attached the binding's (or export fallback's) certificate — the D5
|
|
299
|
+
// coverage context describes exactly that certificate, never a BYO one.
|
|
300
|
+
const managedCertificateAttached = certificate !== undefined;
|
|
305
301
|
if (domainConfig?.certificate) {
|
|
306
302
|
certificate = domainConfig.certificate;
|
|
307
303
|
}
|
|
@@ -333,6 +329,62 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
333
329
|
}).certificate;
|
|
334
330
|
}
|
|
335
331
|
const additionalListenerCertificates = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed);
|
|
332
|
+
// D5 certificate coverage (design 2026-08-18 cdn-app-origin): fold the SAN
|
|
333
|
+
// set of every certificate the listener will carry, where knowable at
|
|
334
|
+
// synth. The cluster-minted certificate's set is the literal minted above;
|
|
335
|
+
// managed certificates are described by the CLI-injected coverage context
|
|
336
|
+
// (absent → opaque, a domain stack predating the hosts outputs); BYO
|
|
337
|
+
// imports are opaque by nature. Fail-open to "partial"/"unknown", never to
|
|
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
|
|
348
|
+
// explicit EXPORTS form is unaffected: Fn.importValue resolves at deploy
|
|
349
|
+
// to the zone's current certificate — the one the coverage describes.
|
|
350
|
+
const explicitPinnedBinding = domainConfig?.managedDomain !== undefined &&
|
|
351
|
+
isManagedDomainBinding(domainConfig.managedDomain);
|
|
352
|
+
const injectedCoverage = managed && !explicitPinnedBinding
|
|
353
|
+
? readInjectedManagedDomainCoverage(ctx.scope.node, managed.zoneName, `Cluster '${props.clusterName}'`)
|
|
354
|
+
: undefined;
|
|
355
|
+
const knownCertificateHosts = [];
|
|
356
|
+
let anyOpaqueCertificate = false;
|
|
357
|
+
if (domainConfig?.certificate) {
|
|
358
|
+
anyOpaqueCertificate = true;
|
|
359
|
+
}
|
|
360
|
+
else if (managedCertificateAttached) {
|
|
361
|
+
if (injectedCoverage?.certificateHosts !== undefined) {
|
|
362
|
+
knownCertificateHosts.push(...injectedCoverage.certificateHosts);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
anyOpaqueCertificate = true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
knownCertificateHosts.push(domainName, ...subjectAlternativeNames);
|
|
370
|
+
}
|
|
371
|
+
for (const source of domainConfig?.additionalCertificates ?? []) {
|
|
372
|
+
if ("certificateArn" in source) {
|
|
373
|
+
anyOpaqueCertificate = true;
|
|
374
|
+
}
|
|
375
|
+
else if (injectedCoverage?.usEast1CertificateHosts !== undefined) {
|
|
376
|
+
knownCertificateHosts.push(...injectedCoverage.usEast1CertificateHosts);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
anyOpaqueCertificate = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const dedupedCertificateHosts = [...new Set(knownCertificateHosts)];
|
|
383
|
+
const certificateCoverage = anyOpaqueCertificate
|
|
384
|
+
? dedupedCertificateHosts.length > 0
|
|
385
|
+
? { kind: "partial", hostnames: dedupedCertificateHosts }
|
|
386
|
+
: { kind: "unknown" }
|
|
387
|
+
: { kind: "enumerated", hostnames: dedupedCertificateHosts };
|
|
336
388
|
let aRecord;
|
|
337
389
|
if (loadBalancer) {
|
|
338
390
|
const routingPolicy = domainConfig?.routingPolicy;
|
|
@@ -367,22 +419,28 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
367
419
|
...(geoLocation !== undefined && { geoLocation }),
|
|
368
420
|
...(setIdentifier !== undefined && { setIdentifier })
|
|
369
421
|
};
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
422
|
+
// apexRecord: "none" yields ONLY the domainName record itself (an
|
|
423
|
+
// ingress-migrated name now owned by a CDN's domainConfig) — routed-host,
|
|
424
|
+
// redirect-host records, listener, and certificates below are untouched
|
|
425
|
+
// (design 2026-08-17 cdn-domain-ownership C2).
|
|
426
|
+
if (domainConfig?.apexRecord !== "none") {
|
|
427
|
+
const apex = new AliasRecord(ctx.scope, `${props.clusterName}ARecord`, {
|
|
428
|
+
zone: hostedZone,
|
|
429
|
+
zoneName,
|
|
430
|
+
recordName: recordLabelWithin(domainName, zoneName),
|
|
431
|
+
target: new LoadBalancerTarget(loadBalancer, {
|
|
432
|
+
evaluateTargetHealth: hasRoutingPolicy
|
|
433
|
+
}),
|
|
434
|
+
// No-churn adoption: the pre-wrapper raw ARecord carried no comment,
|
|
435
|
+
// so adding one now would be a property delta on the live production
|
|
436
|
+
// apex record (flipless-retain-flip principle).
|
|
437
|
+
omitComment: true,
|
|
438
|
+
...routingProps
|
|
439
|
+
});
|
|
440
|
+
// Preserve the pre-wrapper logical ID — see preWrapperLogicalId.
|
|
441
|
+
apex.record.node.defaultChild.overrideLogicalId(preWrapperLogicalId(ctx.scope, `${props.clusterName}ARecord`));
|
|
442
|
+
aRecord = apex.record;
|
|
443
|
+
}
|
|
386
444
|
for (const host of routedHosts) {
|
|
387
445
|
if (host === domainName)
|
|
388
446
|
continue;
|
|
@@ -424,7 +482,16 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
424
482
|
}),
|
|
425
483
|
...(redirectHosts.length > 0 && {
|
|
426
484
|
redirectRules: { hostFqdns: redirectHosts, targetHost: domainName }
|
|
427
|
-
})
|
|
485
|
+
}),
|
|
486
|
+
ingress: {
|
|
487
|
+
hostedZone,
|
|
488
|
+
zoneName,
|
|
489
|
+
domainName,
|
|
490
|
+
routedHosts,
|
|
491
|
+
redirectHosts,
|
|
492
|
+
apexRecordMinted: loadBalancer !== undefined && domainConfig?.apexRecord !== "none",
|
|
493
|
+
certificateCoverage
|
|
494
|
+
}
|
|
428
495
|
};
|
|
429
496
|
}
|
|
430
497
|
const US_EAST_1 = "us-east-1";
|
|
@@ -260,6 +260,16 @@ export interface DomainBaseConfig {
|
|
|
260
260
|
* Omit for a plain alias record.
|
|
261
261
|
*/
|
|
262
262
|
routingPolicy?: EcsDomainRoutingPolicy;
|
|
263
|
+
/**
|
|
264
|
+
* The cluster's own DNS record for `domainName`. "alias" (default) mints
|
|
265
|
+
* the alias A record targeting the ALB — the long-standing behaviour.
|
|
266
|
+
* "none" keeps EVERYTHING else (certificates, HTTPS listener, redirect
|
|
267
|
+
* hosts and their records, host-routed service records) but yields the
|
|
268
|
+
* `domainName` record itself — the final state of an ingress migration
|
|
269
|
+
* where a CDN's `domainConfig` has taken the name over
|
|
270
|
+
* (design 2026-08-17 cdn-domain-ownership C2).
|
|
271
|
+
*/
|
|
272
|
+
apexRecord?: "alias" | "none";
|
|
263
273
|
/**
|
|
264
274
|
* Hostnames (FQDNs within the zone, e.g. `"www.example.com"`) that
|
|
265
275
|
* permanently redirect to `domainName` at the ALB. Each host gets an alias
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synth-time ingress profile for an ECS cluster's ALB (design 2026-08-18
|
|
3
|
+
* cdn-app-origin, D2). The profile is the one sanctioned answer to "what
|
|
4
|
+
* does this cluster's listener actually do?" for downstream constructs —
|
|
5
|
+
* today the `Cdn` construct-reference origin lane, which needs a TLS-valid
|
|
6
|
+
* origin hostname, certificate coverage, and a forwarding verdict without
|
|
7
|
+
* re-deriving any of it from props.
|
|
8
|
+
*
|
|
9
|
+
* P5 (model the machinery, don't paraphrase it): every fact here is exported
|
|
10
|
+
* from the SAME computation the emitting code uses — `default404` is the
|
|
11
|
+
* predicate `addLoadBalancerListener` passes to the listener factory, the
|
|
12
|
+
* rule structure mirrors `registerServiceWithALB`'s exact branching
|
|
13
|
+
* (including the single-service conditions-dropped branch), and the covered
|
|
14
|
+
* SAN set is the one `addHostedZone` mints. Never recompute these facts from
|
|
15
|
+
* cluster props elsewhere; extend the profile instead.
|
|
16
|
+
*/
|
|
17
|
+
import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
18
|
+
import type { IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
19
|
+
import type { EcsServiceProps } from "./ecsTypes.js";
|
|
20
|
+
/**
|
|
21
|
+
* What the certificates ATTACHED to the cluster's listener are known to
|
|
22
|
+
* cover at synth:
|
|
23
|
+
*
|
|
24
|
+
* - `enumerated` — every attached certificate's host set is synth-known
|
|
25
|
+
* (cluster-minted certs, or managed certs described by the D5 coverage
|
|
26
|
+
* context). `hostnames` is complete: a non-matching name is provably
|
|
27
|
+
* uncovered.
|
|
28
|
+
* - `partial` — `hostnames` collects the known certs' hosts, but at least
|
|
29
|
+
* one attached certificate is opaque (imported ARN with no coverage
|
|
30
|
+
* context), so a non-matching name may still be covered.
|
|
31
|
+
* - `unknown` — only opaque certificates are attached.
|
|
32
|
+
*/
|
|
33
|
+
export type CertificateCoverage = {
|
|
34
|
+
kind: "enumerated";
|
|
35
|
+
hostnames: string[];
|
|
36
|
+
} | {
|
|
37
|
+
kind: "partial";
|
|
38
|
+
hostnames: string[];
|
|
39
|
+
} | {
|
|
40
|
+
kind: "unknown";
|
|
41
|
+
};
|
|
42
|
+
export type CoverageAnswer = "covered" | "unknown" | "not-covered";
|
|
43
|
+
/**
|
|
44
|
+
* One listener rule as it will actually be emitted. `host` and `path` are
|
|
45
|
+
* ANDed when both present (`buildRoutingConditions`). Rules the emitter
|
|
46
|
+
* DROPS — a single ports-bearing service with ≤1 routing rule loses its
|
|
47
|
+
* conditions and becomes the listener default (`registerServiceWithALB`) —
|
|
48
|
+
* are deliberately absent here, exactly as they are absent from the ALB.
|
|
49
|
+
*/
|
|
50
|
+
export interface EcsIngressRule {
|
|
51
|
+
host?: string;
|
|
52
|
+
path?: string;
|
|
53
|
+
kind: "forward" | "redirect";
|
|
54
|
+
}
|
|
55
|
+
export interface EcsIngressProfile {
|
|
56
|
+
loadBalancer: IApplicationLoadBalancer;
|
|
57
|
+
/** `cluster.loadBalancer === "internal"` — unreachable as a CloudFront origin. */
|
|
58
|
+
internal: boolean;
|
|
59
|
+
/** 443 iff a certificate resolved — the cluster ALB has exactly one listener. */
|
|
60
|
+
listenerPort: 443 | 80;
|
|
61
|
+
/**
|
|
62
|
+
* The cluster's resolved zone — the SAME `IHostedZone` instance the
|
|
63
|
+
* cluster's own records claim with, so a downstream record (the Cdn's
|
|
64
|
+
* origin record) claims under the identical zone identity. The DNS claim
|
|
65
|
+
* registry keys on the literal hosted-zone ID; a name-derived re-import
|
|
66
|
+
* would register under a different key and silently defeat same-name
|
|
67
|
+
* collision detection.
|
|
68
|
+
*/
|
|
69
|
+
hostedZone?: IHostedZone;
|
|
70
|
+
zoneName?: string;
|
|
71
|
+
domainName?: string;
|
|
72
|
+
/** Every `services[].routing[].host`, deduped, in declaration order. */
|
|
73
|
+
routedHosts: string[];
|
|
74
|
+
redirectHosts: string[];
|
|
75
|
+
/**
|
|
76
|
+
* The listener's actual default-action predicate: `true` → fixed-404
|
|
77
|
+
* default; `false` → the sole target group IS the default action and the
|
|
78
|
+
* listener forwards EVERY hostname.
|
|
79
|
+
*/
|
|
80
|
+
default404: boolean;
|
|
81
|
+
rules: EcsIngressRule[];
|
|
82
|
+
certificateCoverage: CertificateCoverage;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Zone-derived profile ingredients assembled by `addHostedZone`, where the
|
|
86
|
+
* zone, hosts, and certificate coverage are already computed for emission.
|
|
87
|
+
*/
|
|
88
|
+
export interface EcsIngressZoneFacts {
|
|
89
|
+
hostedZone: IHostedZone;
|
|
90
|
+
zoneName: string;
|
|
91
|
+
domainName: string;
|
|
92
|
+
routedHosts: string[];
|
|
93
|
+
redirectHosts: string[];
|
|
94
|
+
/** False when `apexRecord: "none"` yielded the domainName record. */
|
|
95
|
+
apexRecordMinted: boolean;
|
|
96
|
+
certificateCoverage: CertificateCoverage;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The listener's default-action predicate — the SINGLE home of the
|
|
100
|
+
* computation `addLoadBalancerListener` passes to the listener factory as
|
|
101
|
+
* `default404`. `true` when ≥2 routes will exist (the emitter then attaches
|
|
102
|
+
* a fixed-404 default and conditions every rule) or when no service has a
|
|
103
|
+
* port (CDK rejects a listener with neither a default action nor targets).
|
|
104
|
+
*/
|
|
105
|
+
export declare function computeListenerDefault404(services: EcsServiceProps[]): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The listener's rule structure as `registerServiceWithALB` and
|
|
108
|
+
* `addRedirectHostRules` will emit it. Mirrors the emitters' branching
|
|
109
|
+
* exactly: a single ports-bearing service with ≤1 rule contributes NO
|
|
110
|
+
* conditioned rule (its target group is the listener default — even a
|
|
111
|
+
* declared `routing.host` does not gate it), and every redirect host
|
|
112
|
+
* contributes a host-matched 301 rule.
|
|
113
|
+
*/
|
|
114
|
+
export declare function enumerateListenerRules(services: EcsServiceProps[], redirectHosts: string[]): EcsIngressRule[];
|
|
115
|
+
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
116
|
+
export declare function normaliseDnsName(name: string): string;
|
|
117
|
+
/**
|
|
118
|
+
* RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
|
|
119
|
+
* (matches exactly one additional label — `*.example.com` covers
|
|
120
|
+
* `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
|
|
121
|
+
* compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
|
|
122
|
+
*/
|
|
123
|
+
export declare function certNameMatches(pattern: string, hostname: string): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Does the listener's attached-certificate set cover `hostname`?
|
|
126
|
+
* Fail closed where decidable, honest where opaque (P3): only `enumerated`
|
|
127
|
+
* coverage may answer `not-covered`; a miss under `partial`/`unknown` is
|
|
128
|
+
* `unknown` because an opaque certificate may still cover the name.
|
|
129
|
+
*/
|
|
130
|
+
export declare function certificateCovers(coverage: CertificateCoverage, hostname: string): CoverageAnswer;
|
|
131
|
+
export type ForwardingVerdict = {
|
|
132
|
+
verdict: "forwards";
|
|
133
|
+
} | {
|
|
134
|
+
verdict: "partial";
|
|
135
|
+
patterns: string[];
|
|
136
|
+
} | {
|
|
137
|
+
verdict: "none";
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Would the listener forward `Host: <hostname>` requests, for all paths?
|
|
141
|
+
* Computed on the real rule model, never a flattened hostname list:
|
|
142
|
+
*
|
|
143
|
+
* - no fixed-404 default → the default action forwards every hostname;
|
|
144
|
+
* - a host-only rule for the hostname, a host-matched catch-all `/*` path
|
|
145
|
+
* rule, or a host-less catch-all `/*` path rule, forwards all of its
|
|
146
|
+
* paths;
|
|
147
|
+
* - rules that involve the hostname only together with a narrower path
|
|
148
|
+
* condition (ANDed host+path, or host-less non-catch-all paths) forward
|
|
149
|
+
* SOME paths — `partial`, with the patterns named so the caller can
|
|
150
|
+
* surface them;
|
|
151
|
+
* - otherwise every request answers the fixed-404 default — `none`.
|
|
152
|
+
*
|
|
153
|
+
* Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
|
|
154
|
+
* carries the declared spelling, the caller's hostname may differ in case.
|
|
155
|
+
*/
|
|
156
|
+
export declare function forwardingVerdict(profile: EcsIngressProfile, hostname: string): ForwardingVerdict;
|
|
157
|
+
export declare function buildIngressProfile(options: {
|
|
158
|
+
loadBalancer: IApplicationLoadBalancer;
|
|
159
|
+
internal: boolean;
|
|
160
|
+
services: EcsServiceProps[];
|
|
161
|
+
certificateAttached: boolean;
|
|
162
|
+
zoneFacts?: EcsIngressZoneFacts;
|
|
163
|
+
}): EcsIngressProfile;
|
|
164
|
+
/**
|
|
165
|
+
* W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
|
|
166
|
+
* fixed-404 while its `domainName` has a minted apex record and NO rule
|
|
167
|
+
* forwards it answers 404 on its own primary domain — usually the aftermath
|
|
168
|
+
* of adding a second routing rule (which flips the default action from
|
|
169
|
+
* forward to 404, `computeListenerDefault404`). A warning, not an error:
|
|
170
|
+
* pre-existing clusters can already be in this state, and narrowing them is
|
|
171
|
+
* not this check's mandate. Partial forwarding (path-split services) is the
|
|
172
|
+
* normal multi-route shape and does not warn.
|
|
173
|
+
*/
|
|
174
|
+
export declare function warnWhenRecordedApexUnforwarded(profile: EcsIngressProfile, apexRecordMinted: boolean, clusterName: string): void;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synth-time ingress profile for an ECS cluster's ALB (design 2026-08-18
|
|
3
|
+
* cdn-app-origin, D2). The profile is the one sanctioned answer to "what
|
|
4
|
+
* does this cluster's listener actually do?" for downstream constructs —
|
|
5
|
+
* today the `Cdn` construct-reference origin lane, which needs a TLS-valid
|
|
6
|
+
* origin hostname, certificate coverage, and a forwarding verdict without
|
|
7
|
+
* re-deriving any of it from props.
|
|
8
|
+
*
|
|
9
|
+
* P5 (model the machinery, don't paraphrase it): every fact here is exported
|
|
10
|
+
* from the SAME computation the emitting code uses — `default404` is the
|
|
11
|
+
* predicate `addLoadBalancerListener` passes to the listener factory, the
|
|
12
|
+
* rule structure mirrors `registerServiceWithALB`'s exact branching
|
|
13
|
+
* (including the single-service conditions-dropped branch), and the covered
|
|
14
|
+
* SAN set is the one `addHostedZone` mints. Never recompute these facts from
|
|
15
|
+
* cluster props elsewhere; extend the profile instead.
|
|
16
|
+
*/
|
|
17
|
+
import { FjallLogger } from "../../../utils/validationLogger.js";
|
|
18
|
+
function normaliseRoutingRules(routing) {
|
|
19
|
+
return Array.isArray(routing) ? routing : routing ? [routing] : [];
|
|
20
|
+
}
|
|
21
|
+
function servicesWithPorts(services) {
|
|
22
|
+
return services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The listener's default-action predicate — the SINGLE home of the
|
|
26
|
+
* computation `addLoadBalancerListener` passes to the listener factory as
|
|
27
|
+
* `default404`. `true` when ≥2 routes will exist (the emitter then attaches
|
|
28
|
+
* a fixed-404 default and conditions every rule) or when no service has a
|
|
29
|
+
* port (CDK rejects a listener with neither a default action nor targets).
|
|
30
|
+
*/
|
|
31
|
+
export function computeListenerDefault404(services) {
|
|
32
|
+
const withPorts = servicesWithPorts(services);
|
|
33
|
+
const willHaveMultipleRoutes = withPorts.length > 1 ||
|
|
34
|
+
withPorts.some((s) => normaliseRoutingRules(s.routing).length > 1);
|
|
35
|
+
return willHaveMultipleRoutes || withPorts.length === 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The listener's rule structure as `registerServiceWithALB` and
|
|
39
|
+
* `addRedirectHostRules` will emit it. Mirrors the emitters' branching
|
|
40
|
+
* exactly: a single ports-bearing service with ≤1 rule contributes NO
|
|
41
|
+
* conditioned rule (its target group is the listener default — even a
|
|
42
|
+
* declared `routing.host` does not gate it), and every redirect host
|
|
43
|
+
* contributes a host-matched 301 rule.
|
|
44
|
+
*/
|
|
45
|
+
export function enumerateListenerRules(services, redirectHosts) {
|
|
46
|
+
const withPorts = servicesWithPorts(services);
|
|
47
|
+
const isSingleService = withPorts.length === 1;
|
|
48
|
+
const rules = [];
|
|
49
|
+
for (const service of withPorts) {
|
|
50
|
+
const routingRules = normaliseRoutingRules(service.routing);
|
|
51
|
+
if (isSingleService && routingRules.length <= 1)
|
|
52
|
+
continue;
|
|
53
|
+
for (const rule of routingRules) {
|
|
54
|
+
if (rule.host === undefined && rule.path === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
rules.push({
|
|
57
|
+
...(rule.host !== undefined && { host: rule.host }),
|
|
58
|
+
...(rule.path !== undefined && { path: rule.path }),
|
|
59
|
+
kind: "forward"
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const host of redirectHosts) {
|
|
64
|
+
rules.push({ host, kind: "redirect" });
|
|
65
|
+
}
|
|
66
|
+
return rules;
|
|
67
|
+
}
|
|
68
|
+
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
69
|
+
export function normaliseDnsName(name) {
|
|
70
|
+
return name.toLowerCase().replace(/\.$/, "");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
|
|
74
|
+
* (matches exactly one additional label — `*.example.com` covers
|
|
75
|
+
* `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
|
|
76
|
+
* compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
|
|
77
|
+
*/
|
|
78
|
+
export function certNameMatches(pattern, hostname) {
|
|
79
|
+
const p = pattern.toLowerCase();
|
|
80
|
+
const h = hostname.toLowerCase();
|
|
81
|
+
if (p === h)
|
|
82
|
+
return true;
|
|
83
|
+
if (!p.startsWith("*."))
|
|
84
|
+
return false;
|
|
85
|
+
const suffix = p.slice(1); // ".example.com"
|
|
86
|
+
if (!h.endsWith(suffix))
|
|
87
|
+
return false;
|
|
88
|
+
const label = h.slice(0, h.length - suffix.length);
|
|
89
|
+
return label.length > 0 && !label.includes(".");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Does the listener's attached-certificate set cover `hostname`?
|
|
93
|
+
* Fail closed where decidable, honest where opaque (P3): only `enumerated`
|
|
94
|
+
* coverage may answer `not-covered`; a miss under `partial`/`unknown` is
|
|
95
|
+
* `unknown` because an opaque certificate may still cover the name.
|
|
96
|
+
*/
|
|
97
|
+
export function certificateCovers(coverage, hostname) {
|
|
98
|
+
if (coverage.kind === "unknown")
|
|
99
|
+
return "unknown";
|
|
100
|
+
if (coverage.hostnames.some((p) => certNameMatches(p, hostname))) {
|
|
101
|
+
return "covered";
|
|
102
|
+
}
|
|
103
|
+
return coverage.kind === "enumerated" ? "not-covered" : "unknown";
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Would the listener forward `Host: <hostname>` requests, for all paths?
|
|
107
|
+
* Computed on the real rule model, never a flattened hostname list:
|
|
108
|
+
*
|
|
109
|
+
* - no fixed-404 default → the default action forwards every hostname;
|
|
110
|
+
* - a host-only rule for the hostname, a host-matched catch-all `/*` path
|
|
111
|
+
* rule, or a host-less catch-all `/*` path rule, forwards all of its
|
|
112
|
+
* paths;
|
|
113
|
+
* - rules that involve the hostname only together with a narrower path
|
|
114
|
+
* condition (ANDed host+path, or host-less non-catch-all paths) forward
|
|
115
|
+
* SOME paths — `partial`, with the patterns named so the caller can
|
|
116
|
+
* surface them;
|
|
117
|
+
* - otherwise every request answers the fixed-404 default — `none`.
|
|
118
|
+
*
|
|
119
|
+
* Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
|
|
120
|
+
* carries the declared spelling, the caller's hostname may differ in case.
|
|
121
|
+
*/
|
|
122
|
+
export function forwardingVerdict(profile, hostname) {
|
|
123
|
+
if (!profile.default404)
|
|
124
|
+
return { verdict: "forwards" };
|
|
125
|
+
const wanted = normaliseDnsName(hostname);
|
|
126
|
+
const partial = [];
|
|
127
|
+
for (const rule of profile.rules) {
|
|
128
|
+
if (rule.kind !== "forward")
|
|
129
|
+
continue;
|
|
130
|
+
const hostMatches = rule.host !== undefined && normaliseDnsName(rule.host) === wanted;
|
|
131
|
+
if (hostMatches && (rule.path === undefined || rule.path === "/*")) {
|
|
132
|
+
return { verdict: "forwards" };
|
|
133
|
+
}
|
|
134
|
+
if (rule.host === undefined && rule.path === "/*") {
|
|
135
|
+
return { verdict: "forwards" };
|
|
136
|
+
}
|
|
137
|
+
if (hostMatches && rule.path !== undefined) {
|
|
138
|
+
partial.push(`Host=${rule.host} AND Path=${rule.path}`);
|
|
139
|
+
}
|
|
140
|
+
else if (rule.host === undefined && rule.path !== undefined) {
|
|
141
|
+
partial.push(`Path=${rule.path} (any host)`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return partial.length > 0
|
|
145
|
+
? { verdict: "partial", patterns: partial }
|
|
146
|
+
: { verdict: "none" };
|
|
147
|
+
}
|
|
148
|
+
export function buildIngressProfile(options) {
|
|
149
|
+
const { zoneFacts } = options;
|
|
150
|
+
const redirectHosts = zoneFacts?.redirectHosts ?? [];
|
|
151
|
+
return {
|
|
152
|
+
loadBalancer: options.loadBalancer,
|
|
153
|
+
internal: options.internal,
|
|
154
|
+
listenerPort: options.certificateAttached ? 443 : 80,
|
|
155
|
+
...(zoneFacts !== undefined && {
|
|
156
|
+
hostedZone: zoneFacts.hostedZone,
|
|
157
|
+
zoneName: zoneFacts.zoneName,
|
|
158
|
+
domainName: zoneFacts.domainName
|
|
159
|
+
}),
|
|
160
|
+
routedHosts: zoneFacts?.routedHosts ?? [],
|
|
161
|
+
redirectHosts,
|
|
162
|
+
default404: computeListenerDefault404(options.services),
|
|
163
|
+
rules: enumerateListenerRules(options.services, redirectHosts),
|
|
164
|
+
certificateCoverage: zoneFacts?.certificateCoverage ?? {
|
|
165
|
+
// No domain → no certificates attached: the attached set is fully
|
|
166
|
+
// known (empty), which is the honest enumerated answer, not "unknown".
|
|
167
|
+
kind: "enumerated",
|
|
168
|
+
hostnames: []
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
|
|
174
|
+
* fixed-404 while its `domainName` has a minted apex record and NO rule
|
|
175
|
+
* forwards it answers 404 on its own primary domain — usually the aftermath
|
|
176
|
+
* of adding a second routing rule (which flips the default action from
|
|
177
|
+
* forward to 404, `computeListenerDefault404`). A warning, not an error:
|
|
178
|
+
* pre-existing clusters can already be in this state, and narrowing them is
|
|
179
|
+
* not this check's mandate. Partial forwarding (path-split services) is the
|
|
180
|
+
* normal multi-route shape and does not warn.
|
|
181
|
+
*/
|
|
182
|
+
export function warnWhenRecordedApexUnforwarded(profile, apexRecordMinted, clusterName) {
|
|
183
|
+
if (!profile.default404)
|
|
184
|
+
return;
|
|
185
|
+
if (profile.domainName === undefined || !apexRecordMinted)
|
|
186
|
+
return;
|
|
187
|
+
if (forwardingVerdict(profile, profile.domainName).verdict !== "none")
|
|
188
|
+
return;
|
|
189
|
+
FjallLogger.warn(`Cluster '${clusterName}': the listener's default action is a fixed 404 ` +
|
|
190
|
+
`and no routing rule forwards '${profile.domainName}', but its alias ` +
|
|
191
|
+
"record is minted — requests to the cluster's own domain will answer " +
|
|
192
|
+
"404 Not Found. Multiple routing rules flip the listener default from " +
|
|
193
|
+
"forward to 404; keep a forwarding path for the domain, e.g. a " +
|
|
194
|
+
'routing rule { path: "/*" } on the service that should serve it.');
|
|
195
|
+
}
|
|
@@ -11,8 +11,35 @@ export class AliasRecord extends Construct {
|
|
|
11
11
|
this.description =
|
|
12
12
|
props.description ?? defaultDnsComment("alias", this.fqdn);
|
|
13
13
|
// An alias record IS an A record set in Route53 — it claims type "A" so
|
|
14
|
-
// an alias and a plain A record on the same name collide at synth.
|
|
15
|
-
|
|
14
|
+
// an alias and a plain A record on the same name collide at synth. The
|
|
15
|
+
// routing variant rides along: policy siblings with distinct
|
|
16
|
+
// setIdentifiers (e.g. a compute apex alias and a CDN apex alias during
|
|
17
|
+
// an ingress migration) are legal and register cleanly.
|
|
18
|
+
const routingPolicy = props.region !== undefined
|
|
19
|
+
? "latency"
|
|
20
|
+
: props.weight !== undefined
|
|
21
|
+
? "weighted"
|
|
22
|
+
: props.geoLocation !== undefined
|
|
23
|
+
? "geolocation"
|
|
24
|
+
: undefined;
|
|
25
|
+
// Route53 allows one geolocation record per location VALUE — serialise
|
|
26
|
+
// the location so the registry can compare siblings ("*" = the default
|
|
27
|
+
// wildcard record, which is equally one-per-(name, type)).
|
|
28
|
+
const geoLocationKey = props.geoLocation !== undefined
|
|
29
|
+
? [
|
|
30
|
+
props.geoLocation.continentCode,
|
|
31
|
+
props.geoLocation.countryCode,
|
|
32
|
+
props.geoLocation.subdivisionCode
|
|
33
|
+
]
|
|
34
|
+
.filter((part) => part !== undefined)
|
|
35
|
+
.join("/") || "*"
|
|
36
|
+
: undefined;
|
|
37
|
+
claimDnsRecord(this, props, "A", this.fqdn, {
|
|
38
|
+
setIdentifier: props.setIdentifier,
|
|
39
|
+
routingPolicy,
|
|
40
|
+
region: props.region,
|
|
41
|
+
...(geoLocationKey !== undefined && { geoLocationKey })
|
|
42
|
+
});
|
|
16
43
|
// Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
|
|
17
44
|
// so we deliberately do not forward props.ttl to the underlying CDK resource.
|
|
18
45
|
this.record = new CdkARecord(this, "Record", {
|
|
@@ -14,13 +14,27 @@ export interface DnsRecordCommonProps {
|
|
|
14
14
|
export declare const DEFAULT_DNS_TTL_SECONDS = 300;
|
|
15
15
|
export declare function resolveTtl(ttlSeconds: number | undefined): Duration;
|
|
16
16
|
export declare function defaultDnsComment(recordType: string, fqdn: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Routing-variant half of a claim — filled in by wrappers whose records can
|
|
19
|
+
* carry a routing policy (today: AliasRecord). Sibling variants with the
|
|
20
|
+
* same policy type and distinct setIdentifiers legally share a (zone, name,
|
|
21
|
+
* type) triple; the registry encodes Route53's coexistence rules.
|
|
22
|
+
*/
|
|
23
|
+
export interface DnsRecordClaimVariant {
|
|
24
|
+
readonly setIdentifier?: string;
|
|
25
|
+
readonly routingPolicy?: "latency" | "weighted" | "geolocation";
|
|
26
|
+
readonly region?: string;
|
|
27
|
+
/** Serialised geolocation value (continent/country/subdivision). */
|
|
28
|
+
readonly geoLocationKey?: string;
|
|
29
|
+
}
|
|
17
30
|
/**
|
|
18
31
|
* Claim the (zone, name, type) triple in the app-scoped collision registry
|
|
19
32
|
* (design D5, synth-side). Every wrapper in the dnsRecord family calls this
|
|
20
|
-
* from its constructor so
|
|
21
|
-
* the app — fails at synth instead of at CloudFormation deploy.
|
|
22
|
-
* claim their underlying Route53 type ("A")
|
|
23
|
-
*
|
|
33
|
+
* from its constructor so an illegal second claimant — same stack or another
|
|
34
|
+
* stack in the app — fails at synth instead of at CloudFormation deploy.
|
|
35
|
+
* Alias records claim their underlying Route53 type ("A") and pass their
|
|
36
|
+
* routing variant so legal policy siblings register cleanly. Observation
|
|
37
|
+
* only: no constructs are created, so construct trees stay byte-identical.
|
|
24
38
|
*/
|
|
25
|
-
export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string): void;
|
|
39
|
+
export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string, variant?: DnsRecordClaimVariant): void;
|
|
26
40
|
export declare function applyDnsRecordTags(construct: Construct, props: Pick<DnsRecordCommonProps, "zoneName" | "costAllocationEnvironment" | "costAllocationDomain">): void;
|