@fjall/components-infrastructure 2.34.0 → 3.0.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 (62) hide show
  1. package/dist/lib/app.d.ts +19 -2
  2. package/dist/lib/app.js +46 -3
  3. package/dist/lib/patterns/aws/apexDomainPattern.d.ts +11 -12
  4. package/dist/lib/patterns/aws/apexDomainPattern.js +11 -31
  5. package/dist/lib/patterns/aws/cdn.js +25 -10
  6. package/dist/lib/patterns/aws/computeEcs.js +6 -1
  7. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -3
  8. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +13 -4
  9. package/dist/lib/patterns/aws/delegatedDomainPattern.js +57 -8
  10. package/dist/lib/patterns/aws/devSubstrate.d.ts +2 -1
  11. package/dist/lib/patterns/aws/devSubstrate.js +18 -8
  12. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +2 -9
  13. package/dist/lib/patterns/aws/dnsRecordComposer.js +5 -97
  14. package/dist/lib/patterns/aws/domain.d.ts +3 -2
  15. package/dist/lib/patterns/aws/domain.js +3 -2
  16. package/dist/lib/patterns/aws/domainValidation.js +71 -46
  17. package/dist/lib/patterns/aws/externalRecordsPattern.js +2 -11
  18. package/dist/lib/patterns/aws/index.d.ts +1 -3
  19. package/dist/lib/patterns/aws/index.js +2 -3
  20. package/dist/lib/patterns/aws/interfaces/domain.d.ts +22 -8
  21. package/dist/lib/patterns/aws/interfaces/index.d.ts +0 -1
  22. package/dist/lib/patterns/aws/interfaces/index.js +3 -0
  23. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +55 -10
  24. package/dist/lib/patterns/aws/patternDomain.d.ts +77 -0
  25. package/dist/lib/patterns/aws/patternDomain.js +198 -0
  26. package/dist/lib/patterns/aws/payload.js +23 -30
  27. package/dist/lib/patterns/aws/staticSite.d.ts +1 -0
  28. package/dist/lib/patterns/aws/staticSite.js +28 -33
  29. package/dist/lib/resources/aws/base/awsStack.d.ts +8 -0
  30. package/dist/lib/resources/aws/base/awsStack.js +20 -17
  31. package/dist/lib/resources/aws/compute/ecs.d.ts +1 -1
  32. package/dist/lib/resources/aws/compute/ecs.js +1 -1
  33. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +1 -1
  34. package/dist/lib/resources/aws/compute/ecsNetworking.js +212 -47
  35. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +60 -9
  36. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +14 -1
  37. package/dist/lib/resources/aws/compute/ecsValidation.js +51 -0
  38. package/dist/lib/resources/aws/networking/dnsRecord/aRecord.js +3 -2
  39. package/dist/lib/resources/aws/networking/dnsRecord/aaaaRecord.js +3 -2
  40. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +23 -1
  41. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +14 -3
  42. package/dist/lib/resources/aws/networking/dnsRecord/caaRecord.js +3 -2
  43. package/dist/lib/resources/aws/networking/dnsRecord/cnameRecord.js +3 -2
  44. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +10 -1
  45. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +21 -4
  46. package/dist/lib/resources/aws/networking/dnsRecord/mxRecord.js +3 -2
  47. package/dist/lib/resources/aws/networking/dnsRecord/nsRecord.js +3 -2
  48. package/dist/lib/resources/aws/networking/dnsRecord/srvRecord.js +3 -2
  49. package/dist/lib/resources/aws/networking/dnsRecord/txtRecord.js +3 -2
  50. package/dist/lib/resources/aws/networking/domainCertificate.d.ts +28 -4
  51. package/dist/lib/resources/aws/networking/domainCertificate.js +24 -10
  52. package/dist/lib/resources/aws/networking/hostedZone.d.ts +11 -0
  53. package/dist/lib/resources/aws/networking/hostedZone.js +6 -0
  54. package/dist/lib/utils/costAllocationTags.d.ts +9 -0
  55. package/dist/lib/utils/costAllocationTags.js +6 -1
  56. package/dist/lib/utils/dnsRecordRegistry.d.ts +52 -0
  57. package/dist/lib/utils/dnsRecordRegistry.js +64 -0
  58. package/dist/lib/utils/domainTypes.d.ts +43 -36
  59. package/dist/lib/utils/domainTypes.js +65 -2
  60. package/dist/lib/utils/managedDomainContext.d.ts +37 -0
  61. package/dist/lib/utils/managedDomainContext.js +111 -0
  62. package/package.json +4 -4
