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