@fjall/components-infrastructure 14.1.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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * ECS construct-reference origin resolution for the `Cdn` pattern (design
3
+ * 2026-08-18 cdn-app-origin, D3). Given a fronted ECS compute, resolve a
4
+ * TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile, decide
5
+ * who owns its DNS record (P1: the construct that declares a hostname owns
6
+ * its record — a `routing[].host` hostname is the compute's; a derived or
7
+ * free hostname is the Cdn's), and validate certificate coverage and
8
+ * listener forwarding at synth — fail closed where decidable, warn where
9
+ * opaque or partial (P3).
10
+ *
11
+ * The resolver is memoized per compute within one `Cdn`: the default origin
12
+ * and every behaviour referencing the same compute share one resolution and
13
+ * at most one record.
14
+ */
15
+ import { Token } from "aws-cdk-lib";
16
+ import { certificateCovers, forwardingVerdict, normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
17
+ export { normaliseDnsName };
18
+ import { DNS_APEX } from "@fjall/util";
19
+ import { isWithinZone } from "../../utils/domainTypes.js";
20
+ import { FjallLogger } from "../../utils/validationLogger.js";
21
+ /**
22
+ * Relative record label for `domain` within `zoneName` (the staticSite
23
+ * `recordLabelFor` convention): the zone apex maps to the canonical apex
24
+ * label, sub-names drop the zone suffix.
25
+ */
26
+ export function recordLabelWithin(domain, zoneName) {
27
+ if (domain === zoneName) {
28
+ return DNS_APEX;
29
+ }
30
+ const suffix = `.${zoneName}`;
31
+ return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
32
+ }
33
+ const SHARED_CURE = "declare the hostname as a routing host on the receiving service " +
34
+ "(services[].routing[].host) — one declaration adds the certificate SAN " +
35
+ "(cluster-minted certificates), the listener rule, and the DNS record. " +
36
+ "CAUTION: adding a SECOND routing rule to a service flips the listener " +
37
+ "default action from forward to fixed-404, so a cluster serving its " +
38
+ "domain through the default action must keep a forwarding path for it, " +
39
+ 'e.g. routing: [{ path: "/*" }, { host: "<origin hostname>" }].';
40
+ export class CdnEcsOriginResolver {
41
+ options;
42
+ resolutions = new Map();
43
+ warnedUnknownCoverage = false;
44
+ constructor(options) {
45
+ this.options = options;
46
+ }
47
+ resolve(compute) {
48
+ const memoized = this.resolutions.get(compute);
49
+ if (memoized !== undefined) {
50
+ return memoized;
51
+ }
52
+ const resolution = this.resolveFresh(compute);
53
+ this.resolutions.set(compute, resolution);
54
+ return resolution;
55
+ }
56
+ /** Every Cdn-owned record the resolutions so far require, one per compute. */
57
+ getRecordPlans() {
58
+ const plans = [];
59
+ for (const resolution of this.resolutions.values()) {
60
+ if (resolution.recordPlan !== undefined) {
61
+ plans.push(resolution.recordPlan);
62
+ }
63
+ }
64
+ return plans;
65
+ }
66
+ resolveFresh(compute) {
67
+ const { cdnId } = this.options;
68
+ const profile = compute.getIngressProfile();
69
+ if (profile === undefined) {
70
+ // E1 — pre-existing shape, kept: a cluster with no ALB cannot origin.
71
+ throw new Error(`CDN '${cdnId}': the ECS compute origin has no load balancer. ` +
72
+ "Enable loadBalancer in the compute configuration, or front a " +
73
+ "different resource.");
74
+ }
75
+ if (profile.internal) {
76
+ // E2 — CloudFront has no path into a VPC-internal ALB.
77
+ throw new Error(`CDN '${cdnId}': the ECS compute origin's load balancer is internal ` +
78
+ "— CloudFront reaches origins over the public internet and cannot " +
79
+ "address an internal ALB. Make the cluster's load balancer " +
80
+ 'internet-facing (drop loadBalancer: "internal"), or front a ' +
81
+ "different resource.");
82
+ }
83
+ const { hostname, computeOwnsRecord } = this.selectHostname(compute, profile);
84
+ // Origin-loop guard (C4, extended): the resolved hostname must not be
85
+ // one of the distribution's own alias names.
86
+ if (this.options.aliasNames.has(normaliseDnsName(hostname))) {
87
+ throw new Error(`CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
88
+ "of the distribution's own domain names — once DNS points that " +
89
+ "name at the distribution, every request loops CloudFront → " +
90
+ "CloudFront. Pick a dedicated origin hostname (originHostname), " +
91
+ "or serve the alias from the compute directly.");
92
+ }
93
+ if (profile.listenerPort === 80) {
94
+ // E4 — no certificate anywhere: the listener serves HTTP only, and a
95
+ // CloudFront origin fetch over HTTPS has nothing to shake hands with.
96
+ throw new Error(`CDN '${cdnId}': the ECS compute origin's listener serves HTTP only ` +
97
+ "(the cluster has no domain, so no certificate resolved). A " +
98
+ "CloudFront origin needs a TLS-valid HTTPS origin. Give the " +
99
+ "cluster a domain (cluster.domainConfig), or use originType " +
100
+ '"alb" with protocolPolicy "HTTP_ONLY" if plaintext origin ' +
101
+ "traffic is acceptable.");
102
+ }
103
+ // Record-ownership feasibility BEFORE coverage/forwarding (design D3
104
+ // step 3): an out-of-zone hostname's only real cures are an in-zone
105
+ // name or originRecord: "none" — deciding coverage first would hand the
106
+ // user E6's routing-host cure, which ECS itself refuses out-of-zone.
107
+ let recordPlan;
108
+ if (!computeOwnsRecord && this.effectiveOriginRecord(compute) !== "none") {
109
+ // The Cdn owns the record (P1). Zone facts are guaranteed here: a
110
+ // 443 listener implies a domain, which implies zone identity.
111
+ if (profile.hostedZone === undefined ||
112
+ profile.zoneName === undefined ||
113
+ !isWithinZone(hostname, profile.zoneName)) {
114
+ // E5 — a record we cannot mint: outside the cluster's zone.
115
+ throw new Error(`CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
116
+ `origin's hosted zone ('${profile.zoneName ?? "none"}'), so its ` +
117
+ "record cannot be minted here. Use a hostname inside the zone, " +
118
+ 'or set originRecord: "none" and manage the record where the ' +
119
+ "zone lives.");
120
+ }
121
+ recordPlan = {
122
+ hostname,
123
+ hostedZone: profile.hostedZone,
124
+ zoneName: profile.zoneName,
125
+ recordName: recordLabelWithin(hostname, profile.zoneName),
126
+ loadBalancer: profile.loadBalancer
127
+ };
128
+ }
129
+ const coverage = certificateCovers(profile.certificateCoverage, hostname);
130
+ if (coverage === "not-covered") {
131
+ // E6 — provably uncovered: every attached certificate is enumerated
132
+ // and none matches. TLS to the origin fails on every request.
133
+ throw new Error(`CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
134
+ `'${hostname}' (attached certificates cover: ` +
135
+ `${profile.certificateCoverage.kind === "unknown" ? "unknown" : profile.certificateCoverage.hostnames.join(", ") || "nothing"}). ` +
136
+ `Every origin fetch would fail TLS validation. Cure: ${SHARED_CURE}`);
137
+ }
138
+ if (coverage === "unknown" && !this.warnedUnknownCoverage) {
139
+ this.warnedUnknownCoverage = true;
140
+ // W1 — opaque certificates: not decidable at synth, so warn once.
141
+ FjallLogger.warn(`CDN '${cdnId}': cannot verify that the ECS origin's listener ` +
142
+ `certificates cover '${hostname}' (imported certificate ARNs are ` +
143
+ "opaque at synth). If the cluster uses a managed domain, redeploy " +
144
+ "the domain stack with engine >= 14.2.0 so it publishes " +
145
+ "certificate-coverage outputs and this becomes checkable; for " +
146
+ "imported certificates, verify the SANs cover the hostname — TLS " +
147
+ "fails at runtime if not.");
148
+ }
149
+ const verdict = forwardingVerdict(profile, hostname);
150
+ if (verdict.verdict === "none") {
151
+ // E7 — the listener's default action is a fixed 404 and no rule
152
+ // forwards the hostname: every origin fetch answers 404.
153
+ throw new Error(`CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
154
+ `'${hostname}' — its default action is a fixed 404 and no routing ` +
155
+ `rule forwards that hostname. Cure: ${SHARED_CURE}`);
156
+ }
157
+ if (verdict.verdict === "partial") {
158
+ // W2 — some paths forward, others hit the fixed-404 default.
159
+ FjallLogger.warn(`CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
160
+ `some paths (matching rules: ${verdict.patterns.join("; ")}); ` +
161
+ "requests outside those patterns answer the listener's fixed-404 " +
162
+ "default. Verify the patterns against the distribution's " +
163
+ "behaviours.");
164
+ }
165
+ return {
166
+ hostname,
167
+ // The hostname is certificate-covered, so the resource layer's
168
+ // HTTPS_ONLY default is exactly right — no protocol override.
169
+ originConfig: { type: "http", domainName: hostname },
170
+ ...(recordPlan !== undefined && { recordPlan }),
171
+ computeOwnsRecord
172
+ };
173
+ }
174
+ explicitHostnameFor(compute) {
175
+ return this.options.overrides?.get(compute)?.originHostname;
176
+ }
177
+ effectiveOriginRecord(compute) {
178
+ return this.options.overrides?.get(compute)?.originRecord;
179
+ }
180
+ selectHostname(compute, profile) {
181
+ const { cdnId } = this.options;
182
+ const explicit = this.explicitHostnameFor(compute);
183
+ const originRecord = this.effectiveOriginRecord(compute);
184
+ if (explicit !== undefined) {
185
+ if (Token.isUnresolved(explicit)) {
186
+ throw new Error(`CDN '${cdnId}': originHostname must be a literal hostname (got ` +
187
+ "an unresolved token). Coverage, forwarding, and the origin " +
188
+ "record are synth-time decisions — pass the concrete name.");
189
+ }
190
+ // DNS names are case-insensitive (RFC 4343): every membership test
191
+ // compares normalised, so a case-differing spelling cannot slip past
192
+ // the redirect/ownership guards the way it cannot slip past ACM.
193
+ const explicitNormalised = normaliseDnsName(explicit);
194
+ if (profile.redirectHosts.some((host) => normaliseDnsName(host) === explicitNormalised)) {
195
+ // E10 — redirect hosts never serve traffic: the listener answers
196
+ // them with a 301 to the cluster domain, so an origin pointed there
197
+ // loops every viewer request through CloudFront → 301 → CloudFront.
198
+ throw new Error(`CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
199
+ "entry on the ECS origin — the listener 301s it to " +
200
+ `'${profile.domainName ?? "the cluster domain"}' and it never ` +
201
+ "serves traffic. Use a served hostname: a routing host, or the " +
202
+ "derived origin name (omit originHostname).");
203
+ }
204
+ const computeOwnsRecord = profile.routedHosts.some((host) => normaliseDnsName(host) === explicitNormalised);
205
+ if (computeOwnsRecord && originRecord !== undefined) {
206
+ // E8 — the compute already owns that hostname's record.
207
+ throw new Error(`CDN '${cdnId}': originRecord is set, but originHostname ` +
208
+ `'${explicit}' is a routing host — the compute already owns its ` +
209
+ "record (P1: the construct that declares a hostname owns its " +
210
+ "record). Drop originRecord.");
211
+ }
212
+ return { hostname: explicit, computeOwnsRecord };
213
+ }
214
+ // Auto-resolution: routed hosts that are not the distribution's own
215
+ // aliases. (Redirect hosts can never appear — ECS enforces
216
+ // serve-XOR-redirect at synth.)
217
+ const candidates = profile.routedHosts.filter((host) => !this.options.aliasNames.has(normaliseDnsName(host)));
218
+ if (candidates.length === 1 && candidates[0] !== undefined) {
219
+ if (originRecord !== undefined) {
220
+ // E8 — same ownership rule as the explicit arm.
221
+ throw new Error(`CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
222
+ `routing host '${candidates[0]}' — the compute already owns its ` +
223
+ "record. Drop originRecord.");
224
+ }
225
+ return { hostname: candidates[0], computeOwnsRecord: true };
226
+ }
227
+ if (candidates.length > 1) {
228
+ // E3 — ambiguous: several served hostnames could be the origin.
229
+ throw new Error(`CDN '${cdnId}': the ECS origin serves several hostnames ` +
230
+ `(${candidates.join(", ")}) and the origin is ambiguous. Set ` +
231
+ "originHostname — on the distribution for its default origin, or " +
232
+ "on the behaviour entry — to the one the distribution should " +
233
+ "fetch from.");
234
+ }
235
+ if (profile.domainName === undefined) {
236
+ // E4 — nothing to derive from (and nothing a viewer could reach over
237
+ // TLS anyway; the no-domain listener is HTTP-only, so an explicit
238
+ // originHostname would only trade this error for the port-80 one —
239
+ // the catalogue cures are the honest ones).
240
+ throw new Error(`CDN '${cdnId}': the ECS compute origin has no domain to derive an ` +
241
+ "origin hostname from (and no certificate, so nothing serves " +
242
+ "HTTPS). Give the cluster a domain (cluster.domainConfig), or " +
243
+ 'use originType "alb" with protocolPolicy "HTTP_ONLY" if ' +
244
+ "plaintext origin traffic is acceptable.");
245
+ }
246
+ return {
247
+ hostname: `origin.${profile.domainName}`,
248
+ computeOwnsRecord: false
249
+ };
250
+ }
251
+ }
@@ -5,7 +5,7 @@ import { type IApplicationLoadBalancer, type ApplicationListener } from "aws-cdk
5
5
  import { Construct } from "constructs";
6
6
  import { type IEcsCompute } from "./interfaces/compute.js";
7
7
  import { type SecretImport } from "../../resources/aws/secrets/index.js";
8
- import EcsCluster, { type EcsClusterProps } from "../../resources/aws/compute/ecs.js";
8
+ import EcsCluster, { type EcsClusterProps, type EcsIngressProfile } from "../../resources/aws/compute/ecs.js";
9
9
  export { ScalingType } from "./computeEcsTypes.js";
10
10
  export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsServiceConfig, ServiceLogAlarm, EcsComputeProps } from "./computeEcsTypes.js";
11
11
  import { ScalingType, type EcsCapacityProviderConfig, type EcsCapacityProvider, type EcsContainerConfig, type EcsScalingConfig, type EcsServiceConfig, type EcsComputeProps, type QueueScalingConfig } from "./computeEcsTypes.js";
@@ -227,6 +227,14 @@ export declare class EcsCompute extends Construct implements IEcsCompute {
227
227
  getCluster(): ICluster;
228
228
  /** Get the Application Load Balancer if one was created. */
229
229
  getLoadBalancer(): IApplicationLoadBalancer | undefined;
230
+ /**
231
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
232
+ * listener facts, zone identity, and certificate coverage for constructs
233
+ * that front this compute — the `Cdn` construct-reference origin lane
234
+ * resolves its origin hostname against it. Undefined when the compute has
235
+ * no load balancer.
236
+ */
237
+ getIngressProfile(): EcsIngressProfile | undefined;
230
238
  /** Get a specific service by name. */
231
239
  getService(name: string): IBaseService | undefined;
232
240
  /** Get all services in the cluster. */
@@ -1653,6 +1653,16 @@ export class EcsCompute extends Construct {
1653
1653
  getLoadBalancer() {
1654
1654
  return this.ecsCluster.getLoadBalancer();
1655
1655
  }
1656
+ /**
1657
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
1658
+ * listener facts, zone identity, and certificate coverage for constructs
1659
+ * that front this compute — the `Cdn` construct-reference origin lane
1660
+ * resolves its origin hostname against it. Undefined when the compute has
1661
+ * no load balancer.
1662
+ */
1663
+ getIngressProfile() {
1664
+ return this.ecsCluster.getIngressProfile();
1665
+ }
1656
1666
  /** Get a specific service by name. */
1657
1667
  getService(name) {
1658
1668
  return this.ecsCluster.getService(name);
@@ -3,6 +3,7 @@ import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
3
3
  import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
4
4
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
5
5
  import { toPascalCase } from "../../utils/capitaliseString.js";
6
+ import { FjallLogger } from "../../utils/validationLogger.js";
6
7
  const US_EAST_1 = "us-east-1";
7
8
  /**
8
9
  * Certificate composition shared by `composeApexDomain` and
@@ -42,6 +43,21 @@ export function composeDomainCertificates(scope, composition) {
42
43
  costAllocationEnvironment: composition.costAllocationEnvironment,
43
44
  costAllocationDomain: composition.costAllocationDomain
44
45
  });
46
+ // D5 (design 2026-08-18 cdn-app-origin): publish the hostnames this
47
+ // certificate covers beside its ARN export — SAME per-certificate
48
+ // identity, so a consumer can bind coverage for exactly the
49
+ // certificates whose ARNs it binds, never a zone-level aggregate
50
+ // (which would list hostnames of certificates that are not on the
51
+ // consumer's listener and over-claim coverage).
52
+ const serialisedHosts = JSON.stringify(certificateHosts(normalised));
53
+ if (hostsOutputWithinCfnLimit(serialisedHosts, normalised.domainName)) {
54
+ new CfnOutput(scope, `${certId}Hosts`, {
55
+ key: `${certId}Hosts`,
56
+ value: serialisedHosts,
57
+ exportName: getDomainExportNames(normalised.domainName)
58
+ .certificateHosts
59
+ });
60
+ }
45
61
  certificates.set(normalised.domainName, dc.certificate);
46
62
  return;
47
63
  }
@@ -80,6 +96,7 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
80
96
  exportCertificateArn: false
81
97
  });
82
98
  emitUsEast1Export(scope, composition, dc);
99
+ emitUsEast1HostsOutput(scope, composition, cert);
83
100
  return dc.certificate;
84
101
  }
85
102
  const pairedStack = resolvePairedUsEast1Stack(domainStack, composition, cert);
@@ -104,8 +121,60 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
104
121
  exportCertificateArn: false
105
122
  });
106
123
  emitUsEast1Export(pairedStack, composition, dc);
124
+ // The hosts output lands on the MAIN domain stack even though the
125
+ // certificate lives in the paired stack: the SAN list is a synth-time
126
+ // literal (no cross-region reference), and the CLI's existing main-stack
127
+ // DescribeStacks read must reach it without touching the paired-stack
128
+ // reader (design 2026-08-18 cdn-app-origin, D5).
129
+ emitUsEast1HostsOutput(domainStack, composition, cert);
107
130
  return dc.certificate;
108
131
  }
132
+ /** Hostnames a certificate covers: its domainName plus every SAN, deduped. */
133
+ function certificateHosts(cert) {
134
+ return [
135
+ ...new Set([cert.domainName, ...(cert.subjectAlternativeNames ?? [])])
136
+ ];
137
+ }
138
+ /**
139
+ * Guard on CloudFormation's 1024-byte output-value limit (1000-byte
140
+ * threshold for headroom under the hard cap): a certificate with enough —
141
+ * or long enough — SANs would otherwise fail the WHOLE domain-stack deploy
142
+ * with an opaque CFN error. Per the D5 fail-open contract the hosts output
143
+ * is SKIPPED instead: the deploy keeps succeeding, and consumers see
144
+ * coverage-unknown for this certificate (W1 — its coverage stays
145
+ * unverifiable at CDN synth), never a false verdict.
146
+ */
147
+ const HOSTS_OUTPUT_MAX_BYTES = 1000;
148
+ function hostsOutputWithinCfnLimit(serialisedHosts, certificateDomainName) {
149
+ const bytes = Buffer.byteLength(serialisedHosts, "utf8");
150
+ if (bytes <= HOSTS_OUTPUT_MAX_BYTES)
151
+ return true;
152
+ FjallLogger.warn(`Certificate '${certificateDomainName}': its hosts list serialises to ` +
153
+ `${bytes} bytes, over the ${HOSTS_OUTPUT_MAX_BYTES}-byte guard for ` +
154
+ "CloudFormation's 1024-byte output-value limit, so its " +
155
+ "certificate-hosts output is skipped and the certificate's coverage " +
156
+ "stays unverifiable at CDN synth (W1) — the deploy itself keeps " +
157
+ "succeeding. Trim the certificate's SAN list, or split the hosts " +
158
+ "across multiple certificates.");
159
+ return false;
160
+ }
161
+ /**
162
+ * The zone-level `<zone>-us-east-1-certificate-hosts` output (D5 companion
163
+ * to `emitUsEast1Export`): the hostnames the viewer certificate covers,
164
+ * always minted on the MAIN domain stack so the D2 DescribeStacks read
165
+ * reaches it in one call regardless of where the certificate itself lives.
166
+ */
167
+ function emitUsEast1HostsOutput(scope, composition, cert) {
168
+ const serialisedHosts = JSON.stringify(certificateHosts(cert));
169
+ if (!hostsOutputWithinCfnLimit(serialisedHosts, cert.domainName))
170
+ return;
171
+ const exports = getDomainExportNames(composition.effectiveZoneName);
172
+ new CfnOutput(scope, `${composition.safeZone}UsEast1CertificateHosts`, {
173
+ key: `${composition.safeZone}UsEast1CertificateHosts`,
174
+ value: serialisedHosts,
175
+ exportName: exports.usEast1CertificateHosts
176
+ });
177
+ }
109
178
  /**
110
179
  * The zone-level `<zone>-us-east-1-certificate-arn` export
111
180
  * (`getDomainExportNames(...).usEast1CertificateArn` — the SSOT
@@ -19,6 +19,7 @@ import { type IAutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
19
19
  import { type ISecurityGroup, type IConnectable } from "aws-cdk-lib/aws-ec2";
20
20
  import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
21
21
  import { type Construct } from "constructs";
22
+ import type { EcsIngressProfile } from "../../../resources/aws/compute/ingressProfile.js";
22
23
  /**
23
24
  * Compute type discriminator.
24
25
  * Used to determine which specific interface applies.
@@ -66,6 +67,12 @@ export interface IEcsCompute extends ICompute, IConnectable {
66
67
  * for HTTPS, 80 for HTTP); does not indicate protocol.
67
68
  */
68
69
  getPrimaryListenerPort(): number | undefined;
70
+ /**
71
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
72
+ * listener facts, zone identity, and certificate coverage for constructs
73
+ * that front this compute. Undefined when there is no load balancer.
74
+ */
75
+ getIngressProfile(): EcsIngressProfile | undefined;
69
76
  }
70
77
  /**
71
78
  * Lambda compute interface.
@@ -4,11 +4,13 @@ import { Construct } from "constructs";
4
4
  import type { StackBuilder } from "../base/awsStack.js";
5
5
  import type { ApplicationListener, ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
6
6
  import { type EcsClusterProps } from "./ecsTypes.js";
7
+ import { type EcsIngressProfile } from "./ingressProfile.js";
7
8
  export * from "./ecsTypes.js";
8
9
  export * from "./ecsConstants.js";
9
10
  export * from "./ecsContext.js";
10
11
  export * from "./ecsTaskDefinition.js";
11
12
  export * from "./ecsNetworking.js";
13
+ export * from "./ingressProfile.js";
12
14
  export { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
13
15
  export { validateEcsClusterProps, validateEcsDomainConfig, validateSsmPathComponent } from "./ecsValidation.js";
14
16
  export * from "./ecsServiceFactory.js";
@@ -54,6 +56,7 @@ export default class EcsCluster extends Construct implements IConnectable {
54
56
  private loadBalancer?;
55
57
  private loadBalancerListener?;
56
58
  private certificate?;
59
+ private ingressProfile?;
57
60
  private asgState;
58
61
  private services;
59
62
  private scheduledTaskDefinitions;
@@ -69,6 +72,14 @@ export default class EcsCluster extends Construct implements IConnectable {
69
72
  getLoadBalancer(): ApplicationLoadBalancer | undefined;
70
73
  /** Get the load balancer's listener. Undefined if disabled. */
71
74
  getListener(): ApplicationListener | undefined;
75
+ /**
76
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
77
+ * listener facts (default-action predicate, rule structure, port), the
78
+ * cluster's zone identity, and certificate coverage — for downstream
79
+ * constructs that front this cluster. Undefined when the cluster has no
80
+ * load balancer.
81
+ */
82
+ getIngressProfile(): EcsIngressProfile | undefined;
72
83
  /** Get a specific service by name. */
73
84
  getService(name: string): FargateService | Ec2Service | undefined;
74
85
  /** Get all services in this cluster. */
@@ -12,12 +12,14 @@ import { validateEcsClusterProps } from "./ecsValidation.js";
12
12
  import { createExecutionRole, createTaskRole, createTaskDefinition, addContainersToTask, isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
13
13
  import { addLoadBalancer, addLoadBalancerListener, addHostedZone, addDirectAccessOutputs, addRedirectHostRules, registerServiceWithALB } from "./ecsNetworking.js";
14
14
  import { createService, addServiceScaling, getOrCreateAsgCapacityProvider } from "./ecsServiceFactory.js";
15
+ import { buildIngressProfile, warnWhenRecordedApexUnforwarded } from "./ingressProfile.js";
15
16
  // Re-export all types/enums/constants so existing consumers are not broken
16
17
  export * from "./ecsTypes.js";
17
18
  export * from "./ecsConstants.js";
18
19
  export * from "./ecsContext.js";
19
20
  export * from "./ecsTaskDefinition.js";
20
21
  export * from "./ecsNetworking.js";
22
+ export * from "./ingressProfile.js";
21
23
  export { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
22
24
  export { validateEcsClusterProps, validateEcsDomainConfig, validateSsmPathComponent } from "./ecsValidation.js";
23
25
  export * from "./ecsServiceFactory.js";
@@ -64,6 +66,7 @@ export default class EcsCluster extends Construct {
64
66
  loadBalancer;
65
67
  loadBalancerListener;
66
68
  certificate;
69
+ ingressProfile;
67
70
  // EC2-specific (mutable state shared with ecsServiceFactory)
68
71
  asgState = {
69
72
  providers: new Map(),
@@ -113,6 +116,20 @@ export default class EcsCluster extends Construct {
113
116
  if (hzResult?.redirectRules !== undefined) {
114
117
  addRedirectHostRules(this.ctx, this.loadBalancerListener, hzResult.redirectRules, this.priorityState);
115
118
  }
119
+ // D2 (design 2026-08-18 cdn-app-origin): the synth-time answer to
120
+ // "what does this cluster's listener actually do?", assembled from the
121
+ // same facts the emitters above used — never re-derived from props by
122
+ // downstream consumers (the Cdn construct-reference origin lane).
123
+ this.ingressProfile = buildIngressProfile({
124
+ loadBalancer: this.loadBalancer,
125
+ internal: props.cluster?.loadBalancer === "internal",
126
+ services: props.services,
127
+ certificateAttached: this.certificate !== undefined,
128
+ zoneFacts: hzResult?.ingress
129
+ });
130
+ if (hzResult?.ingress !== undefined) {
131
+ warnWhenRecordedApexUnforwarded(this.ingressProfile, hzResult.ingress.apexRecordMinted, props.clusterName);
132
+ }
116
133
  }
117
134
  else if (this.directAccessEnabled) {
118
135
  addDirectAccessOutputs(this.ctx, this.asgState.autoScalingGroup);
@@ -154,6 +171,16 @@ export default class EcsCluster extends Construct {
154
171
  getListener() {
155
172
  return this.loadBalancerListener;
156
173
  }
174
+ /**
175
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
176
+ * listener facts (default-action predicate, rule structure, port), the
177
+ * cluster's zone identity, and certificate coverage — for downstream
178
+ * constructs that front this cluster. Undefined when the cluster has no
179
+ * load balancer.
180
+ */
181
+ getIngressProfile() {
182
+ return this.ingressProfile;
183
+ }
157
184
  /** Get a specific service by name. */
158
185
  getService(name) {
159
186
  return this.services.get(name)?.service;
@@ -5,6 +5,7 @@ import { type ARecord, type IHostedZone } from "aws-cdk-lib/aws-route53";
5
5
  import type { AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
6
6
  import type { ContainerDefinition, FargateService, Ec2Service } from "aws-cdk-lib/aws-ecs";
7
7
  import { SecurityGroup } from "../networking/securityGroup.js";
8
+ import { type EcsIngressZoneFacts } from "./ingressProfile.js";
8
9
  import type { EcsConstructContext } from "./ecsContext.js";
9
10
  import type { EcsServiceProps } from "./ecsTypes.js";
10
11
  import { type PriorityState } from "./hostHeaderListenerRule.js";
@@ -40,6 +41,7 @@ export declare function addHostedZone(ctx: EcsConstructContext, loadBalancer?: A
40
41
  aRecord?: ARecord;
41
42
  additionalListenerCertificates?: IListenerCertificate[];
42
43
  redirectRules?: EcsRedirectRuleConfig;
44
+ ingress?: EcsIngressZoneFacts;
43
45
  };
44
46
  export declare function addDirectAccessOutputs(ctx: EcsConstructContext, autoScalingGroup?: AutoScalingGroup): void;
45
47
  export declare function registerServiceWithALB(ctx: EcsConstructContext, listener: ApplicationListener, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service, primaryContainer: ContainerDefinition, priorityState: PriorityState): IApplicationTargetGroup;
@@ -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;
@@ -430,7 +482,16 @@ export function addHostedZone(ctx, loadBalancer) {
430
482
  }),
431
483
  ...(redirectHosts.length > 0 && {
432
484
  redirectRules: { hostFqdns: redirectHosts, targetHost: domainName }
433
- })
485
+ }),
486
+ ingress: {
487
+ hostedZone,
488
+ zoneName,
489
+ domainName,
490
+ routedHosts,
491
+ redirectHosts,
492
+ apexRecordMinted: loadBalancer !== undefined && domainConfig?.apexRecord !== "none",
493
+ certificateCoverage
494
+ }
434
495
  };
435
496
  }
436
497
  const US_EAST_1 = "us-east-1";