@fjall/components-infrastructure 14.1.0 → 14.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/lib/patterns/aws/apexDomainPattern.js +1 -1
  2. package/dist/lib/patterns/aws/cdn.d.ts +83 -4
  3. package/dist/lib/patterns/aws/cdn.js +279 -69
  4. package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +83 -0
  5. package/dist/lib/patterns/aws/cdnAppOrigin.js +252 -0
  6. package/dist/lib/patterns/aws/computeEcs.d.ts +9 -1
  7. package/dist/lib/patterns/aws/computeEcs.js +10 -0
  8. package/dist/lib/patterns/aws/delegatedDomainPattern.js +1 -1
  9. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +13 -4
  10. package/dist/lib/patterns/aws/dnsRecordComposer.js +88 -6
  11. package/dist/lib/patterns/aws/domainCertificateComposer.js +66 -1
  12. package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -0
  13. package/dist/lib/patterns/aws/interfaces/domain.d.ts +32 -0
  14. package/dist/lib/patterns/aws/patternDomain.d.ts +9 -0
  15. package/dist/lib/patterns/aws/patternDomain.js +60 -27
  16. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +37 -0
  17. package/dist/lib/resources/aws/cdn/cloudFront.js +96 -4
  18. package/dist/lib/resources/aws/compute/ecs.d.ts +11 -0
  19. package/dist/lib/resources/aws/compute/ecs.js +27 -0
  20. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -0
  21. package/dist/lib/resources/aws/compute/ecsNetworking.js +117 -61
  22. package/dist/lib/resources/aws/compute/ingressProfile.d.ts +203 -0
  23. package/dist/lib/resources/aws/compute/ingressProfile.js +215 -0
  24. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +10 -2
  25. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +18 -9
  26. package/dist/lib/utils/dnsRecordRegistry.d.ts +3 -5
  27. package/dist/lib/utils/dnsRecordRegistry.js +11 -14
  28. package/dist/lib/utils/domainTypes.d.ts +11 -0
  29. package/dist/lib/utils/domainTypes.js +17 -0
  30. package/dist/lib/utils/managedDomainContext.d.ts +58 -1
  31. package/dist/lib/utils/managedDomainContext.js +96 -0
  32. package/package.json +3 -3
@@ -33,12 +33,22 @@ export interface StandardRecord {
33
33
  readonly name: string;
34
34
  readonly value: string | string[];
35
35
  readonly ttl?: number;
36
+ /**
37
+ * Overrides the record's derived construct-id name segment (PascalCase
38
+ * alphanumeric). The rare escape hatch for two record names that sanitise
39
+ * to the same PascalCase segment under `recordIds: "stable"` — the
40
+ * composer's collision refusal names it. Round-tripped by the generator's
41
+ * zone importer and the webapp record splice.
42
+ */
43
+ readonly id?: string;
36
44
  }
37
45
  export interface AliasRecord {
38
46
  readonly type: "A" | "AAAA";
39
47
  readonly name: string;
40
48
  readonly target: FjallTarget;
41
49
  readonly ttl?: never;
50
+ /** Same construct-id override as {@link StandardRecord.id}. */
51
+ readonly id?: string;
42
52
  }
43
53
  export type DnsRecord = StandardRecord | AliasRecord;