@@ -1,11 +1,17 @@
1
1
  import { ApplicationProtocol, ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  import { Port } from "aws-cdk-lib/aws-ec2";
3
- import { CfnOutput, Duration, Fn } from "aws-cdk-lib";
4
- import { Certificate, CertificateValidation } from "aws-cdk-lib/aws-certificatemanager";
5
- import { ARecord, HostedZone as AWSHostedZone, RecordTarget } from "aws-cdk-lib/aws-route53";
3
+ import { CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
4
+ import { createHash } from "node:crypto";
5
+ import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
6
+ import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
6
7
  import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
7
- import { HostedZone as FjallHostedZone } from "../networking/hostedZone.js";
8
+ import { DomainCertificate } from "../networking/domainCertificate.js";
9
+ import { AliasRecord } from "../networking/dnsRecord/aliasRecord.js";
8
10
  import { SecurityGroup } from "../networking/securityGroup.js";
11
+ import { DNS_APEX, isManagedDomainBinding, isWithinZone } from "../../../utils/domainTypes.js";
12
+ import { readInjectedManagedDomainBinding } from "../../../utils/managedDomainContext.js";
13
+ import { getSafeZoneName, toPascalCase } from "../../../utils/capitaliseString.js";
14
+ import { FjallLogger } from "../../../utils/validationLogger.js";
9
15
  import { isServiceEc2 } from "./ecsTaskDefinition.js";
10
16
  import { createApplicationLoadBalancer } from "./applicationLoadBalancer.js";
11
17
  import { addRoutingListener } from "./listenerRouting.js";
@@ -97,6 +103,60 @@ export function addLoadBalancerListener(ctx, loadBalancer, certificate) {
97
103
  default404: willHaveMultipleRoutes || noServicePorts
98
104
  });
99
105
  }
106
+ /**
107
+ * Reproduce the CloudFormation logical ID CDK allocated for the pre-wrapper
108
+ * raw `ARecord` at `<scope>/<id>` — i.e. for its `CfnRecordSet` default child
109
+ * at `<scope>/<id>/Resource` (the `makeUniqueId` algorithm from aws-cdk-lib
110
+ * `core/lib/private/uniqueid.ts`, pinned byte-for-byte by
111
+ * `ecsDomainBaseline.test.ts`). The `AliasRecord` wrapper nests its RecordSet
112
+ * one level deeper (`<scope>/<id>/Record/Resource`), which would re-hash the
113
+ * logical ID and churn every deployed apex record — a logical-ID change on a
114
+ * live RecordSet is a create-then-delete replacement that Route53 rejects
115
+ * ("record set already exists"). Per the flipless-retain-flip no-churn
116
+ * principle we pin the legacy ID via `overrideLogicalId` instead.
117
+ */
118
+ function preWrapperLogicalId(scope, id) {
119
+ const stack = Stack.of(scope);
120
+ // "Resource" — the raw L2's CfnRecordSet default-child id — is hidden from
121
+ // the human-readable portion below but participates in the hash.
122
+ const components = [id, "Resource"];
123
+ for (let current = scope; current !== undefined && current !== stack; current = current.node.scope) {
124
+ components.unshift(current.node.id);
125
+ }
126
+ // "Default" components are hidden from both the human-readable portion and
127
+ // the hash; "Resource" only from the human-readable portion.
128
+ const visible = components.filter((c) => c !== "Default");
129
+ const sole = visible.length === 1 ? visible[0] : undefined;
130
+ if (sole !== undefined) {
131
+ const candidate = sole.replace(/[^A-Za-z0-9]/g, "");
132
+ if (candidate.length <= 255)
133
+ return candidate;
134
+ }
135
+ const deduped = [];
136
+ for (const component of visible) {
137
+ const previous = deduped[deduped.length - 1];
138
+ if (previous === undefined || !previous.endsWith(component)) {
139
+ deduped.push(component);
140
+ }
141
+ }
142
+ const hash = createHash("md5")
143
+ .update(visible.join("/"))
144
+ .digest("hex")
145
+ .slice(0, 8)
146
+ .toUpperCase();
147
+ const human = deduped
148
+ .filter((c) => c !== "Resource")
149
+ .map((c) => c.replace(/[^A-Za-z0-9]/g, ""))
150
+ .join("")
151
+ .slice(0, 240);
152
+ return human + hash;
153
+ }
154
+ /** Record label for `fqdn` relative to `zoneName` (apex → `@`). */
155
+ function recordLabelWithin(fqdn, zoneName) {
156
+ return fqdn === zoneName
157
+ ? DNS_APEX
158
+ : fqdn.slice(0, fqdn.length - zoneName.length - 1);
159
+ }
100
160
  export function addHostedZone(ctx, loadBalancer) {
101
161
  const props = ctx.props;
102
162
  const domainConfig = props.cluster?.domainConfig;
@@ -105,43 +165,129 @@ export function addHostedZone(ctx, loadBalancer) {
105
165
  if (!domainName)
106
166
  return {};
107
167
  let hostedZone;
168
+ let zoneName;
108
169
  let certificate;
109
- let aRecord;
110
- // Managed domain: import zone and cert from domain stack via Fn.importValue
111
- if (domainConfig?.managedDomain) {
112
- const managed = domainConfig.managedDomain;
113
- hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
114
- hostedZoneId: Fn.importValue(managed.hostedZoneIdExport),
115
- zoneName: managed.zoneName
116
- });
117
- certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, Fn.importValue(managed.certificateArnExport));
170
+ // D2 — explicit props.managedDomain wins over the CLI-injected context
171
+ // binding (explicit beats injected); the context read sits ahead of the
172
+ // BYO domainConfig.hostedZone chain. Bare-CDK synth has no context entry.
173
+ const managed = domainConfig?.managedDomain ??
174
+ readInjectedManagedDomainBinding(ctx.scope.node, domainName, `Cluster '${props.clusterName}'`);
175
+ if (managed) {
176
+ zoneName = managed.zoneName;
177
+ if (isManagedDomainBinding(managed)) {
178
+ // D2 binding: concrete values injected by the CLI at deploy time —
179
+ // literals cross accounts and regions where Fn.importValue cannot.
180
+ hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
181
+ hostedZoneId: managed.hostedZoneId,
182
+ zoneName: managed.zoneName
183
+ });
184
+ if (managed.certificateArn !== undefined) {
185
+ certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, managed.certificateArn);
186
+ }
187
+ }
188
+ else {
189
+ // Export-name fallback (bare-CDK synth): same account, same region only.
190
+ hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
191
+ hostedZoneId: Fn.importValue(managed.hostedZoneIdExport),
192
+ zoneName: managed.zoneName
193
+ });
194
+ certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, Fn.importValue(managed.certificateArnExport));
195
+ }
118
196
  }
