@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.
- package/dist/lib/patterns/aws/apexDomainPattern.js +12 -24
- package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
- package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +6 -2
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +28 -27
- package/dist/lib/patterns/aws/devSubstrate.js +28 -10
- package/dist/lib/patterns/aws/domainCertificateComposer.d.ts +48 -0
- package/dist/lib/patterns/aws/domainCertificateComposer.js +179 -0
- package/dist/lib/patterns/aws/domainValidation.js +95 -7
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +33 -0
- 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/dist/lib/resources/aws/networking/crossAccountDelegationRecord.d.ts +10 -0
- package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +27 -1
- package/dist/lib/utils/domainTypes.d.ts +5 -11
- package/dist/lib/utils/domainTypes.js +7 -4
- 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 `
|
|
101
|
-
* (`external-delegated`) only; on any other registrar
|
|
102
|
-
* hard error rather than a silent no-op. On the
|
|
103
|
-
* ARN must be a LITERAL string (VD2-a): an
|
|
104
|
-
* reached for `Fn.importValue`, which
|
|
105
|
-
* exactly the trap that sank the legacy
|
|
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
|
-
|
|
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
|
}
|