44
54
  export type Certificate = string | {
@@ -65,6 +75,28 @@ export type Certificate = string | {
65
75
  export interface DomainCommonProps {
66
76
  readonly zoneName: string;
67
77
  readonly records?: DnsRecord[];
78
+ /**
79
+ * Construct-id scheme for the records list.
80
+ *
81
+ * `"indexed"` (default) embeds each record's LIST POSITION in its logical
82
+ * ID (`...Record0`, `...Record1`, …) — the legacy eject-contract formula,
83
+ * byte-frozen for deployed stacks. Under it the records list is
84
+ * APPEND-ONLY: inserting or removing an entry renumbers every later
85
+ * record, which CloudFormation executes as delete + recreate of live
86
+ * record sets.
87
+ *
88
+ * `"stable"` derives position-independent IDs
89
+ * (`<Zone><Name><Type>Record`) so entries can be inserted, removed and
90
+ * reordered freely. Collision-free by the same doctrine the DNS claim
91
+ * registry enforces (one owner per (zone, name, type); this vocabulary
92
+ * cannot express routing-policy variants) — the composer refuses
93
+ * duplicate (name, type) pairs and sanitised-name collisions at synth,
94
+ * naming the per-record `id` escape hatch. `fjall domain import` emits it
95
+ * for fresh zones. Flipping an ALREADY-DEPLOYED zone renames every
96
+ * record's logical ID (delete + recreate at deploy) — a deliberate
97
+ * two-deploy migration, never a casual edit.
98
+ */
99
+ readonly recordIds?: "indexed" | "stable";
68
100
  readonly certificates?: Certificate[];
69
101
  readonly tags?: Record<string, string>;
70
102
  readonly description?: string;
@@ -57,6 +57,15 @@ export interface ResolvedPatternZone {
57
57
  readonly hostedZone: IHostedZone;
58
58
  readonly zoneName: string;
59
59
  }
60
+ /**
61
+ * Resolve the hosted zone for a pattern domain from explicit identity only
62
+ * (H2). Throws when no identity is given or when the domain falls outside
63
+ * the declared zone. The D2 precedence (explicit `managedDomain` beats the
64
+ * CLI-injected binding, ahead of the BYO `zoneName`/`hostedZoneId` chain)
65
+ * and the binding-vs-exports zone-id branch live in the shared
66
+ * `resolveEffectiveManagedDomain` — the same resolution the ECS networking
67
+ * lane consumes.
68
+ */
60
69
  export declare function resolvePatternZone(request: PatternDomainRequest): ResolvedPatternZone;
61
70
  /**
62
71
  * Resolve the CloudFront viewer certificate for a pattern domain (H3/D3).
@@ -30,37 +30,30 @@ import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
30
30
  import { HostedZone } from "aws-cdk-lib/aws-route53";
31
31
  import { DomainCertificate } from "../../resources/aws/networking/index.js";
32
32
  import { isManagedDomainBinding, isWithinZone } from "../../utils/domainTypes.js";
33
- import { readInjectedManagedDomainBinding } from "../../utils/managedDomainContext.js";
33
+ import { readInjectedManagedDomainCoverage, resolveEffectiveManagedDomain } from "../../utils/managedDomainContext.js";
34
+ import { certNameMatches } from "../../resources/aws/compute/ingressProfile.js";
35
+ import { cdnOriginRefusal } from "./cdnAppOrigin.js";
34
36
  const US_EAST_1 = "us-east-1";
35
37
  /**
36
38
  * Resolve the hosted zone for a pattern domain from explicit identity only
37
- * (H2). Throws when no identity is given or when the domain falls outside the
38
- * declared zone.
39
+ * (H2). Throws when no identity is given or when the domain falls outside
40
+ * the declared zone. The D2 precedence (explicit `managedDomain` beats the
41
+ * CLI-injected binding, ahead of the BYO `zoneName`/`hostedZoneId` chain)
42
+ * and the binding-vs-exports zone-id branch live in the shared
43
+ * `resolveEffectiveManagedDomain` — the same resolution the ECS networking
44
+ * lane consumes.
39
45
  */
40
- /**
41
- * Effective managed-domain identity for a request (D2): an EXPLICIT
42
- * `managedDomain` prop wins over the CLI-injected context binding (explicit
43
- * beats injected), and the context read sits ahead of the BYO
44
- * `zoneName`/`hostedZoneId` chain. Bare-CDK synth carries no context entry
45
- * and falls straight through to the explicit props.
46
- */
47
- function resolveEffectiveManagedDomain(request) {
48
- return (request.identity.managedDomain ??
49
- readInjectedManagedDomainBinding(request.scope.node, request.domain, request.context));
50
- }
51
46
  export function resolvePatternZone(request) {
52
47
  const { scope, idPrefix, context, domain, identity } = request;
53
- const managed = resolveEffectiveManagedDomain(request);
54
- if (managed !== undefined) {
55
- assertDomainWithinZone(context, domain, managed.zoneName);
56
- const hostedZoneId = isManagedDomainBinding(managed)
57
- ? // CLI-injected literal (D2) — carries no Fn.importValue constraint.
58
- managed.hostedZoneId
59
- : // Legacy export-name fallback — same-account, same-region only.
60
- Fn.importValue(managed.hostedZoneIdExport);
48
+ const effective = effectiveManagedDomainFor(request);
49
+ if (effective !== undefined) {
50
+ assertDomainWithinZone(context, domain, effective.zoneName);
61
51
  return {
62
- hostedZone: HostedZone.fromHostedZoneAttributes(scope, `${idPrefix}ManagedHostedZone`, { hostedZoneId, zoneName: managed.zoneName }),
63
- zoneName: managed.zoneName
52
+ hostedZone: HostedZone.fromHostedZoneAttributes(scope, `${idPrefix}ManagedHostedZone`, {
53
+ hostedZoneId: effective.hostedZoneId,
54
+ zoneName: effective.zoneName
55
+ }),
56
+ zoneName: effective.zoneName
64
57
  };
65
58
  }
66
59
  if (identity.zoneName !== undefined) {
@@ -110,8 +103,11 @@ export function resolvePatternZone(request) {
110
103
  */
111
104
  export function resolvePatternCloudFrontCertificate(request, zone) {
112
105
  const { scope, app, idPrefix, context, domain, identity } = request;
113
- const managed = resolveEffectiveManagedDomain(request);
114
- if (managed !== undefined && isManagedDomainBinding(managed)) {
106
+ const effective = effectiveManagedDomainFor(request);
107
+ const managed = effective?.managed;
108
+ if (effective !== undefined &&
109
+ managed !== undefined &&
110
+ isManagedDomainBinding(managed)) {
115
111
  const arn = managed.usEast1CertificateArn;
116
112
  if (arn === undefined) {
117
113
  throw new Error(`${context}: managed domain binding for zone '${managed.zoneName}' ` +
@@ -122,10 +118,13 @@ export function resolvePatternCloudFrontCertificate(request, zone) {
122
118
  "'zoneName' to provision an app-owned us-east-1 certificate.");
123
119
  }
124
120
  assertUsEast1CertificateArn(context, arn);
121
+ assertViewerHostCovered(request, effective);
125
122
  return Certificate.fromCertificateArn(scope, `${idPrefix}ManagedCertificate`, arn);
126
123
  }
127
124
  const region = resolvedRegionOf(scope);
128
- if (managed !== undefined && (region === undefined || region === US_EAST_1)) {
125
+ if (managed !== undefined &&
126
+ !isManagedDomainBinding(managed) &&
127
+ (region === undefined || region === US_EAST_1)) {
129
128
  // Legacy export-name form: Fn.importValue resolves same-account,
130
129
  // same-region only, so the imported certificate satisfies CloudFront
131
130
  // exactly when the app itself deploys to us-east-1. A region-unresolved
@@ -170,6 +169,40 @@ export function resolvePatternCloudFrontCertificate(request, zone) {
170
169
  exportCertificateArn: false
171
170
  }).certificate;
172
171
  }
172
+ function effectiveManagedDomainFor(request) {
173
+ return resolveEffectiveManagedDomain(request.scope.node, request.identity.managedDomain, request.domain, request.context);
174
+ }
175
+ /**
176
+ * E11 — the requested viewer hostname against the managed us-east-1 viewer
177
+ * certificate's declared hosts (D5 coverage context). A definite miss fails
178
+ * CloudFront's own alias-vs-certificate validation at deploy with an opaque
179
+ * InvalidViewerCertificate — refuse at synth with the cure instead. Absent
180
+ * or provenance-gated coverage stays SILENT (no warning): CloudFront
181
+ * validates at deploy regardless, so unknown coverage carries no runtime
182
+ * risk here — unlike the origin lane's W1, where an uncovered hostname only
183
+ * fails at request time.
184
+ */
185
+ function assertViewerHostCovered(request, effective) {
186
+ // D5 provenance gate: an explicit pinned binding may name an OLDER
187
+ // certificate the current coverage does not describe.
188
+ if (effective.explicitPinnedBinding)
189
+ return;
190
+ const coverage = readInjectedManagedDomainCoverage(request.scope.node, effective.zoneName, request.context);
191
+ const hosts = coverage?.usEast1CertificateHosts;
192
+ if (hosts === undefined || hosts.length === 0)
193
+ return;
194
+ if (hosts.some((pattern) => certNameMatches(pattern, request.domain))) {
195
+ return;
196
+ }
197
+ throw cdnOriginRefusal("E11", `${request.context}: the managed domain's us-east-1 viewer certificate ` +
198
+ `covers ${hosts.join(", ")} — none matches '${request.domain}'. ` +
199
+ "CloudFront rejects a distribution whose alias its viewer " +
200
+ "certificate does not cover (InvalidViewerCertificate, at deploy). " +
201
+ `Declare '${request.domain}' (or a covering wildcard) on the domain ` +
202
+ "stack's cloudFront certificate and redeploy it, or set 'zoneName' " +
203
+ "instead of the managed domain to provision an app-owned us-east-1 " +
204
+ "certificate.");
205
+ }
173
206
  function assertDomainWithinZone(context, domain, zoneName) {
174
207
  if (!isWithinZone(domain, zoneName)) {
175
208
  throw new Error(`${context}: domain '${domain}' is not within zone '${zoneName}' ` +
@@ -90,6 +90,11 @@ export interface CloudFrontDistributionProps {
90
90
  export declare class CloudFrontDistribution extends Construct {
91
91
  readonly id: string;
92
92
  private distribution;
93
+ private readonly origins;
94
+ /** Shared S3 access identities, keyed by the owning stack (see
95
+ * `s3OriginAccessIdentityFor` for why the key is a Stack). */
96
+ private readonly s3AccessIdentities;
97
+ private s3AccessControl?;
93
98
  constructor(scope: Construct, id: string, props: CloudFrontDistributionProps);
94
99
  /**
95
100
  * Build a composable CloudFront Function for VIEWER_REQUEST.
@@ -107,7 +112,39 @@ export declare class CloudFrontDistribution extends Construct {
107
112
  /** SPA fallback: serve /index.html @200 for 403/404 so client-side routing
108
113
  * handles the path. CloudFront returns 403 (not 404) for missing S3 keys. */
109
114
  private buildSpaErrorResponses;
115
+ /**
116
+ * One `IOrigin` per distinct origin configuration: behaviours that
117
+ * resolve to the same origin (same bucket/ALB/hostname with the same
118
+ * options) share a single distribution origin entry instead of minting an
119
+ * identical one per behaviour. Token-bearing hostnames (e.g. a Lambda
120
+ * function URL) memoize per token instance — two independently-derived
121
+ * tokens never share, which is the safe direction.
122
+ */
110
123
  private createOrigin;
124
+ private static originKey;
125
+ /**
126
+ * S3 access identities are created HERE, at a position-independent scope,
127
+ * and handed to `S3BucketOrigin` — never left to CDK's default, which
128
+ * creates them under the distribution's positional `Origin${n}` child.
129
+ * An OAI/OAC is semantically "the identity this distribution presents to
130
+ * S3": one per distribution, shared by every S3 origin. Position-scoped
131
+ * identities couple a DEPLOYED IAM resource's logical ID to the origin
132
+ * list's order — origin dedup (or any behaviour insertion) would then
133
+ * silently delete/repurpose a live OAI and atomically swap the bucket
134
+ * policy it gates, 403-ing the paths the edge still serves with the old
135
+ * identity until the distribution update propagates.
136
+ *
137
+ * Cross-stack buckets get their identity in the BUCKET's stack (same rule
138
+ * CDK's default applies): the bucket policy must reference the OAI, and a
139
+ * distribution-stack OAI would complete a cycle with the CDN stack's
140
+ * reference to the bucket.
141
+ */
142
+ private s3OriginAccessIdentityFor;
143
+ /** OAC twin of `s3OriginAccessIdentityFor` — an OAC is a signing config,
144
+ * not a bucket grant (bucket policies key on the distribution ARN), so a
145
+ * single distribution-scoped one serves every OAC origin. */
146
+ private s3OriginAccessControl;
147
+ private buildOrigin;
111
148
  private resolveCachePolicy;
112
149
  private resolveAllowedMethods;
113
150
  private resolveOriginProtocolPolicy;
@@ -1,12 +1,17 @@
1
1
  import { Construct } from "constructs";
2
- import { CfnOutput } from "aws-cdk-lib";
2
+ import { CfnOutput, Names, Stack } from "aws-cdk-lib";
3
3
  import { toPascalCase } from "../../../utils/capitaliseString.js";
4
4
  import { cdnDomainExportName } from "@fjall/util";
5
- import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType } from "aws-cdk-lib/aws-cloudfront";
5
+ import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
6
6
  import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
7
7
  export class CloudFrontDistribution extends Construct {
8
8
  id;
9
9
  distribution;
10
+ origins = new Map();
11
+ /** Shared S3 access identities, keyed by the owning stack (see
12
+ * `s3OriginAccessIdentityFor` for why the key is a Stack). */
13
+ s3AccessIdentities = new Map();
14
+ s3AccessControl;
10
15
  constructor(scope, id, props) {
11
16
  super(scope, id);
12
17
  this.id = id;
@@ -144,7 +149,92 @@ export class CloudFrontDistribution extends Construct {
144
149
  responsePagePath: "/index.html"
145
150
  }));
146
151
  }
152
+ /**
153
+ * One `IOrigin` per distinct origin configuration: behaviours that
154
+ * resolve to the same origin (same bucket/ALB/hostname with the same
155
+ * options) share a single distribution origin entry instead of minting an
156
+ * identical one per behaviour. Token-bearing hostnames (e.g. a Lambda
157
+ * function URL) memoize per token instance — two independently-derived
158
+ * tokens never share, which is the safe direction.
159
+ */
147
160
  createOrigin(config) {
161
+ const key = CloudFrontDistribution.originKey(config);
162
+ const memoized = this.origins.get(key);
163
+ if (memoized !== undefined) {
164
+ return memoized;
165
+ }
166
+ const origin = this.buildOrigin(config);
167
+ this.origins.set(key, origin);
168
+ return origin;
169
+ }
170
+ static originKey(config) {
171
+ switch (config.type) {
172
+ case "s3":
173
+ return [
174
+ "s3",
175
+ config.bucket.node.addr,
176
+ config.originPath ?? "",
177
+ config.originAccess ?? ""
178
+ ].join("|");
179
+ case "alb":
180
+ return [
181
+ "alb",
182
+ config.loadBalancer.node.addr,
183
+ config.httpPort ?? "",
184
+ config.httpsPort ?? "",
185
+ config.protocolPolicy ?? ""
186
+ ].join("|");
187
+ case "http":
188
+ return [
189
+ "http",
190
+ config.domainName,
191
+ config.originPath ?? "",
192
+ config.httpPort ?? "",
193
+ config.httpsPort ?? "",
194
+ config.protocolPolicy ?? ""
195
+ ].join("|");
196
+ }
197
+ }
198
+ /**
199
+ * S3 access identities are created HERE, at a position-independent scope,
200
+ * and handed to `S3BucketOrigin` — never left to CDK's default, which
201
+ * creates them under the distribution's positional `Origin${n}` child.
202
+ * An OAI/OAC is semantically "the identity this distribution presents to
203
+ * S3": one per distribution, shared by every S3 origin. Position-scoped
204
+ * identities couple a DEPLOYED IAM resource's logical ID to the origin
205
+ * list's order — origin dedup (or any behaviour insertion) would then
206
+ * silently delete/repurpose a live OAI and atomically swap the bucket
207
+ * policy it gates, 403-ing the paths the edge still serves with the old
208
+ * identity until the distribution update propagates.
209
+ *
210
+ * Cross-stack buckets get their identity in the BUCKET's stack (same rule
211
+ * CDK's default applies): the bucket policy must reference the OAI, and a
212
+ * distribution-stack OAI would complete a cycle with the CDN stack's
213
+ * reference to the bucket.
214
+ */
215
+ s3OriginAccessIdentityFor(bucket) {
216
+ const bucketStack = Stack.of(bucket);
217
+ const existing = this.s3AccessIdentities.get(bucketStack);
218
+ if (existing !== undefined) {
219
+ return existing;
220
+ }
221
+ const sameStack = bucketStack === Stack.of(this);
222
+ const identity = sameStack
223
+ ? new OriginAccessIdentity(this, "S3OriginAccessIdentity", {
224
+ comment: `Identity for ${this.node.path}`
225
+ })
226
+ : new OriginAccessIdentity(bucketStack, `${Names.uniqueId(this)}S3OriginAccessIdentity`, { comment: `Identity for ${this.node.path}` });
227
+ this.s3AccessIdentities.set(bucketStack, identity);
228
+ return identity;
229
+ }
230
+ /** OAC twin of `s3OriginAccessIdentityFor` — an OAC is a signing config,
231
+ * not a bucket grant (bucket policies key on the distribution ARN), so a
232
+ * single distribution-scoped one serves every OAC origin. */
233
+ s3OriginAccessControl() {
234
+ this.s3AccessControl ??= new S3OriginAccessControl(this, "S3OriginAccessControl");
235
+ return this.s3AccessControl;
236
+ }
237
+ buildOrigin(config) {
148
238
  switch (config.type) {
149
239
  case "s3":
150
240
  // OAC (single-stack only) is safe when the bucket and distribution live
@@ -153,11 +243,13 @@ export class CloudFrontDistribution extends Construct {
153
243
  // See: https://github.com/aws/aws-cdk/issues/31462
154
244
  if (config.originAccess === "oac") {
155
245
  return S3BucketOrigin.withOriginAccessControl(config.bucket, {
156
- originPath: config.originPath
246
+ originPath: config.originPath,
247
+ originAccessControl: this.s3OriginAccessControl()
157
248
  });
158
249
  }
159
250
  return S3BucketOrigin.withOriginAccessIdentity(config.bucket, {
160
- originPath: config.originPath
251
+ originPath: config.originPath,
252
+ originAccessIdentity: this.s3OriginAccessIdentityFor(config.bucket)
161
253
  });
162
254
  case "alb":
163
255
  return new LoadBalancerV2Origin(config.loadBalancer, {
@@ -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;