@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.
@@ -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
- default404: willHaveMultipleRoutes || noServicePorts
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
- const apex = new AliasRecord(ctx.scope, `${props.clusterName}ARecord`, {
371
- zone: hostedZone,
372
- zoneName,
373
- recordName: recordLabelWithin(domainName, zoneName),
374
- target: new LoadBalancerTarget(loadBalancer, {
375
- evaluateTargetHealth: hasRoutingPolicy
376
- }),
377
- // No-churn adoption: the pre-wrapper raw ARecord carried no comment, so
378
- // adding one now would be a property delta on the live production apex
379
- // record (flipless-retain-flip principle).
380
- omitComment: true,
381
- ...routingProps
382
- });
383
- // Preserve the pre-wrapper logical ID see preWrapperLogicalId.
384
- apex.record.node.defaultChild.overrideLogicalId(preWrapperLogicalId(ctx.scope, `${props.clusterName}ARecord`));
385
- aRecord = apex.record;
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
- claimDnsRecord(this, props, "A", this.fqdn);
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 a second claimant — same stack or another stack in
21
- * the app — fails at synth instead of at CloudFormation deploy. Alias records
22
- * claim their underlying Route53 type ("A"). Observation only: no constructs
23
- * are created, so construct trees stay byte-identical.
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;