119
- else if (!domainConfig?.hostedZone) {
120
- const fjallHostedZone = new FjallHostedZone(ctx.scope, `${props.clusterName}HostedZone`, {
121
- zoneName: domainName
122
- });
123
- hostedZone = fjallHostedZone.hostedZone;
197
+ else if (domainConfig?.hostedZone) {
198
+ hostedZone = domainConfig.hostedZone.hostedZone;
199
+ zoneName = domainConfig.hostedZone.zoneName;
124
200
  }
125
201
  else {
126
- hostedZone = domainConfig.hostedZone.hostedZone;
202
+ // H1/D1 — Fjall never creates a hosted zone inside an app stack: it would
203
+ // be undelegated (its nameservers are never published), ACM DNS validation
204
+ // would hang until CloudFormation rolls back (~1 h), and destroying the
205
+ // stack would delete the zone.
206
+ throw new Error(`Cluster '${props.clusterName}': domain '${domainName}' names no hosted zone source ` +
207
+ "(got neither domainConfig.managedDomain nor domainConfig.hostedZone). " +
208
+ "Fjall never creates a hosted zone inside an app stack — it would be undelegated, " +
209
+ "so certificate validation hangs until rollback and stack destruction deletes the zone. " +
210
+ "Reference a managed domain (domainConfig.managedDomain) or import the zone explicitly " +
211
+ "(domainConfig.hostedZone: HostedZoneFactory.import(stack, hostedZoneId, zoneName)).");
212
+ }
213
+ if (!isWithinZone(domainName, zoneName)) {
214
+ throw new Error(`Cluster '${props.clusterName}': domain '${domainName}' is outside hosted zone ` +
215
+ `'${zoneName}' (got zone '${zoneName}'). A record written here would never resolve. ` +
216
+ `Use a zone that contains '${domainName}', or manage the domain from its own Domain stack.`);
127
217
  }
218
+ // H7 — host-routing completeness: every `routing: { host }` needs an alias
219
+ // record and (on the cluster-minted certificate) a SAN, or the deploy goes
220
+ // green and TLS fails at the first request.
221
+ const routedHosts = [];
222
+ for (const service of props.services) {
223
+ const rules = Array.isArray(service.routing)
224
+ ? service.routing
225
+ : service.routing
226
+ ? [service.routing]
227
+ : [];
228
+ for (const rule of rules) {
229
+ if (rule.host === undefined)
230
+ continue;
231
+ if (!isWithinZone(rule.host, zoneName)) {
232
+ throw new Error(`Cluster '${props.clusterName}', service '${service.name}': routing.host ` +
233
+ `'${rule.host}' is outside hosted zone '${zoneName}' (got '${rule.host}'). ` +
234
+ "Its alias record and certificate SAN cannot be created here, so TLS would " +
235
+ `fail after a green deploy. Use a host under '${zoneName}', or serve it ` +
236
+ "from its own Domain stack.");
237
+ }
238
+ if (!routedHosts.includes(rule.host))
239
+ routedHosts.push(rule.host);
240
+ }
241
+ }
242
+ const subjectAlternativeNames = routedHosts.filter((h) => h !== domainName);
128
243
  if (domainConfig?.certificate) {
129
244
  certificate = domainConfig.certificate;
130
245
  }
131
- else if (!domainConfig?.managedDomain) {
132
- certificate = new Certificate(ctx.scope, `${props.clusterName}Certificate`, {
246
+ if (certificate !== undefined) {
247
+ if (subjectAlternativeNames.length > 0) {
248
+ FjallLogger.warn(`Cluster '${props.clusterName}': host-routed services rely on the supplied ` +
249
+ `certificate covering ${subjectAlternativeNames.join(", ")} — Fjall cannot ` +
250
+ "add SANs to an imported certificate. Ensure it carries these names or a " +
251
+ "matching wildcard, or TLS fails for those hosts.");
252
+ }
253
+ }
254
+ else {
255
+ certificate = new DomainCertificate(ctx.scope, `${props.clusterName}Certificate`, {
133
256
  domainName,
134
- validation: CertificateValidation.fromDns(hostedZone)
135
- });
257
+ ...(subjectAlternativeNames.length > 0 && { subjectAlternativeNames }),
258
+ hostedZone,
259
+ // Honest environment tag: the cluster's app already tags the
260
+ // account-stage-resolved environment tree-wide (App.globalTags via
261
+ // getConfig); the mint must inherit it, not out-tag it with the
262
+ // resource-level "management" default. No sniffing here — any local
263
+ // re-resolution (context/env-var channels) can diverge from the app
264
+ // cascade, and did on the first live plan.
265
+ inheritCostAllocationEnvironment: true,
266
+ // The cert is consumed in-stack by the ALB listener; exporting would
267
+ // collide with a managed-domain/apex stack that owns the domain's
268
+ // export names.
269
+ exportCertificateArn: false
270
+ }).certificate;
136
271
  }
137
- if (domainConfig) {
138
- const region = "region" in domainConfig ? domainConfig.region : undefined;
139
- const weight = "weight" in domainConfig ? domainConfig.weight : undefined;
140
- const geoLocation = "geoLocation" in domainConfig ? domainConfig.geoLocation : undefined;
141
- const hasRoutingPolicy = !!region || weight !== undefined || !!geoLocation;
142
- let setIdentifier = domainConfig.setIdentifier;
143
- if (hasRoutingPolicy && !setIdentifier) {
144
- if (region) {
272
+ let aRecord;
273
+ if (loadBalancer) {
274
+ const routingPolicy = domainConfig?.routingPolicy;
275
+ const weight = domainConfig !== undefined && "weight" in domainConfig
276
+ ? domainConfig.weight
277
+ : undefined;
278
+ const geoLocation = domainConfig !== undefined && "geoLocation" in domainConfig
279
+ ? domainConfig.geoLocation
280
+ : undefined;
281
+ const region = routingPolicy?.region;
282
+ const hasRoutingPolicy = routingPolicy !== undefined ||
283
+ weight !== undefined ||
284
+ geoLocation !== undefined;
285
+ // Identical derivation to the removed `domainConfig.region` era
286
+ // (`<clusterName><region>`) so migrated clusters emit byte-identical
287
+ // CloudFormation — pinned by ecsDomainBaseline.test.ts.
288
+ let setIdentifier = routingPolicy?.setIdentifier ?? domainConfig?.setIdentifier;
289
+ if (hasRoutingPolicy && setIdentifier === undefined) {
290
+ if (region !== undefined) {
145
291
  setIdentifier = `${props.clusterName}${region}`;
146
292
  }
147
293
  else if (weight !== undefined) {
@@ -151,27 +297,46 @@ export function addHostedZone(ctx, loadBalancer) {
151
297
  setIdentifier = `${props.clusterName}Geo`;
152
298
  }
153
299
  }
154
- if (loadBalancer && hostedZone) {
155
- aRecord = new ARecord(ctx.scope, `${props.clusterName}ARecord`, {
156
- recordName: domainName,
300
+ const routingProps = {
301
+ ...(region !== undefined && { region }),
302
+ ...(weight !== undefined && { weight }),
303
+ ...(geoLocation !== undefined && { geoLocation }),
304
+ ...(setIdentifier !== undefined && { setIdentifier })
305
+ };
306
+ const apex = new AliasRecord(ctx.scope, `${props.clusterName}ARecord`, {
307
+ zone: hostedZone,
308
+ zoneName,
309
+ recordName: recordLabelWithin(domainName, zoneName),
310
+ target: new LoadBalancerTarget(loadBalancer, {
311
+ evaluateTargetHealth: hasRoutingPolicy
312
+ }),
313
+ // No-churn adoption: the pre-wrapper raw ARecord carried no comment, so
314
+ // adding one now would be a property delta on the live production apex
315
+ // record (flipless-retain-flip principle).
316
+ omitComment: true,
317
+ ...routingProps
318
+ });
319
+ // Preserve the pre-wrapper logical ID — see preWrapperLogicalId.
320
+ apex.record.node.defaultChild.overrideLogicalId(preWrapperLogicalId(ctx.scope, `${props.clusterName}ARecord`));
321
+ aRecord = apex.record;
322
+ for (const host of routedHosts) {
323
+ if (host === domainName)
324
+ continue;
325
+ const safeHost = toPascalCase(getSafeZoneName(host));
326
+ new AliasRecord(ctx.scope, `${props.clusterName}${safeHost}AliasRecord`, {
157
327
  zone: hostedZone,
158
- target: RecordTarget.fromAlias(new LoadBalancerTarget(loadBalancer, {
328
+ zoneName,
329
+ recordName: recordLabelWithin(host, zoneName),
330
+ target: new LoadBalancerTarget(loadBalancer, {
159
331
  evaluateTargetHealth: hasRoutingPolicy
160
- })),
161
- region,
162
- weight,
163
- geoLocation,
164
- setIdentifier: setIdentifier
332
+ }),
333
+ // Host records inherit the cluster's routing policy: a latency apex
334
+ // beside a plain host record would collide with the other region's
335
+ // deploy of the same (name, type) pair.
336
+ ...routingProps
165
337
  });
166
338
  }
167
339
  }
168
- else if (simpleDomain && loadBalancer && hostedZone) {
169
- aRecord = new ARecord(ctx.scope, `${props.clusterName}ARecord`, {
170
- recordName: domainName,
171
- zone: hostedZone,
172
- target: RecordTarget.fromAlias(new LoadBalancerTarget(loadBalancer))
173
- });
174
- }
175
340
  return { hostedZone, certificate, aRecord };
176
341
  }
177
342
  export function addDirectAccessOutputs(ctx, autoScalingGroup) {
@@ -17,7 +17,7 @@ import { type Certificate } from "aws-cdk-lib/aws-certificatemanager";
17
17
  import { type ConnectionSpec } from "../../../utils/connector.js";
18
18
  import { type RemoteConnectionSpec } from "./ecsRemoteConnections.js";
19
19
  import { type SecretImport } from "../secrets/index.js";
20
- import type { ManagedDomainExports } from "../../../utils/domainTypes.js";
20
+ import type { ManagedDomainBinding, ManagedDomainExports } from "../../../utils/domainTypes.js";
21
21
  import type { ITopic } from "aws-cdk-lib/aws-sns";
22
22
  import type { ILogGroup } from "aws-cdk-lib/aws-logs";
23
23
  import type { EcsServiceAlarmThresholds, LogPatternAlarmSpec } from "../monitoring/index.js";
@@ -135,19 +135,65 @@ export interface Ec2CapacityConfig {
135
135
  */
136
136
  tags?: Record<string, string>;
137
137
  }
138
+ /**
139
+ * Explicit Route53 latency routing policy for the cluster's alias records
140
+ * (design D7/H8). Replaces the removed `domainConfig.region` field, which
141
+ * silently converted the record to a latency policy with an auto-derived
142
+ * `setIdentifier`.
143
+ */
144
+ export interface EcsLatencyRoutingPolicy {
145
+ type: "latency";
146
+ /** AWS region this record answers for. */
147
+ region: string;
148
+ /**
149
+ * Routing-policy record discriminator. Default: `<clusterName><region>` —
150
+ * the exact derivation the removed `region` field used, so migrating a
151
+ * deployed cluster emits byte-identical CloudFormation (no churn on the
152
+ * production apex record).
153
+ */
154
+ setIdentifier?: string;
155
+ }
156
+ export type EcsDomainRoutingPolicy = EcsLatencyRoutingPolicy;
138
157
  /**
139
158
  * Domain configuration for HTTPS and DNS.
159
+ *
160
+ * A zone source is REQUIRED: reference a managed domain stack
161
+ * (`managedDomain`) or import the zone explicitly (`hostedZone`). Fjall never
162
+ * creates a hosted zone inside an app stack (design D1/H1) — such a zone
163
+ * would be undelegated, so ACM DNS validation hangs until CloudFormation
164
+ * rolls back, and destroying the stack would delete the zone.
140
165
  */
141
166
  export interface DomainBaseConfig {
142
167
  domainName: string;
168
+ /** Explicitly imported zone (BYO path), e.g. `HostedZoneFactory.import(...)`. */
143
169
  hostedZone?: FjallHostedZone;
144
170
  certificate?: Certificate;
171
+ /**
172
+ * Routing-policy record discriminator for the weighted/geolocation
173
+ * variants. For latency routing prefer `routingPolicy.setIdentifier`;
174
+ * this field remains the fallback for migrated configs.
175
+ */
145
176
  setIdentifier?: string;
146
- /** Import zone and cert from a managed domain stack via Fn.importValue() */
147
- managedDomain?: ManagedDomainExports;
148
- }
149
- export interface LatencyDomainConfig extends DomainBaseConfig {
150
- region: string;
177
+ /**
178
+ * Import zone and cert from a managed domain stack. `ManagedDomainBinding`
179
+ * carries literal values injected by the CLI at deploy time (crosses
180
+ * accounts and regions); `ManagedDomainExports` is the bare-CDK
181
+ * `Fn.importValue()` fallback (same account, same region only).
182
+ */
183
+ managedDomain?: ManagedDomainExports | ManagedDomainBinding;
184
+ /**
185
+ * Explicit Route53 routing policy for the cluster's alias records.
186
+ * Omit for a plain alias record.
187
+ */
188
+ routingPolicy?: EcsDomainRoutingPolicy;
189
+ /**
190
+ * @deprecated Removed (design H8). `region` silently converted the record
191
+ * to a latency routing policy with an auto-derived `setIdentifier`. Declare
192
+ * it explicitly: `routingPolicy: { type: "latency", region }`. Typed
193
+ * `never` so old code fails to compile; a runtime guard throws with the
194
+ * same cure for non-TypeScript callers.
195
+ */
196
+ region?: never;
151
197
  }
152
198
  export interface WeightedDomainConfig extends DomainBaseConfig {
153
199
  weight: number;
@@ -155,7 +201,7 @@ export interface WeightedDomainConfig extends DomainBaseConfig {
155
201
  export interface GeoLocationDomainConfig extends DomainBaseConfig {
156
202
  geoLocation: GeoLocation;
157
203
  }
158
- export type DomainConfig = DomainBaseConfig | LatencyDomainConfig | WeightedDomainConfig | GeoLocationDomainConfig;
204
+ export type DomainConfig = DomainBaseConfig | WeightedDomainConfig | GeoLocationDomainConfig;
159
205
  /**
160
206
  * A dependency on another container in the same task definition.
161
207
  * Maps directly to ECS `ContainerDependency`. See `ContainerDependency` in
@@ -270,6 +316,10 @@ export interface EcsClusterClusterConfig {
270
316
  * Domain for HTTPS access.
271
317
  * - Omit: ALB created with default DNS (*.elb.amazonaws.com)
272
318
  * - Specified: Creates ACM certificate + Route53 DNS A record
319
+ *
320
+ * Requires `domainConfig` to name a zone source (`managedDomain` or
321
+ * `hostedZone`) — Fjall never creates a hosted zone inside an app stack
322
+ * (design D1/H1), so a bare `domain` with no zone source throws at synth.
273
323
  */
274
324
  domain?: string;
275
325
  /**
@@ -287,8 +337,9 @@ export interface EcsClusterClusterConfig {
287
337
  */
288
338
  directAccess?: boolean;
289
339
  /**
290
- * Domain configuration for advanced routing policies (latency, weighted, geo).
291
- * Only used when domain is specified.
340
+ * Domain configuration: zone source (managed domain or imported zone),
341
+ * certificate, and routing policy (latency via `routingPolicy`, weighted,
342
+ * geo). Required for any custom domain.
292
343
  */
293
344
  domainConfig?: DomainConfig;
294
345
  /**
@@ -1,4 +1,4 @@
1
- import type { EcsClusterProps } from "./ecsTypes.js";
1
+ import type { DomainConfig, EcsClusterProps } from "./ecsTypes.js";
2
2
  /**
3
3
  * Validates ECS cluster props before construction.
4
4
  * Pure function — does not depend on class state.
@@ -23,6 +23,19 @@ import type { EcsClusterProps } from "./ecsTypes.js";
23
23
  * @throws Error if validation fails
24
24
  */
25
25
  export declare function validateEcsClusterProps(props: EcsClusterProps): void;
26
+ /**
27
+ * Domain-config constraints shared by the resources-layer hook
28
+ * (`validateEcsClusterProps`) and the patterns-layer mirror
29
+ * (`validateEcsProps` in `lib/patterns/aws/computeEcs.ts`). Pure — safe to
30
+ * call from both per generator-standards § "Validate at the Lowest Layer the
31
+ * Field Belongs To". `clusterName` is unavailable at the patterns layer, so
32
+ * the context prefix falls back to the domain name.
33
+ *
34
+ * @param domainConfig - The domain config to validate (no-op when undefined)
35
+ * @param clusterName - Cluster name for the error-context prefix, if known
36
+ * @throws Error if validation fails
37
+ */
38
+ export declare function validateEcsDomainConfig(domainConfig: DomainConfig | undefined, clusterName?: string): void;
26
39
  /**
27
40
  * Validates an SSM path component for correctness.
28
41
  * SSM parameter paths have specific constraints that must be enforced.
@@ -26,6 +26,7 @@ import { ScalingType } from "./ecsTypes.js";
26
26
  * @throws Error if validation fails
27
27
  */
28
28
  export function validateEcsClusterProps(props) {
29
+ validateEcsDomainConfig(props.cluster?.domainConfig, props.clusterName);
29
30
  const loadBalancerDisabled = props.cluster?.loadBalancer === false ||
30
31
  props.cluster?.directAccess === true;
31
32
  // Validate services array
@@ -173,6 +174,56 @@ export function validateEcsClusterProps(props) {
173
174
  }
174
175
  }
175
176
  }
177
+ /**
178
+ * Domain-config constraints shared by the resources-layer hook
179
+ * (`validateEcsClusterProps`) and the patterns-layer mirror
180
+ * (`validateEcsProps` in `lib/patterns/aws/computeEcs.ts`). Pure — safe to
181
+ * call from both per generator-standards § "Validate at the Lowest Layer the
182
+ * Field Belongs To". `clusterName` is unavailable at the patterns layer, so
183
+ * the context prefix falls back to the domain name.
184
+ *
185
+ * @param domainConfig - The domain config to validate (no-op when undefined)
186
+ * @param clusterName - Cluster name for the error-context prefix, if known
187
+ * @throws Error if validation fails
188
+ */
189
+ export function validateEcsDomainConfig(domainConfig, clusterName) {
190
+ if (domainConfig === undefined)
191
+ return;
192
+ const context = clusterName !== undefined
193
+ ? `Cluster '${clusterName}'`
194
+ : `Domain '${domainConfig.domainName}'`;
195
+ // H8 — the field is `never`-typed, but JavaScript callers and casts can
196
+ // still pass it; fail closed with the cure naming the new shape.
197
+ const legacyRegion = domainConfig.region;
198
+ if (legacyRegion !== undefined) {
199
+ throw new Error(`${context}: domainConfig.region has been removed (got '${String(legacyRegion)}') — ` +
200
+ "it silently converted the record to a latency routing policy with an " +
201
+ "auto-derived setIdentifier. Declare the policy explicitly: " +
202
+ `routingPolicy: { type: "latency", region: "${String(legacyRegion)}" }. ` +
203
+ "The default setIdentifier derivation ('<clusterName><region>') is " +
204
+ "unchanged, so the migration emits identical CloudFormation.");
205
+ }
206
+ const routingPolicy = domainConfig.routingPolicy;
207
+ if (routingPolicy !== undefined) {
208
+ if (routingPolicy.type !== "latency") {
209
+ throw new Error(`${context}: unsupported routingPolicy.type '${String(routingPolicy.type)}' (supported: 'latency'). For weighted or geolocation routing use ` +
210
+ "the 'weight' / 'geoLocation' domainConfig fields.");
211
+ }
212
+ if (typeof routingPolicy.region !== "string" ||
213
+ routingPolicy.region.trim() === "") {
214
+ throw new Error(`${context}: routingPolicy.region must be a non-empty AWS region ` +
215
+ `(got ${JSON.stringify(routingPolicy.region)}). Example: ` +
216
+ `routingPolicy: { type: "latency", region: "us-east-1" }.`);
217
+ }
218
+ const weight = "weight" in domainConfig ? domainConfig.weight : undefined;
219
+ const geoLocation = "geoLocation" in domainConfig ? domainConfig.geoLocation : undefined;
220
+ if (weight !== undefined || geoLocation !== undefined) {
221
+ throw new Error(`${context}: routingPolicy cannot be combined with ` +
222
+ `'${weight !== undefined ? "weight" : "geoLocation"}' — a record ` +
223
+ "set carries exactly one routing policy. Remove one of them.");
224
+ }
225
+ }
226
+ }
176
227
  /**
177
228
  * Validates an SSM path component for correctness.
178
229
  * SSM parameter paths have specific constraints that must be enforced.
@@ -1,14 +1,15 @@
1
1
  import { Construct } from "constructs";
2
2
  import { ARecord as CdkARecord, RecordTarget } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class ARecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description = props.description ?? defaultDnsComment("A", this.fqdn);
12
+ claimDnsRecord(this, props, "A", this.fqdn);
12
13
  this.record = new CdkARecord(this, "Record", {
13
14
  zone: props.zone,
14
15
  recordName: this.fqdn,
@@ -1,15 +1,16 @@
1
1
  import { Construct } from "constructs";
2
2
  import { AaaaRecord as CdkAaaaRecord, RecordTarget } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class AaaaRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description =
12
12
  props.description ?? defaultDnsComment("AAAA", this.fqdn);
13
+ claimDnsRecord(this, props, "AAAA", this.fqdn);
13
14
  this.record = new CdkAaaaRecord(this, "Record", {
14
15
  zone: props.zone,
15
16
  recordName: this.fqdn,
@@ -1,8 +1,30 @@
1
1
  import { Construct } from "constructs";
2
- import { ARecord as CdkARecord, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
2
+ import { ARecord as CdkARecord, type GeoLocation, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
3
3
  import { type DnsRecordCommonProps } from "./dnsRecordBase.js";
4
4
  export interface AliasRecordProps extends DnsRecordCommonProps {
5
5
  readonly target: IAliasRecordTarget;
6
+ /**
7
+ * Latency routing region (CDK `RecordSetOptions.region`). Route53 requires
8
+ * a `setIdentifier` alongside it — callers derive one when unset.
9
+ */
10
+ readonly region?: string;
11
+ /** Weighted routing weight, 0-255 (CDK `RecordSetOptions.weight`). */
12
+ readonly weight?: number;
13
+ /** Geolocation routing (CDK `RecordSetOptions.geoLocation`). */
14
+ readonly geoLocation?: GeoLocation;
15
+ /**
16
+ * Routing-policy record discriminator — distinguishes records that share a
17
+ * (name, type) pair under latency/weighted/geolocation policies.
18
+ */
19
+ readonly setIdentifier?: string;
20
+ /**
21
+ * Omit the Route53 record comment entirely. Used for no-churn adoption of
22
+ * records that pre-date this wrapper (flipless-retain-flip principle: the
23
+ * default comment would otherwise be a property delta against the live
24
+ * record's pinned template). The wrapper-level `description` getter is
25
+ * unaffected.
26
+ */
27
+ readonly omitComment?: boolean;
6
28
  }
7
29
  export declare class AliasRecord extends Construct {
8
30
  readonly record: CdkARecord;
@@ -1,22 +1,33 @@
1
1
  import { Construct } from "constructs";
2
2
  import { ARecord as CdkARecord, RecordTarget } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn } from "./dnsRecordBase.js";
4
4
  export class AliasRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description =
12
12
  props.description ?? defaultDnsComment("alias", this.fqdn);
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);
13
16
  // Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
14
17
  // so we deliberately do not forward props.ttl to the underlying CDK resource.
15
18
  this.record = new CdkARecord(this, "Record", {
16
19
  zone: props.zone,
17
20
  recordName: this.fqdn,
18
21
  target: RecordTarget.fromAlias(props.target),
19
- comment: this.description
22
+ ...(props.omitComment !== true && { comment: this.description }),
23
+ ...(props.region !== undefined && { region: props.region }),
24
+ ...(props.weight !== undefined && { weight: props.weight }),
25
+ ...(props.geoLocation !== undefined && {
26
+ geoLocation: props.geoLocation
27
+ }),
28
+ ...(props.setIdentifier !== undefined && {
29
+ setIdentifier: props.setIdentifier
30
+ })
20
31
  });
21
32
  applyDnsRecordTags(this, props);
22
33
  }
@@ -1,14 +1,15 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CaaRecord as CdkCaaRecord } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class CaaRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description = props.description ?? defaultDnsComment("CAA", this.fqdn);
12
+ claimDnsRecord(this, props, "CAA", this.fqdn);
12
13
  this.record = new CdkCaaRecord(this, "Record", {
13
14
  zone: props.zone,
14
15
  recordName: this.fqdn,