@fjall/components-infrastructure 3.6.0 → 3.7.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.
- package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
- package/dist/lib/patterns/aws/devSubstrate.js +28 -10
- package/dist/lib/resources/aws/compute/ecs.js +7 -3
- package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +24 -2
- package/dist/lib/resources/aws/compute/ecsNetworking.js +136 -10
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +7 -1
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +37 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +45 -0
- package/dist/lib/resources/aws/compute/listenerRouting.d.ts +10 -1
- package/dist/lib/resources/aws/compute/listenerRouting.js +13 -1
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +7 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +6 -0
- package/package.json +4 -4
|
@@ -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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
249
|
-
`certificate
|
|
250
|
-
"
|
|
251
|
-
"
|
|
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
|
|
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:
|
|
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 && {
|
|
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.
|
|
3
|
+
"version": "3.7.0",
|
|
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.
|
|
71
|
-
"@fjall/util": "^3.
|
|
70
|
+
"@fjall/generator": "^3.7.0",
|
|
71
|
+
"@fjall/util": "^3.7.0",
|
|
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": "
|
|
85
|
+
"gitHead": "2ee5e51bc6aa45e34ec70160b36ddf976371ccd5"
|
|
86
86
|
}
|