@fjall/components-infrastructure 3.5.2 → 3.6.1

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 (24) hide show
  1. package/dist/lib/patterns/aws/apexDomainPattern.js +12 -24
  2. package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
  3. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +6 -2
  4. package/dist/lib/patterns/aws/delegatedDomainPattern.js +28 -27
  5. package/dist/lib/patterns/aws/devSubstrate.js +28 -10
  6. package/dist/lib/patterns/aws/domainCertificateComposer.d.ts +48 -0
  7. package/dist/lib/patterns/aws/domainCertificateComposer.js +179 -0
  8. package/dist/lib/patterns/aws/domainValidation.js +95 -7
  9. package/dist/lib/patterns/aws/interfaces/domain.d.ts +33 -0
  10. package/dist/lib/resources/aws/compute/ecs.js +7 -3
  11. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +24 -2
  12. package/dist/lib/resources/aws/compute/ecsNetworking.js +136 -10
  13. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +7 -1
  14. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +37 -0
  15. package/dist/lib/resources/aws/compute/ecsValidation.js +45 -0
  16. package/dist/lib/resources/aws/compute/listenerRouting.d.ts +10 -1
  17. package/dist/lib/resources/aws/compute/listenerRouting.js +13 -1
  18. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +7 -0
  19. package/dist/lib/resources/aws/compute/persistentDataVolume.js +6 -0
  20. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.d.ts +10 -0
  21. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +27 -1
  22. package/dist/lib/utils/domainTypes.d.ts +5 -11
  23. package/dist/lib/utils/domainTypes.js +7 -4
  24. package/package.json +4 -4
@@ -86,29 +86,69 @@ function validateRecords(scope, props, effectiveZone) {
86
86
  }
87
87
  function validateCertificates(props, effectiveZone) {
88
88
  const certificates = props.certificates ?? [];
89
+ let cloudFrontCount = 0;
89
90
  for (const cert of certificates) {
90
91
  const resolved = normaliseCertificate(cert);
91
92
  assertWithinZone(resolved.domainName, effectiveZone);
92
93
  for (const san of resolved.subjectAlternativeNames ?? []) {
93
94
  assertWithinZone(san, effectiveZone);
94
95
  }
96
+ // D3 CloudFront designation consistency. TS already narrows the prop to
97
+ // boolean; the runtime check catches JS callers and stale generated code.
98
+ if (resolved.cloudFront !== undefined &&
99
+ typeof resolved.cloudFront !== "boolean") {
100
+ throw new Error(`Domain: certificate '${resolved.domainName}' has a non-boolean ` +
101
+ `'cloudFront' (received: ${JSON.stringify(resolved.cloudFront)}). ` +
102
+ `Cure: set cloudFront: true for a CloudFront viewer certificate, ` +
103
+ `or drop the prop.`);
104
+ }
105
+ if (resolved.cloudFront === true) {
106
+ cloudFrontCount += 1;
107
+ if (props.registrar === "external-records") {
108
+ throw new Error(`Domain: certificate '${resolved.domainName}' sets cloudFront: ` +
109
+ `true, which registrar 'external-records' does not support — ` +
110
+ `there is no Fjall-managed hosted zone to DNS-validate the ` +
111
+ `us-east-1 certificate against. Cure: manage the zone with ` +
112
+ `registrar 'route53' or 'external-delegated', or drop ` +
113
+ `cloudFront and provision an app-owned us-east-1 certificate.`);
114
+ }
115
+ }
116
+ }
117
+ if (cloudFrontCount > 1) {
118
+ throw new Error(`Domain: at most one certificate may set cloudFront: true (received ` +
119
+ `${cloudFrontCount}) — the zone-level ` +
120
+ `'<zone>-us-east-1-certificate-arn' export is singular. Cure: keep ` +
121
+ `one CloudFront certificate and move the other names into its ` +
122
+ `subjectAlternativeNames.`);
95
123
  }
96
124
  }
97
125
  /**
98
126
  * D8 delegation-consistency checks for the child-writes shape.
99
127
  *
100
- * `parentDelegationRoleArn` and `phase` belong to the delegated topology
101
- * (`external-delegated`) only; on any other registrar their presence is a
102
- * hard error rather than a silent no-op. On the delegated topology the role
103
- * ARN must be a LITERAL string (VD2-a): an unresolved token means the caller
104
- * reached for `Fn.importValue`, which resolves same-account only and is
105
- * exactly the trap that sank the legacy `DomainDelegation` pattern.
128
+ * `parentDelegationRoleArn`, `phase` and `adoptedNameServers` belong to the
129
+ * delegated topology (`external-delegated`) only; on any other registrar
130
+ * their presence is a hard error rather than a silent no-op. On the
131
+ * delegated topology the role ARN must be a LITERAL string (VD2-a): an
132
+ * unresolved token means the caller reached for `Fn.importValue`, which
133
+ * resolves same-account only and is exactly the trap that sank the legacy
134
+ * `DomainDelegation` pattern. Adoption (`hostedZoneId` +
135
+ * `adoptedNameServers`) is both-or-neither: an imported zone exposes no
136
+ * NS attribute, so a one-sided declaration either cannot delegate or
137
+ * cannot name the zone it delegates to.
106
138
  */
