@fjall/components-infrastructure 3.6.0 → 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.
@@ -361,6 +361,7 @@ export class ClickHouseDatabase extends Construct {
361
361
  clickHouseContainerSecrets[userPasswordEnvName(name)] =
362
362
  userSecret.getImport("password");
363
363
  }
364
+ const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
364
365
  const ecsCompute = new EcsCompute(this, "Compute", {
365
366
  type: "ecs",
366
367
  vpc,
@@ -393,7 +394,10 @@ export class ClickHouseDatabase extends Construct {
393
394
  deviceName: CLICKHOUSE_EBS_DEVICE_NAME,
394
395
  availabilityZone: dataAz,
395
396
  iops: CLICKHOUSE_EBS_IOPS,
396
- throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS
397
+ throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS,
398
+ ...(resolvedAlertsTopic !== undefined && {
399
+ alarmTopic: resolvedAlertsTopic
400
+ })
397
401
  },
398
402
  userData,
399
403
  desiredCapacity: 1,
@@ -492,7 +496,6 @@ export class ClickHouseDatabase extends Construct {
492
496
  // asgName is the CloudWatch dimension the host metrics key off, so without
493
497
  // it the alarms cannot be built; no topic is the default, so skip silently
494
498
  // rather than throw.
495
- const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
496
499
  const asgName = ecsCompute.getAutoScalingGroupName();
497
500
  if (resolvedAlertsTopic !== undefined && asgName !== undefined) {
498
501
  createClickHouseAlarms({
@@ -5,6 +5,7 @@ import { Repository } from "aws-cdk-lib/aws-ecr";
5
5
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
7
7
  import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
8
+ import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
8
9
  import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
9
10
  import { Construct } from "constructs";
10
11
  import { readFileSync } from "node:fs";
@@ -20,6 +21,7 @@ import { createLambdaTargetGroup } from "../../resources/aws/compute/lambdaAlbTa
20
21
  import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
21
22
  import { Ecr } from "../../resources/aws/storage/ecr.js";
22
23
  import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
24
+ import { AliasRecord } from "../../resources/aws/networking/dnsRecord/aliasRecord.js";
23
25
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
24
26
  import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
25
27
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
@@ -56,6 +58,7 @@ const DEV_SUBSTRATE_PARENT_ROLE_ID = "DevSubstrateParentDelegationRole";
56
58
  /** Construct ids for the substrate's wildcard cert + :443 HTTPS listener (A6). */
57
59
  const DEV_SUBSTRATE_CERT_ID = "DevSubstrateCertificate";
58
60
  const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
61
+ const DEV_SUBSTRATE_WILDCARD_ALIAS_ID = "DevSubstrateWildcardAlias";
59
62
  // Default deploy phase when `phase` is omitted — the cert + :443 listener
60
63
  // build. Shared with the delegated `Domain` topology via
61
64
  // `DOMAIN_DEPLOY_DEFAULT_PHASE` (utils/domainTypes.ts): the R2 two-step guard
@@ -68,13 +71,6 @@ const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
68
71
  * `point-at-existing` (GAP-7) is deferred, so this is a fixed value for now.
69
72
  */
70
73
  const DEV_SUBSTRATE_DATABASE_MODE = "aurora";
71
- /**
72
- * The container port slots serve HTTP on. Fjall owns the slot image, so this is a
73
- * fixed contract: the ALB → slot-SG ingress opens exactly this port and the
74
- * host-header rules a slot registers (§5.4) forward to it. The public :80 ALB
75
- * front listener is separate.
76
- */
77
- const DEV_SUBSTRATE_SLOT_PORT = 8080;
78
74
  /**
79
75
  * Physical-name prefix pinning every substrate resource inside the `fjall-dev-*`
80
76
  * fence. Load-bearing: the dev-tier IAM grants (`@fjall/generator devRolePolicies`)
@@ -210,17 +206,24 @@ export class DevSubstrate extends Construct {
210
206
  // explicitly. Path + name pin the role inside the /fjall/dev/ fence: the dev
211
207
  // deploy role's only iam:PassRole grant is on role/fjall/dev/*, so the CDK
212
208
  // defaults (path "/", CFN-generated name) would AccessDeny slot task-def
213
- // registration.
209
+ // registration. ssmSecretsPath grants the slot parameter tree — the worker
210
+ // writes DATABASE_URL + FJALL_* under /fjall/dev/<app>/<slot>/ and slot
211
+ // task-defs inject them via this role; without it the task dies at
212
+ // ResourceInitializationError on ssm:GetParameters.
214
213
  const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID, {
214
+ ssmSecretsPath: `/fjall/dev/${props.appKebab}`,
215
215
  path: "/fjall/dev/",
216
216
  roleName: `${devFenceName(props.appKebab)}-slot-exec`
217
217
  });
218
218
  const { partition, region, account } = Stack.of(this);
219
+ // Whole app dev-fence subtree, not /slots/* — the worker names each slot's
220
+ // log group /fjall/dev/<app>/<slot> (the slot base path), so a /slots/*
221
+ // scope would deny CreateLogStream at task start.
219
222
  slotExecutionRole.addToPolicy(new PolicyStatement({
220
223
  effect: Effect.ALLOW,
221
224
  actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
222
225
  resources: [
223
- `arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}/slots/*:*`
226
+ `arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}/*:*`
224
227
  ]
225
228
  }));
226
229
  // Slot-SG → Aurora ingress. Dormant in Data-API mode (Data API reaches the
@@ -258,7 +261,11 @@ export class DevSubstrate extends Construct {
258
261
  // fence-pinned name — the reaper finds it by the subtree fence tags. A pinned
259
262
  // name would only reintroduce the 32-char ALB-name truncation dance.
260
263
  const loadBalancer = createApplicationLoadBalancer(this, DEV_SUBSTRATE_ALB_ID, { vpc, internetFacing: true, securityGroup: albSecurityGroup });
261
- slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.tcp(DEV_SUBSTRATE_SLOT_PORT),
264
+ // All-TCP, not a pinned port: the worker registers each slot's target group
265
+ // on the per-slot `buildSpec.containerPort` (default 3000, `--port`
266
+ // overridable), chosen at provision time — synth cannot know it. Scope stays
267
+ // tight because the source is the substrate's own ALB SG.
268
+ slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.allTcp(),
262
269
  // EC2 SG-rule descriptions reject non-ASCII (allowed set is
263
270
  // `a-zA-Z0-9. _-:/()#,@[]+=&;{}!$*` — no arrow, em-dash, or even `>`).
264
271
  "Dev substrate ALB to slot tasks");
@@ -376,6 +383,17 @@ export class DevSubstrate extends Construct {
376
383
  // Unconditional 404 as on :80 (per-slot rules attach out of band, §5.4). open
377
384
  // (CDK default) adds the :443 ingress to the ALB SG.
378
385
  const httpsListener = addRoutingListener(loadBalancer, DEV_SUBSTRATE_HTTPS_LISTENER_ID, { port: 443, certificate: certificate.certificate, default404: true });
386
+ // GAP-4: one wildcard ALIAS resolves every slot host to the ALB — routing
387
+ // happens per-slot via host-header rules, so no per-slot DNS write is ever
388
+ // needed. Phase-gated with the cert (the record is useless before :443
389
+ // exists) and created in adopt mode too: the adopted zone predates the
390
+ // substrate, but the record targets the substrate's own ALB.
391
+ new AliasRecord(this, DEV_SUBSTRATE_WILDCARD_ALIAS_ID, {
392
+ zone: childZone,
393
+ zoneName: childZoneName,
394
+ recordName: "*",
395
+ target: new LoadBalancerTarget(loadBalancer)
396
+ });
379
397
  return { devDomain: childZoneName, httpsListener };
380
398
  }
381
399
  /**
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
2
2
  import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
3
3
  import { EbsDeviceVolumeType, Volume } from "aws-cdk-lib/aws-ec2";
4
+ import type { ITopic } from "aws-cdk-lib/aws-sns";
4
5
  import { Construct } from "constructs";
5
6
  import { LambdaFunction } from "./lambda.js";
6
7
  import { SQSQueue } from "../messaging/sqs.js";
@@ -35,6 +36,12 @@ export interface PersistentDataVolumeProps {
35
36
  iops?: number;
36
37
  /** Throughput in MiB/s (gp3 only). */
37
38
  throughputMbps?: number;
39
+ /**
40
+ * SNS topic `attachFailureAlarm` notifies. Omitted, the alarm still exists
41
+ * but fires no action — pass the stack's alerts topic or the failure is
42
+ * only visible to someone watching the CloudWatch console.
43
+ */
44
+ alarmTopic?: ITopic;
38
45
  }
39
46
  /**
40
47
  * Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { Aws, Duration, RemovalPolicy, Size, Stack, Tags } from "aws-cdk-lib";
5
5
  import { DefaultResult, LifecycleTransition } from "aws-cdk-lib/aws-autoscaling";
6
6
  import { Alarm, ComparisonOperator, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
7
+ import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
7
8
  import { EbsDeviceVolumeType, Volume } from "aws-cdk-lib/aws-ec2";
8
9
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
9
10
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
@@ -220,6 +221,11 @@ export class PersistentDataVolume extends Construct {
220
221
  comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
221
222
  treatMissingData: TreatMissingData.NOT_BREACHING
222
223
  });
224
+ if (props.alarmTopic !== undefined) {
225
+ const snsAction = new SnsAction(props.alarmTopic);
226
+ this.attachFailureAlarm.addAlarmAction(snsAction);
227
+ this.attachFailureAlarm.addOkAction(snsAction);
228
+ }
223
229
  }
224
230
  }
225
231
  function validatePersistentDataVolumeProps(props) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-organizations": "^3.1038.0",
70
- "@fjall/generator": "^3.6.0",
71
- "@fjall/util": "^3.6.0",
70
+ "@fjall/generator": "^3.6.1",
71
+ "@fjall/util": "^3.6.1",
72
72
  "constructs": "^10.6.0"
73
73
  },
74
74
  "overrides": {
@@ -82,5 +82,5 @@
82
82
  "engines": {
83
83
  "node": ">=18.0.0"
84
84
  },
85
- "gitHead": "10366db602d7e80669e22825a52071a47e05c3b7"
85
+ "gitHead": "4bf3b77f9ef1472ca7afc6da552a225062ad602c"
86
86
  }