107
139
  function validateDelegationConfig(props) {
108
140
  const arn = props
109
141
  .parentDelegationRoleArn;
110
142
  const phase = props.phase;
111
143
  if (props.registrar !== "external-delegated") {
144
+ const adoptedNameServers = props
145
+ .adoptedNameServers;
146
+ if (adoptedNameServers !== undefined) {
147
+ throw new Error(`Domain: 'adoptedNameServers' is only supported with registrar: ` +
148
+ `'external-delegated' (received registrar: '${props.registrar}'). ` +
149
+ `Cure: adoption of an existing child zone is declared on the ` +
150
+ `delegated child Domain — move the prop there, or drop it.`);
151
+ }
112
152
  if (arn !== undefined) {
113
153
  throw new Error(`Domain: 'parentDelegationRoleArn' is only supported with registrar: ` +
114
154
  `'external-delegated' (received registrar: '${props.registrar}'). ` +
@@ -129,6 +169,7 @@ function validateDelegationConfig(props) {
129
169
  `received: '${String(phase)}'. Cure: use 'zone' for step 1 of the ` +
130
170
  `two-step delegated deploy, 'full' (or omit) for the complete build.`);
131
171
  }
172
+ validateAdoptionConfig(props);
132
173
  if (arn === undefined) {
133
174
  return;
134
175
  }
@@ -151,13 +192,60 @@ function validateDelegationConfig(props) {
151
192
  `output (arn:<partition>:iam::<account>:role/<name>).`);
152
193
  }
153
194
  }
195
+ /**
196
+ * Child-zone ADOPTION consistency (external-delegated only). `hostedZoneId`
197
+ * and `adoptedNameServers` are both-or-neither, and the name servers must be
198
+ * a non-empty array of literal hostnames: an imported zone exposes no
199
+ * `hostedZoneNameServers` attribute, so these literals are the ONLY source
200
+ * the `Custom::CrossAccountZoneDelegation` UPSERT has for the parent NS row
201
+ * — a token or empty set would delegate the zone to nowhere.
202
+ */
203
+ function validateAdoptionConfig(props) {
204
+ const hostedZoneId = props.hostedZoneId;
205
+ const adoptedNameServers = props
206
+ .adoptedNameServers;
207
+ if (hostedZoneId === undefined && adoptedNameServers === undefined) {
208
+ return;
209
+ }
210
+ if (adoptedNameServers === undefined) {
211
+ throw new Error(`Domain: adopting an existing child zone requires 'adoptedNameServers' ` +
212
+ `alongside 'hostedZoneId' (both-or-neither). An imported zone ` +
213
+ `exposes no name-server attribute at synth, so the parent NS UPSERT ` +
214
+ `needs the live values. Cure: read them once ` +
215
+ `(aws route53 get-hosted-zone --id ${String(hostedZoneId)}) and ` +
216
+ `paste the DelegationSet.NameServers literals.`);
217
+ }
218
+ if (hostedZoneId === undefined) {
219
+ throw new Error(`Domain: 'adoptedNameServers' requires 'hostedZoneId' (both-or-neither) ` +
220
+ `— the literals describe an EXISTING zone to adopt. Cure: set the ` +
221
+ `adopted zone's hostedZoneId, or drop 'adoptedNameServers' to create ` +
222
+ `a fresh child zone.`);
223
+ }
224
+ if (typeof hostedZoneId !== "string" || hostedZoneId.length === 0) {
225
+ throw new Error(`Domain: 'hostedZoneId' must be a non-empty string; received: ` +
226
+ `${JSON.stringify(hostedZoneId)}.`);
227
+ }
228
+ if (!Array.isArray(adoptedNameServers) || adoptedNameServers.length === 0) {
229
+ throw new Error(`Domain: 'adoptedNameServers' must be a non-empty array of NS ` +
230
+ `hostnames; received: ${JSON.stringify(adoptedNameServers)}. Cure: ` +
231
+ `paste the adopted zone's DelegationSet.NameServers.`);
232
+ }
233
+ for (const ns of adoptedNameServers) {
234
+ if (typeof ns !== "string" || ns.length === 0 || Token.isUnresolved(ns)) {
235
+ throw new Error(`Domain: every 'adoptedNameServers' entry must be a literal NS ` +
236
+ `hostname (no CDK tokens) — the delegation UPSERT writes them ` +
237
+ `verbatim into the parent zone. Received: ${JSON.stringify(ns)}.`);
238
+ }
239
+ }
240
+ }
154
241
  function normaliseCertificate(cert) {
155
242
  if (typeof cert === "string") {
156
243
  return { domainName: cert };
157
244
  }
158
245
  return {
159
246
  domainName: cert.domainName,
160
- subjectAlternativeNames: cert.subjectAlternativeNames
247
+ subjectAlternativeNames: cert.subjectAlternativeNames,
248
+ cloudFront: cert.cloudFront
161
249
  };
162
250
  }
163
251
  function assertWithinZone(candidate, zoneName) {
@@ -39,6 +39,22 @@ export type Certificate = string | {
39
39
  readonly domainName: string;
40
40
  readonly subjectAlternativeNames?: string[];
41
41
  readonly transparencyLogging?: boolean;
42
+ /**
43
+ * D3 — CloudFront viewer certificate. CloudFront accepts certificates
44
+ * from us-east-1 only, so a `cloudFront: true` entry mints its
45
+ * `DomainCertificate` there — in-stack when the domain stack itself
46
+ * resolves to us-east-1, otherwise in a domain-paired
47
+ * `<StackName>UsEast1Certificates` stack — and publishes the
48
+ * zone-level `<zone>-us-east-1-certificate-arn` export in place of
49
+ * the per-domain regional export. The ARN reaches CloudFront
50
+ * consumers as a LITERAL through
51
+ * `ManagedDomainBinding.usEast1CertificateArn` (D2 DescribeStacks),
52
+ * never via CDK cross-region references. At most one certificate per
53
+ * Domain may set it (the zone-level export is singular — put extra
54
+ * names in `subjectAlternativeNames`); unsupported on registrar
55
+ * `"external-records"`, which has no hosted zone to validate against.
56
+ */
57
+ readonly cloudFront?: boolean;
42
58
  };
43
59
  export interface DomainCommonProps {
44
60
  readonly zoneName: string;
@@ -55,6 +71,23 @@ export interface Route53ApexProps extends DomainCommonProps {
55
71
  export interface ExternalDelegatedProps extends DomainCommonProps {
56
72
  readonly registrar: "external-delegated";
57
73
  readonly delegatedSubdomain: string;
74
+ /**
75
+ * Child-zone ADOPTION: hosted zone id of an EXISTING zone for
76
+ * `{delegatedSubdomain}.{zoneName}`. When present the pattern IMPORTS the
77
+ * zone instead of creating one. Requires `adoptedNameServers`
78
+ * (both-or-neither — validated): an imported zone exposes no
79
+ * `hostedZoneNameServers` attribute at synth, so the parent NS UPSERT
80
+ * needs the live values as literals.
81
+ */
82
+ readonly hostedZoneId?: string;
83
+ /**
84
+ * Literal NS hostnames of the adopted zone (from a live
85
+ * `get-hosted-zone` read). Carried verbatim into the
86
+ * `Custom::CrossAccountZoneDelegation` UPSERT — the delegation record in
87
+ * the parent zone must keep pointing at the adopted zone's real name
88
+ * servers. Only valid together with `hostedZoneId`.
89
+ */
90
+ readonly adoptedNameServers?: string[];
58
91
  /**
59
92
  * D8 — child-writes delegation. LITERAL ARN of the parent zone's
60
93
  * DelegationRole (VD2-a: never `Fn.importValue`, which resolves
@@ -9,7 +9,7 @@ import { createEcsServiceAlarms, createLogPatternAlarms } from "../monitoring/in
9
9
  import { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
10
10
  import { validateEcsClusterProps } from "./ecsValidation.js";
11
11
  import { createExecutionRole, createTaskRole, createTaskDefinition, addContainersToTask, isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
12
- import { addLoadBalancer, addLoadBalancerListener, addHostedZone, addDirectAccessOutputs, registerServiceWithALB } from "./ecsNetworking.js";
12
+ import { addLoadBalancer, addLoadBalancerListener, addHostedZone, addDirectAccessOutputs, addRedirectHostRules, registerServiceWithALB } from "./ecsNetworking.js";
13
13
  import { createService, addServiceScaling, getOrCreateAsgCapacityProvider } from "./ecsServiceFactory.js";
14
14
  // Re-export all types/enums/constants so existing consumers are not broken
15
15
  export * from "./ecsTypes.js";
@@ -101,11 +101,15 @@ export default class EcsCluster extends Construct {
101
101
  if (!this.loadBalancerDisabled) {
102
102
  const lbResult = addLoadBalancer(this.ctx, this.anyServiceUsesEc2(), this.asgState.asgSecurityGroup);
103
103
  this.loadBalancer = lbResult.loadBalancer;
104
+ let hzResult;
104
105
  if (props.cluster?.domain || props.cluster?.domainConfig) {
105
- const hzResult = addHostedZone(this.ctx, this.loadBalancer);
106
+ hzResult = addHostedZone(this.ctx, this.loadBalancer);
106
107
  this.certificate = hzResult.certificate;
107
108
  }
108
- this.loadBalancerListener = addLoadBalancerListener(this.ctx, this.loadBalancer, this.certificate);
109
+ this.loadBalancerListener = addLoadBalancerListener(this.ctx, this.loadBalancer, this.certificate, hzResult?.additionalListenerCertificates);
110
+ if (hzResult?.redirectRules !== undefined) {
111
+ addRedirectHostRules(this.ctx, this.loadBalancerListener, hzResult.redirectRules, this.priorityState);
112
+ }
109
113
  }
110
114
  else if (this.directAccessEnabled) {
111
115
  addDirectAccessOutputs(this.ctx, this.asgState.autoScalingGroup);
@@ -1,4 +1,4 @@
1
- import { type ApplicationListener, type ApplicationLoadBalancer, type IApplicationTargetGroup } from "aws-cdk-lib/aws-elasticloadbalancingv2";
1
+ import { type ApplicationListener, type ApplicationLoadBalancer, type IApplicationTargetGroup, type IListenerCertificate } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  import { type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
3
3
  import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
4
4
  import { type ARecord, type IHostedZone } from "aws-cdk-lib/aws-route53";
@@ -13,11 +13,33 @@ export declare function addLoadBalancer(ctx: EcsConstructContext, anyServiceUses
13
13
  loadBalancer: ApplicationLoadBalancer;
14
14
  loadBalancerSecurityGroup?: SecurityGroup;
15
15
  };
16
- export declare function addLoadBalancerListener(ctx: EcsConstructContext, loadBalancer: ApplicationLoadBalancer, certificate?: ICertificate): ApplicationListener;
16
+ export declare function addLoadBalancerListener(ctx: EcsConstructContext, loadBalancer: ApplicationLoadBalancer, certificate?: ICertificate, additionalCertificates?: IListenerCertificate[]): ApplicationListener;
17
+ /**
18
+ * Redirect-hosts configuration resolved by `addHostedZone` for the listener
19
+ * step — the alias records are created during zone handling, but the 301
20
+ * rules can only attach once the listener exists.
21
+ */
22
+ export interface EcsRedirectRuleConfig {
23
+ /** Redirect host FQDNs, validated within the cluster's zone. */
24
+ hostFqdns: string[];
25
+ /** Redirect target — the cluster's `domainName`. */
26
+ targetHost: string;
27
+ }
28
+ /**
29
+ * Permanent-redirect listener rules for `domainConfig.redirectHosts`: one
30
+ * host-header-matched rule per host, 301 to `https://<targetHost>` preserving
31
+ * the original path and query (RedirectConfig omits Path/Query, so ELB
32
+ * retains them). Priorities come from the deterministic host-hash band shared
33
+ * with slot rules — stable across synths, disjoint from the service-rule
34
+ * auto-increment counter.
35
+ */
36
+ export declare function addRedirectHostRules(ctx: EcsConstructContext, listener: ApplicationListener, redirect: EcsRedirectRuleConfig, priorityState: PriorityState): void;
17
37
  export declare function addHostedZone(ctx: EcsConstructContext, loadBalancer?: ApplicationLoadBalancer): {
18
38
  hostedZone?: IHostedZone;
19
39
  certificate?: ICertificate;
20
40
  aRecord?: ARecord;
41
+ additionalListenerCertificates?: IListenerCertificate[];
42
+ redirectRules?: EcsRedirectRuleConfig;
21
43
  };
22
44
  export declare function addDirectAccessOutputs(ctx: EcsConstructContext, autoScalingGroup?: AutoScalingGroup): void;
23
45
  export declare function registerServiceWithALB(ctx: EcsConstructContext, listener: ApplicationListener, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service, primaryContainer: ContainerDefinition, priorityState: PriorityState): IApplicationTargetGroup;
@@ -1,6 +1,6 @@
1
- import { ApplicationProtocol, ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
1
+ import { ApplicationProtocol, ListenerAction, ListenerCertificate, ListenerCondition } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  import { Port } from "aws-cdk-lib/aws-ec2";
3
- import { CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
3
+ import { CfnOutput, Duration, Fn, Stack, Token } from "aws-cdk-lib";
4
4
  import { createHash } from "node:crypto";
5
5
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
6
6
  import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
@@ -15,7 +15,7 @@ import { FjallLogger } from "../../../utils/validationLogger.js";
15
15
  import { isServiceEc2 } from "./ecsTaskDefinition.js";
16
16
  import { createApplicationLoadBalancer } from "./applicationLoadBalancer.js";
17
17
  import { addRoutingListener } from "./listenerRouting.js";
18
- import { buildRoutingConditions, getNextPriority } from "./hostHeaderListenerRule.js";
18
+ import { buildRoutingConditions, deterministicHostPriority, getNextPriority } from "./hostHeaderListenerRule.js";
19
19
  export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
20
20
  const props = ctx.props;
21
21
  const defaultLoadBalancerName = `${props.clusterName}LoadBalancer`;
@@ -83,7 +83,7 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
83
83
  });
84
84
  return { loadBalancer, loadBalancerSecurityGroup };
85
85
  }
86
- export function addLoadBalancerListener(ctx, loadBalancer, certificate) {
86
+ export function addLoadBalancerListener(ctx, loadBalancer, certificate, additionalCertificates) {
87
87
  const port = certificate ? 443 : 80;
88
88
  const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
89
89
  const willHaveMultipleRoutes = servicesWithPorts.length > 1 ||
@@ -100,9 +100,34 @@ export function addLoadBalancerListener(ctx, loadBalancer, certificate) {
100
100
  return addRoutingListener(loadBalancer, `${ctx.props.clusterName}Listener`, {
101
101
  port,
102
102
  ...(certificate && { certificate }),
103
+ ...(additionalCertificates !== undefined &&
104
+ additionalCertificates.length > 0 && { additionalCertificates }),
103
105
  default404: willHaveMultipleRoutes || noServicePorts
104
106
  });
105
107
  }
108
+ /**
109
+ * Permanent-redirect listener rules for `domainConfig.redirectHosts`: one
110
+ * host-header-matched rule per host, 301 to `https://<targetHost>` preserving
111
+ * the original path and query (RedirectConfig omits Path/Query, so ELB
112
+ * retains them). Priorities come from the deterministic host-hash band shared
113
+ * with slot rules — stable across synths, disjoint from the service-rule
114
+ * auto-increment counter.
115
+ */
116
+ export function addRedirectHostRules(ctx, listener, redirect, priorityState) {
117
+ for (const host of redirect.hostFqdns) {
118
+ const safeHost = toPascalCase(getSafeZoneName(host));
119
+ listener.addAction(`Redirect${safeHost}`, {
120
+ conditions: [ListenerCondition.hostHeaders([host])],
121
+ priority: deterministicHostPriority(host, priorityState),
122
+ action: ListenerAction.redirect({
123
+ host: redirect.targetHost,
124
+ protocol: "HTTPS",
125
+ port: "443",
126
+ permanent: true
127
+ })
128
+ });
129
+ }
130
+ }
106
131
  /**
107
132
  * Reproduce the CloudFormation logical ID CDK allocated for the pre-wrapper
108
133
  * raw `ARecord` at `<scope>/<id>` — i.e. for its `CfnRecordSet` default child
@@ -239,16 +264,40 @@ export function addHostedZone(ctx, loadBalancer) {
239
264
  routedHosts.push(rule.host);
240
265
  }
241
266
  }
242
- const subjectAlternativeNames = routedHosts.filter((h) => h !== domainName);
267
+ // Redirect hosts get the same completeness treatment as routed hosts (H7):
268
+ // an alias record, a SAN on a cluster-minted certificate, and — once the
269
+ // listener exists — a 301 rule (attached by the caller via
270
+ // addRedirectHostRules, since the listener is created after this step).
271
+ const redirectHosts = domainConfig?.redirectHosts ?? [];
272
+ for (const host of redirectHosts) {
273
+ if (!isWithinZone(host, zoneName)) {
274
+ throw new Error(`Cluster '${props.clusterName}': redirectHosts entry '${host}' is outside ` +
275
+ `hosted zone '${zoneName}' (got '${host}'). Its alias record cannot be ` +
276
+ `created here. Use a host under '${zoneName}', or redirect it from its ` +
277
+ "own Domain stack.");
278
+ }
279
+ if (host === domainName) {
280
+ throw new Error(`Cluster '${props.clusterName}': redirectHosts entry '${host}' equals the ` +
281
+ "cluster domain — it would redirect to itself. Remove it; the apex is " +
282
+ "already served by the cluster.");
283
+ }
284
+ if (routedHosts.includes(host)) {
285
+ throw new Error(`Cluster '${props.clusterName}': host '${host}' is both a service ` +
286
+ "routing.host and a redirectHosts entry. A host either serves traffic " +
287
+ "or redirects — remove it from one of the two.");
288
+ }
289
+ }
290
+ const subjectAlternativeNames = [...routedHosts, ...redirectHosts].filter((h) => h !== domainName);
243
291
  if (domainConfig?.certificate) {
244
292
  certificate = domainConfig.certificate;
245
293
  }
246
294
  if (certificate !== undefined) {
247
295
  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.");
296
+ FjallLogger.warn(`Cluster '${props.clusterName}': host-routed services and redirect hosts ` +
297
+ `rely on the supplied certificate (or additionalCertificates) covering ` +
298
+ `${subjectAlternativeNames.join(", ")} Fjall cannot add SANs to an ` +
299
+ "imported certificate. Ensure these names or a matching wildcard are " +
300
+ "covered, or TLS fails for those hosts.");
252
301
  }
253
302
  }
254
303
  else {
@@ -269,6 +318,7 @@ export function addHostedZone(ctx, loadBalancer) {
269
318
  exportCertificateArn: false
270
319
  }).certificate;
271
320
  }
321
+ const additionalListenerCertificates = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed);
272
322
  let aRecord;
273
323
  if (loadBalancer) {
274
324
  const routingPolicy = domainConfig?.routingPolicy;
@@ -336,8 +386,84 @@ export function addHostedZone(ctx, loadBalancer) {
336
386
  ...routingProps
337
387
  });
338
388
  }
389
+ for (const host of redirectHosts) {
390
+ const safeHost = toPascalCase(getSafeZoneName(host));
391
+ new AliasRecord(ctx.scope, `${props.clusterName}${safeHost}RedirectAliasRecord`, {
392
+ zone: hostedZone,
393
+ zoneName,
394
+ recordName: recordLabelWithin(host, zoneName),
395
+ target: new LoadBalancerTarget(loadBalancer, {
396
+ evaluateTargetHealth: hasRoutingPolicy
397
+ }),
398
+ // Redirect records inherit the cluster's routing policy for the
399
+ // same reason host records do.
400
+ ...routingProps
401
+ });
402
+ }
403
+ }
404
+ return {
405
+ hostedZone,
406
+ certificate,
407
+ aRecord,
408
+ ...(additionalListenerCertificates.length > 0 && {
409
+ additionalListenerCertificates
410
+ }),
411
+ ...(redirectHosts.length > 0 && {
412
+ redirectRules: { hostFqdns: redirectHosts, targetHost: domainName }
413
+ })
414
+ };
415
+ }
416
+ const US_EAST_1 = "us-east-1";
417
+ /**
418
+ * Resolve `domainConfig.additionalCertificates` to listener certificates.
419
+ * The `managedDomainCertificate: "usEast1"` selector consumes the D2-injected
420
+ * binding's literal `usEast1CertificateArn`. ACM certificates are regional,
421
+ * so the selector is valid only when the stack itself deploys to us-east-1 —
422
+ * anything else fails at synth rather than as CloudFormation's opaque
423
+ * deploy-time rejection.
424
+ */
425
+ function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed) {
426
+ if (sources === undefined || sources.length === 0)
427
+ return [];
428
+ const certificates = [];
429
+ for (const source of sources) {
430
+ if ("certificateArn" in source) {
431
+ certificates.push(ListenerCertificate.fromArn(source.certificateArn));
432
+ continue;
433
+ }
434
+ if (managed === undefined ||
435
+ !isManagedDomainBinding(managed) ||
436
+ managed.usEast1CertificateArn === undefined) {
437
+ throw new Error(`Cluster '${clusterName}': additionalCertificates requests the managed ` +
438
+ "domain's us-east-1 certificate, but no managed domain binding " +
439
+ "carrying usEast1CertificateArn is available. Deploy through the " +
440
+ "Fjall CLI with a domain stack that declares a cloudFront certificate " +
441
+ "for this zone, or supply certificateArn directly.");
442
+ }
443
+ const arn = managed.usEast1CertificateArn;
444
+ // Binding values are literals by contract (D2), so the ARN's region
445
+ // component is inspectable at synth.
446
+ const arnRegion = Token.isUnresolved(arn) ? undefined : arn.split(":")[3];
447
+ if (arnRegion !== US_EAST_1) {
448
+ throw new Error(`Cluster '${clusterName}': the managed domain binding's ` +
449
+ `usEast1CertificateArn must be a literal us-east-1 ACM ARN (got '${arn}'). ` +
450
+ "Redeploy the domain stack so its CloudFront certificate is " +
451
+ "provisioned in us-east-1, or correct the injected binding.");
452
+ }
453
+ const stackRegion = Stack.of(scope).region;
454
+ const resolvedRegion = Token.isUnresolved(stackRegion)
455
+ ? undefined
456
+ : stackRegion;
457
+ if (resolvedRegion !== US_EAST_1) {
458
+ throw new Error(`Cluster '${clusterName}': the managed domain's us-east-1 certificate ` +
459
+ "can only be attached to a listener in us-east-1 (stack region: " +
460
+ `'${resolvedRegion ?? "unresolved"}'). ACM certificates are regional — ` +
461
+ "declare a certificate in the cluster's own region on the domain " +
462
+ "stack, or supply certificateArn for a same-region certificate.");
463
+ }
464
+ certificates.push(ListenerCertificate.fromArn(arn));
339
465
  }
340
- return { hostedZone, certificate, aRecord };
466
+ return certificates;
341
467
  }
342
468
  export function addDirectAccessOutputs(ctx, autoScalingGroup) {
343
469
  if (!ctx.directAccessEnabled || !autoScalingGroup)
@@ -143,7 +143,13 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
143
143
  }),
144
144
  ...(resolvedWarmPool !== undefined && { warmPool: resolvedWarmPool }),
145
145
  ...(ec2Config.persistentDataVolume !== undefined && {
146
- persistentDataVolume: ec2Config.persistentDataVolume
146
+ persistentDataVolume: {
147
+ ...ec2Config.persistentDataVolume,
148
+ ...(ec2Config.persistentDataVolume.alarmTopic === undefined &&
149
+ ctx.props.alertsTopic !== undefined && {
150
+ alarmTopic: ctx.props.alertsTopic
151
+ })
152
+ }
147
153
  }),
148
154
  ...(ec2Config.tags !== undefined && { tags: ec2Config.tags })
149
155
  });
@@ -178,6 +178,22 @@ export interface EcsLatencyRoutingPolicy {
178
178
  setIdentifier?: string;
179
179
  }
180
180
  export type EcsDomainRoutingPolicy = EcsLatencyRoutingPolicy;
181
+ /**
182
+ * Source for an additional SNI certificate on the cluster's HTTPS listener.
183
+ *
184
+ * - `{ managedDomainCertificate: "usEast1" }` — the managed domain stack's
185
+ * us-east-1 certificate, resolved from the CLI-injected
186
+ * `ManagedDomainBinding.usEast1CertificateArn` (design D2/D3). Valid only
187
+ * when the cluster's stack itself deploys to us-east-1 — ACM certificates
188
+ * are regional, and an ALB accepts certificates from its own region only.
189
+ * - `{ certificateArn }` — an explicit ACM certificate ARN (BYO path; also
190
+ * accepts a token from a certificate construct in the same app).
191
+ */
192
+ export type EcsAdditionalListenerCertificate = {
193
+ managedDomainCertificate: "usEast1";
194
+ } | {
195
+ certificateArn: string;
196
+ };
181
197
  /**
182
198
  * Domain configuration for HTTPS and DNS.
183
199
  *
@@ -210,6 +226,27 @@ export interface DomainBaseConfig {
210
226
  * Omit for a plain alias record.
211
227
  */
212
228
  routingPolicy?: EcsDomainRoutingPolicy;
229
+ /**
230
+ * Hostnames (FQDNs within the zone, e.g. `"www.example.com"`) that
231
+ * permanently redirect to `domainName` at the ALB. Each host gets an alias
232
+ * A record targeting the ALB — inheriting the cluster's routing policy,
233
+ * exactly like host-routed service records — plus an HTTPS listener rule
234
+ * matching the host header and issuing a 301 to `https://<domainName>`,
235
+ * preserving the original path and query.
236
+ *
237
+ * TLS coverage: a cluster-minted certificate gains each host as a SAN
238
+ * automatically; an imported or managed certificate must already cover the
239
+ * host, or attach one via `additionalCertificates`.
240
+ */
241
+ redirectHosts?: string[];
242
+ /**
243
+ * Additional SNI certificates attached to the cluster's HTTPS listener
244
+ * (`AWS::ElasticLoadBalancingV2::ListenerCertificate`). Use when the
245
+ * primary certificate does not cover every served or redirected host —
246
+ * e.g. the managed domain's us-east-1 www/wildcard certificate for a
247
+ * `redirectHosts` entry.
248
+ */
249
+ additionalCertificates?: EcsAdditionalListenerCertificate[];
213
250
  /**
214
251
  * @deprecated Removed (design H8). `region` silently converted the record
215
252
  * to a latency routing policy with an auto-derived `setIdentifier`. Declare
@@ -29,6 +29,20 @@ export function validateEcsClusterProps(props) {
29
29
  validateEcsDomainConfig(props.cluster?.domainConfig, props.clusterName);
30
30
  const loadBalancerDisabled = props.cluster?.loadBalancer === false ||
31
31
  props.cluster?.directAccess === true;
32
+ if (loadBalancerDisabled) {
33
+ const domainConfig = props.cluster?.domainConfig;
34
+ const listenerFeature = (domainConfig?.redirectHosts?.length ?? 0) > 0
35
+ ? "redirectHosts"
36
+ : (domainConfig?.additionalCertificates?.length ?? 0) > 0
37
+ ? "additionalCertificates"
38
+ : undefined;
39
+ if (listenerFeature !== undefined) {
40
+ throw new Error(`Cluster '${props.clusterName}': domainConfig.${listenerFeature} requires the ` +
41
+ "cluster's load balancer, but it is disabled (loadBalancer: false or " +
42
+ "directAccess: true). Redirect rules and SNI certificates live on the ALB " +
43
+ "HTTPS listener — enable the load balancer or drop the setting.");
44
+ }
45
+ }
32
46
  // Validate services array
33
47
  if (!props.services || props.services.length === 0) {
34
48
  throw new Error("At least one service must be specified.");
@@ -223,6 +237,37 @@ export function validateEcsDomainConfig(domainConfig, clusterName) {
223
237
  "set carries exactly one routing policy. Remove one of them.");
224
238
  }
225
239
  }
240
+ const redirectHosts = domainConfig.redirectHosts;
241
+ if (redirectHosts !== undefined) {
242
+ for (const host of redirectHosts) {
243
+ if (typeof host !== "string" || host.trim() === "") {
244
+ throw new Error(`${context}: redirectHosts entries must be non-empty host names ` +
245
+ `(got ${JSON.stringify(host)}).`);
246
+ }
247
+ }
248
+ const duplicates = redirectHosts.filter((host, index) => redirectHosts.indexOf(host) !== index);
249
+ if (duplicates.length > 0) {
250
+ throw new Error(`${context}: duplicate redirectHosts: ${[...new Set(duplicates)].join(", ")}. ` +
251
+ "Each host can redirect once.");
252
+ }
253
+ }
254
+ const additionalCertificates = domainConfig.additionalCertificates;
255
+ if (additionalCertificates !== undefined) {
256
+ for (const entry of additionalCertificates) {
257
+ // Shape guard for JavaScript callers — the union is `never`-safe in TS
258
+ // but casts and untyped configs can still pass anything.
259
+ const isManagedSelector = "managedDomainCertificate" in entry &&
260
+ entry.managedDomainCertificate === "usEast1";
261
+ const isLiteralArn = "certificateArn" in entry &&
262
+ typeof entry.certificateArn === "string" &&
263
+ entry.certificateArn.trim() !== "";
264
+ if (!isManagedSelector && !isLiteralArn) {
265
+ throw new Error(`${context}: additionalCertificates entries must be ` +
266
+ `{ managedDomainCertificate: "usEast1" } or { certificateArn: "<ACM ARN>" } ` +
267
+ `(got ${JSON.stringify(entry)}).`);
268
+ }
269
+ }
270
+ }
226
271
  }
227
272
  /**
228
273
  * Validates an SSM path component for correctness.
@@ -1,8 +1,17 @@
1
- import { type ApplicationListener, type ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
1
+ import { type ApplicationListener, type ApplicationLoadBalancer, type IListenerCertificate } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  import type { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
3
3
  export interface RoutingListenerOptions {
4
4
  readonly port: number;
5
5
  readonly certificate?: ICertificate;
6
+ /**
7
+ * Additional SNI certificates. CDK keeps the first `certificates` entry on
8
+ * the listener resource itself and emits the rest as
9
+ * `AWS::ElasticLoadBalancingV2::ListenerCertificate` resources, so the
10
+ * primary certificate's CloudFormation shape is unchanged. Requires
11
+ * `certificate` — CloudFormation rejects certificates on an HTTP listener
12
+ * at deploy time, far too late.
13
+ */
14
+ readonly additionalCertificates?: IListenerCertificate[];
6
15
  /**
7
16
  * Attach a fixed 404 default action. The ECS path passes this conditionally
8
17
  * (only when ≥2 routes exist or no service has a port — CDK rejects a listener
@@ -6,6 +6,13 @@ import { ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
6
6
  * caller's target groups become the listener's default).
7
7
  */
8
8
  export function addRoutingListener(loadBalancer, id, options) {
9
+ if (options.certificate === undefined &&
10
+ options.additionalCertificates !== undefined &&
11
+ options.additionalCertificates.length > 0) {
12
+ throw new Error(`Listener '${id}': additionalCertificates require an HTTPS listener ` +
13
+ "(no primary certificate resolved, so this listener is HTTP). " +
14
+ "CloudFormation would reject the ListenerCertificate at deploy time.");
15
+ }
9
16
  const defaultAction = options.default404
10
17
  ? ListenerAction.fixedResponse(404, {
11
18
  contentType: "text/plain",
@@ -14,7 +21,12 @@ export function addRoutingListener(loadBalancer, id, options) {
14
21
  : undefined;
15
22
  return loadBalancer.addListener(id, {
16
23
  port: options.port,
17
- ...(options.certificate && { certificates: [options.certificate] }),
24
+ ...(options.certificate && {
25
+ certificates: [
26
+ options.certificate,
27
+ ...(options.additionalCertificates ?? [])
28
+ ]
29
+ }),
18
30
  ...(defaultAction !== undefined && { defaultAction })
19
31
  });
20
32
  }