@fjall/components-infrastructure 14.0.0 → 14.1.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/cdn.d.ts +88 -1
- package/dist/lib/patterns/aws/cdn.js +178 -5
- package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +7 -3
- package/dist/lib/patterns/aws/targets/fjallTargets.js +7 -3
- package/dist/lib/resources/aws/compute/ecsNetworking.js +22 -16
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +10 -0
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +29 -2
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +19 -5
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +17 -6
- package/dist/lib/utils/dnsRecordRegistry.d.ts +42 -11
- package/dist/lib/utils/dnsRecordRegistry.js +68 -14
- package/package.json +3 -3
|
@@ -6,9 +6,50 @@ import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-
|
|
|
6
6
|
import type App from "../../app.js";
|
|
7
7
|
import { CloudFrontDistribution, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
|
|
8
8
|
import { type ICdn } from "./interfaces/cdn.js";
|
|
9
|
-
import type
|
|
9
|
+
import { type StaticSiteRouting } from "@fjall/util";
|
|
10
|
+
import { type ManagedDomainBinding, type ManagedDomainExports } from "../../utils/domainTypes.js";
|
|
10
11
|
import { type Storage } from "./storage.js";
|
|
11
12
|
import { type AnyCompute } from "./compute.js";
|
|
13
|
+
/**
|
|
14
|
+
* Managed-domain surface for a CDN — the distribution's counterpart to the
|
|
15
|
+
* ECS cluster's `domainConfig` (design 2026-08-17 cdn-domain-ownership C1).
|
|
16
|
+
* The CDN resolves its zone and us-east-1 viewer certificate exactly like
|
|
17
|
+
* the staticSite/payload patterns (explicit `managedDomain` beats the
|
|
18
|
+
* CLI-injected binding, which beats BYO `zoneName`/`hostedZoneId`), derives
|
|
19
|
+
* `domainNames` from `domainName`, and — unless `record: "none"` — owns the
|
|
20
|
+
* alias record for it in the CDN stack, satellite-style.
|
|
21
|
+
*/
|
|
22
|
+
export interface CdnDomainConfig {
|
|
23
|
+
/** Public hostname the distribution serves, e.g. the zone apex. */
|
|
24
|
+
domainName: string;
|
|
25
|
+
/**
|
|
26
|
+
* "alias" (default) mints the Route53 alias record in the CDN stack.
|
|
27
|
+
* "none" claims the alternate domain name + certificate WITHOUT the DNS
|
|
28
|
+
* record — the pre-flip validation state: the distribution answers for
|
|
29
|
+
* `domainName` (testable via `curl --resolve`) while live DNS still points
|
|
30
|
+
* elsewhere.
|
|
31
|
+
*/
|
|
32
|
+
record?: "alias" | "none";
|
|
33
|
+
/**
|
|
34
|
+
* Routing policy for the alias record. Use latency with a region label
|
|
35
|
+
* DIFFERENT from an existing compute-owned variant on the same name —
|
|
36
|
+
* Route53 allows one latency record per region, and sibling variants are
|
|
37
|
+
* exactly how an apex migrates between owners with no delete window (the
|
|
38
|
+
* claim registry enforces the legal shapes).
|
|
39
|
+
*/
|
|
40
|
+
routingPolicy?: {
|
|
41
|
+
type: "latency";
|
|
42
|
+
region: string;
|
|
43
|
+
};
|
|
44
|
+
/** Defaults to `${id}${region}` when `routingPolicy` is set — the same
|
|
45
|
+
* derivation convention as the ECS cluster's apex record. */
|
|
46
|
+
setIdentifier?: string;
|
|
47
|
+
/** Explicit managed-domain identity — beats the CLI-injected binding. */
|
|
48
|
+
managedDomain?: ManagedDomainExports | ManagedDomainBinding;
|
|
49
|
+
/** BYO zone identity (with `hostedZoneId` to skip the runtime lookup). */
|
|
50
|
+
zoneName?: string;
|
|
51
|
+
hostedZoneId?: string;
|
|
52
|
+
}
|
|
12
53
|
/**
|
|
13
54
|
* Common CDN props shared across all origin types.
|
|
14
55
|
*/
|
|
@@ -19,6 +60,20 @@ interface BaseCdnProps {
|
|
|
19
60
|
* is only set by hand for a directly-instantiated `Cdn`.
|
|
20
61
|
*/
|
|
21
62
|
appName?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Managed-domain surface: resolve the zone + us-east-1 viewer certificate
|
|
65
|
+
* through the domain helpers and own the domain's alias record in the CDN
|
|
66
|
+
* stack. Mutually exclusive with the literal
|
|
67
|
+
* `domainNames`/`certificate`/`certificateArn` surface, and requires the
|
|
68
|
+
* factory path (`app.addCdn(CdnFactory.build(...))`) so the `App` is known.
|
|
69
|
+
*/
|
|
70
|
+
domainConfig?: CdnDomainConfig;
|
|
71
|
+
/**
|
|
72
|
+
* App instance backing `domainConfig` resolution (certificate placement,
|
|
73
|
+
* zone lookups). `CdnFactory.build` fills it — only set by hand for a
|
|
74
|
+
* directly-instantiated `Cdn`.
|
|
75
|
+
*/
|
|
76
|
+
app?: App;
|
|
22
77
|
cachePolicy?: CachePolicyPreset | ICachePolicy;
|
|
23
78
|
defaultAllowedMethods?: "GET_HEAD" | "GET_HEAD_OPTIONS" | "ALL";
|
|
24
79
|
behaviours?: SmartCdnBehaviour[];
|
|
@@ -97,6 +152,24 @@ export interface SmartCdnBehaviour {
|
|
|
97
152
|
*/
|
|
98
153
|
export declare class Cdn extends CloudFrontDistribution implements ICdn {
|
|
99
154
|
constructor(scope: Construct, id: string, props: ICdnProps);
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a `domainConfig` through the shared pattern-domain helpers
|
|
157
|
+
* (design C1): zone identity via `resolvePatternZone` (explicit
|
|
158
|
+
* `managedDomain` → CLI-injected binding → BYO `zoneName`/`hostedZoneId`),
|
|
159
|
+
* viewer certificate via `resolvePatternCloudFrontCertificate` (binding's
|
|
160
|
+
* literal us-east-1 ARN, else app-owned provisioning). The exact resolution
|
|
161
|
+
* chain staticSite and payload already use — the CDN stops being the one
|
|
162
|
+
* CloudFront-fronted pattern without it.
|
|
163
|
+
*/
|
|
164
|
+
private static resolveDomain;
|
|
165
|
+
/**
|
|
166
|
+
* Own the domain's DNS record in the CDN stack (satellite doctrine — the
|
|
167
|
+
* app owns its records, the domain stack owns zone-level records), exactly
|
|
168
|
+
* as the ECS cluster owns its apex alias. Latency routing gets the
|
|
169
|
+
* cluster's setIdentifier derivation (`${id}${region}`) unless overridden;
|
|
170
|
+
* `record: "none"` skips this entirely (pre-flip validation state).
|
|
171
|
+
*/
|
|
172
|
+
private createDomainRecord;
|
|
100
173
|
/**
|
|
101
174
|
* Resolve ICdnProps to CloudFrontDistributionProps.
|
|
102
175
|
*/
|
|
@@ -148,6 +221,20 @@ export declare class Cdn extends CloudFrontDistribution implements ICdn {
|
|
|
148
221
|
* domainNames: ["app.example.com"],
|
|
149
222
|
* certificate: myCert
|
|
150
223
|
* }));
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* // Managed domain — the CDN resolves the zone + us-east-1 certificate and
|
|
227
|
+
* // owns the alias record in its own stack (satellite doctrine), like the
|
|
228
|
+
* // ECS cluster's domainConfig.
|
|
229
|
+
* app.addCdn(CdnFactory.build("AppCdn", {
|
|
230
|
+
* originType: "http",
|
|
231
|
+
* domainName: "origin.example.com",
|
|
232
|
+
* forwardHostHeader: true,
|
|
233
|
+
* domainConfig: {
|
|
234
|
+
* domainName: "example.com",
|
|
235
|
+
* routingPolicy: { type: "latency", region: "us-east-1" }
|
|
236
|
+
* }
|
|
237
|
+
* }));
|
|
151
238
|
*/
|
|
152
239
|
export declare class CdnFactory {
|
|
153
240
|
/**
|
|
@@ -1,14 +1,110 @@
|
|
|
1
1
|
import { Fn, Token } from "aws-cdk-lib";
|
|
2
2
|
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
3
|
+
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
3
4
|
import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
|
|
5
|
+
import { AliasRecord } from "../../resources/aws/networking/index.js";
|
|
6
|
+
import { DNS_APEX } from "@fjall/util";
|
|
7
|
+
import { resolvePatternZone, resolvePatternCloudFrontCertificate } from "./patternDomain.js";
|
|
4
8
|
import { isStorage } from "./storage.js";
|
|
5
9
|
import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
|
|
10
|
+
/**
|
|
11
|
+
* Relative record label for `domain` within `zoneName` (the staticSite
|
|
12
|
+
* `recordLabelFor` convention): the zone apex maps to the canonical apex
|
|
13
|
+
* label, sub-names drop the zone suffix.
|
|
14
|
+
*/
|
|
15
|
+
function recordLabelWithin(domain, zoneName) {
|
|
16
|
+
if (domain === zoneName) {
|
|
17
|
+
return DNS_APEX;
|
|
18
|
+
}
|
|
19
|
+
const suffix = `.${zoneName}`;
|
|
20
|
+
return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
|
|
21
|
+
}
|
|
22
|
+
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
23
|
+
function normaliseDnsName(name) {
|
|
24
|
+
return name.toLowerCase().replace(/\.$/, "");
|
|
25
|
+
}
|
|
6
26
|
/**
|
|
7
27
|
* Validates CDN props — synth-time hard errors for shapes CloudFormation
|
|
8
28
|
* would only reject at deploy.
|
|
9
29
|
*/
|
|
10
30
|
function validateCdnProps(props) {
|
|
11
31
|
const hasCertificate = !!(props.certificate || props.certificateArn);
|
|
32
|
+
// domainConfig is the managed-domain authority for the viewer config —
|
|
33
|
+
// mixing it with the literal surface would give the alias list and the
|
|
34
|
+
// certificate two owners with no defined precedence.
|
|
35
|
+
if (props.domainConfig !== undefined) {
|
|
36
|
+
if (props.domainNames !== undefined || hasCertificate) {
|
|
37
|
+
throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') cannot be ` +
|
|
38
|
+
"combined with 'domainNames', 'certificate', or 'certificateArn'. " +
|
|
39
|
+
"'domainConfig' derives the alternate domain names and resolves " +
|
|
40
|
+
"the us-east-1 viewer certificate itself — drop the literal props, " +
|
|
41
|
+
"or drop 'domainConfig' and manage both by hand.");
|
|
42
|
+
}
|
|
43
|
+
if (props.app === undefined) {
|
|
44
|
+
throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') requires ` +
|
|
45
|
+
"the App for certificate placement, but 'app' is unset. Create the " +
|
|
46
|
+
"CDN through the factory path — app.addCdn(CdnFactory.build(...)) " +
|
|
47
|
+
"— which fills it, or pass 'app' explicitly.");
|
|
48
|
+
}
|
|
49
|
+
// Latency region and setIdentifier are literals by contract — inspect
|
|
50
|
+
// them at synth so a config slip fails here with the cure, not at
|
|
51
|
+
// CloudFormation with a raw Route53 InvalidChangeBatch (or, for the
|
|
52
|
+
// empty-string cases, a CDK error about a prop the user never set).
|
|
53
|
+
const region = props.domainConfig.routingPolicy?.region;
|
|
54
|
+
if (region !== undefined && !/^[a-z]{2,4}(-[a-z]+)+-\d+$/.test(region)) {
|
|
55
|
+
throw new Error(`CDN 'domainConfig.routingPolicy.region' ('${region}') is not an ` +
|
|
56
|
+
"AWS region name (expected e.g. 'us-east-1'). Route53 latency " +
|
|
57
|
+
"records carry a literal region label — fix the region string.");
|
|
58
|
+
}
|
|
59
|
+
const setIdentifier = props.domainConfig.setIdentifier;
|
|
60
|
+
if (setIdentifier !== undefined &&
|
|
61
|
+
(setIdentifier.trim() === "" || setIdentifier.length > 128)) {
|
|
62
|
+
throw new Error(`CDN 'domainConfig.setIdentifier' (${JSON.stringify(setIdentifier)}) ` +
|
|
63
|
+
"must be 1-128 non-blank characters (Route53 SetIdentifier limit). " +
|
|
64
|
+
"Omit it to use the derived '<id><region>' value.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Origin-loop guard (design 2026-08-17 cdn-domain-ownership C4): an HTTP
|
|
68
|
+
// origin pointing at one of the distribution's own domain names sends
|
|
69
|
+
// every request CloudFront → DNS → CloudFront until the request-depth
|
|
70
|
+
// limit 508s. Synth can see this shape whenever the origin hostname is a
|
|
71
|
+
// literal, so refuse it with the cure.
|
|
72
|
+
const aliasNames = new Set();
|
|
73
|
+
if (props.domainConfig !== undefined) {
|
|
74
|
+
aliasNames.add(normaliseDnsName(props.domainConfig.domainName));
|
|
75
|
+
}
|
|
76
|
+
for (const name of props.domainNames ?? []) {
|
|
77
|
+
if (!Token.isUnresolved(name)) {
|
|
78
|
+
aliasNames.add(normaliseDnsName(name));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const defaultOriginHostname = props.originType === "http"
|
|
82
|
+
? props.domainName
|
|
83
|
+
: props.originType === "auto" && typeof props.origin === "string"
|
|
84
|
+
? props.origin
|
|
85
|
+
: undefined;
|
|
86
|
+
// Behaviour origins are the same literal-HTTP-origin surface: a string
|
|
87
|
+
// behaviour origin becomes an HTTP origin via detectOriginFromResource, so
|
|
88
|
+
// a self-referential one loops exactly like the default origin — but only
|
|
89
|
+
// for its path pattern, a PARTIAL outage that is even harder to spot.
|
|
90
|
+
const literalOriginHostnames = [
|
|
91
|
+
...(defaultOriginHostname !== undefined ? [defaultOriginHostname] : []),
|
|
92
|
+
...(props.behaviours ?? [])
|
|
93
|
+
.map((behaviour) => behaviour.origin)
|
|
94
|
+
.filter((origin) => typeof origin === "string")
|
|
95
|
+
];
|
|
96
|
+
for (const originHostname of literalOriginHostnames) {
|
|
97
|
+
if (!Token.isUnresolved(originHostname) &&
|
|
98
|
+
aliasNames.has(normaliseDnsName(originHostname))) {
|
|
99
|
+
throw new Error(`CDN origin '${originHostname}' is also one of the distribution's own ` +
|
|
100
|
+
"domain names — once DNS points that name at the distribution, every " +
|
|
101
|
+
"request loops CloudFront → CloudFront. Point the origin at a " +
|
|
102
|
+
"dedicated origin hostname instead (e.g. an ECS service " +
|
|
103
|
+
"'routing[].host' such as 'origin.<zone>' resolving to the load " +
|
|
104
|
+
"balancer, or a domain-stack record targeting the origin resource) " +
|
|
105
|
+
"and keep the public name on the CDN.");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
12
108
|
// CloudFront accepts viewer certificates from us-east-1 only. A literal
|
|
13
109
|
// ARN is inspectable at synth (design H3/D3 — the old advisory log is
|
|
14
110
|
// replaced by real us-east-1 provisioning in the patterns; this assert
|
|
@@ -73,20 +169,80 @@ function validateCdnProps(props) {
|
|
|
73
169
|
export class Cdn extends CloudFrontDistribution {
|
|
74
170
|
constructor(scope, id, props) {
|
|
75
171
|
validateCdnProps(props);
|
|
76
|
-
|
|
172
|
+
// Zone + certificate resolve BEFORE super() — the viewer certificate is
|
|
173
|
+
// part of the distribution's props — so their constructs land on the CDN
|
|
174
|
+
// stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
|
|
175
|
+
const domain = props.domainConfig !== undefined && props.app !== undefined
|
|
176
|
+
? Cdn.resolveDomain(scope, id, props.domainConfig, props.app)
|
|
177
|
+
: undefined;
|
|
178
|
+
const resolvedProps = Cdn.resolveProps(scope, id, props, domain);
|
|
77
179
|
super(scope, id, resolvedProps);
|
|
180
|
+
if (domain !== undefined && props.domainConfig !== undefined) {
|
|
181
|
+
this.createDomainRecord(id, props.domainConfig, domain);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Resolve a `domainConfig` through the shared pattern-domain helpers
|
|
186
|
+
* (design C1): zone identity via `resolvePatternZone` (explicit
|
|
187
|
+
* `managedDomain` → CLI-injected binding → BYO `zoneName`/`hostedZoneId`),
|
|
188
|
+
* viewer certificate via `resolvePatternCloudFrontCertificate` (binding's
|
|
189
|
+
* literal us-east-1 ARN, else app-owned provisioning). The exact resolution
|
|
190
|
+
* chain staticSite and payload already use — the CDN stops being the one
|
|
191
|
+
* CloudFront-fronted pattern without it.
|
|
192
|
+
*/
|
|
193
|
+
static resolveDomain(scope, id, domainConfig, app) {
|
|
194
|
+
const request = {
|
|
195
|
+
scope,
|
|
196
|
+
app,
|
|
197
|
+
idPrefix: id,
|
|
198
|
+
context: `CDN '${id}'`,
|
|
199
|
+
domain: domainConfig.domainName,
|
|
200
|
+
identity: domainConfig
|
|
201
|
+
};
|
|
202
|
+
const zone = resolvePatternZone(request);
|
|
203
|
+
const certificate = resolvePatternCloudFrontCertificate(request, zone);
|
|
204
|
+
return { zone, certificate };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Own the domain's DNS record in the CDN stack (satellite doctrine — the
|
|
208
|
+
* app owns its records, the domain stack owns zone-level records), exactly
|
|
209
|
+
* as the ECS cluster owns its apex alias. Latency routing gets the
|
|
210
|
+
* cluster's setIdentifier derivation (`${id}${region}`) unless overridden;
|
|
211
|
+
* `record: "none"` skips this entirely (pre-flip validation state).
|
|
212
|
+
*/
|
|
213
|
+
createDomainRecord(id, domainConfig, domain) {
|
|
214
|
+
if (domainConfig.record === "none") {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const region = domainConfig.routingPolicy?.region;
|
|
218
|
+
const setIdentifier = domainConfig.setIdentifier ??
|
|
219
|
+
(region !== undefined ? `${id}${region}` : undefined);
|
|
220
|
+
new AliasRecord(this, "AliasRecord", {
|
|
221
|
+
zone: domain.zone.hostedZone,
|
|
222
|
+
zoneName: domain.zone.zoneName,
|
|
223
|
+
recordName: recordLabelWithin(domainConfig.domainName, domain.zone.zoneName),
|
|
224
|
+
// CloudFrontTarget sets no EvaluateTargetHealth — Route53 requires it
|
|
225
|
+
// absent for CloudFront alias targets.
|
|
226
|
+
target: new CloudFrontTarget(this.getDistribution()),
|
|
227
|
+
...(region !== undefined && { region }),
|
|
228
|
+
...(setIdentifier !== undefined && { setIdentifier })
|
|
229
|
+
});
|
|
78
230
|
}
|
|
79
231
|
/**
|
|
80
232
|
* Resolve ICdnProps to CloudFrontDistributionProps.
|
|
81
233
|
*/
|
|
82
|
-
static resolveProps(scope, id, props) {
|
|
234
|
+
static resolveProps(scope, id, props, domain) {
|
|
83
235
|
const defaultOrigin = Cdn.resolveDefaultOrigin(props);
|
|
84
236
|
const behaviours = Cdn.resolveBehaviours(props.behaviours);
|
|
85
237
|
const appName = props.appName;
|
|
238
|
+
const domainNames = props.domainNames ??
|
|
239
|
+
(props.domainConfig !== undefined
|
|
240
|
+
? [props.domainConfig.domainName]
|
|
241
|
+
: undefined);
|
|
86
242
|
const certificate = props.certificate ??
|
|
87
243
|
(props.certificateArn
|
|
88
244
|
? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
|
|
89
|
-
:
|
|
245
|
+
: domain?.certificate);
|
|
90
246
|
const s3Routing = props.originType === "s3"
|
|
91
247
|
? {
|
|
92
248
|
defaultRootObject: props.defaultRootObject,
|
|
@@ -102,7 +258,7 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
102
258
|
defaultCachePolicy: props.cachePolicy,
|
|
103
259
|
defaultAllowedMethods: props.defaultAllowedMethods,
|
|
104
260
|
behaviours,
|
|
105
|
-
domainNames
|
|
261
|
+
domainNames,
|
|
106
262
|
certificate,
|
|
107
263
|
comment: props.comment,
|
|
108
264
|
enableLogging: props.enableLogging,
|
|
@@ -265,6 +421,20 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
265
421
|
* domainNames: ["app.example.com"],
|
|
266
422
|
* certificate: myCert
|
|
267
423
|
* }));
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* // Managed domain — the CDN resolves the zone + us-east-1 certificate and
|
|
427
|
+
* // owns the alias record in its own stack (satellite doctrine), like the
|
|
428
|
+
* // ECS cluster's domainConfig.
|
|
429
|
+
* app.addCdn(CdnFactory.build("AppCdn", {
|
|
430
|
+
* originType: "http",
|
|
431
|
+
* domainName: "origin.example.com",
|
|
432
|
+
* forwardHostHeader: true,
|
|
433
|
+
* domainConfig: {
|
|
434
|
+
* domainName: "example.com",
|
|
435
|
+
* routingPolicy: { type: "latency", region: "us-east-1" }
|
|
436
|
+
* }
|
|
437
|
+
* }));
|
|
268
438
|
*/
|
|
269
439
|
export class CdnFactory {
|
|
270
440
|
/**
|
|
@@ -278,9 +448,12 @@ export class CdnFactory {
|
|
|
278
448
|
return (app, scope) => {
|
|
279
449
|
// The App is the only place the app name is known here; without it the
|
|
280
450
|
// export falls back to the construct id and the Domain import misses.
|
|
451
|
+
// The App instance itself rides along for domainConfig resolution
|
|
452
|
+
// (certificate placement needs app.getUsEast1CertificateStack()).
|
|
281
453
|
return new Cdn(scope, id, {
|
|
282
454
|
...props,
|
|
283
|
-
appName: props.appName ?? app.getName()
|
|
455
|
+
appName: props.appName ?? app.getName(),
|
|
456
|
+
app: props.app ?? app
|
|
284
457
|
});
|
|
285
458
|
};
|
|
286
459
|
}
|
|
@@ -23,15 +23,19 @@ export declare function fjallApp(appName: string, computeName?: string): FjallAl
|
|
|
23
23
|
/**
|
|
24
24
|
* Target helper for a Fjall CloudFront-fronted app.
|
|
25
25
|
*
|
|
26
|
-
* Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName
|
|
27
|
-
*
|
|
26
|
+
* Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
|
|
27
|
+
* — emitted by every `CloudFrontDistribution` given an `appName` (the
|
|
28
|
+
* `CdnFactory.build` path fills it from the App). Naming an app with no such
|
|
29
|
+
* export fails at CloudFormation execution, exactly as `fjallApp` documents.
|
|
28
30
|
*/
|
|
29
31
|
export declare function fjallCdn(appName: string): FjallAliasTarget;
|
|
30
32
|
/**
|
|
31
33
|
* Target helper for a Fjall S3 static-site bucket.
|
|
32
34
|
*
|
|
33
35
|
* Requires the bucket stack to publish exports `${safeBucket}WebsiteEndpoint`
|
|
34
|
-
* and `${safeBucket}WebsiteHostedZoneId
|
|
36
|
+
* and `${safeBucket}WebsiteHostedZoneId` — emitted by website-enabled `S3`
|
|
37
|
+
* storage. Naming a bucket with no such exports fails at CloudFormation
|
|
38
|
+
* execution, exactly as `fjallApp` documents.
|
|
35
39
|
*/
|
|
36
40
|
export declare function fjallBucket(bucketName: string): FjallAliasTarget;
|
|
37
41
|
/**
|
|
@@ -25,8 +25,10 @@ export function fjallApp(appName, computeName) {
|
|
|
25
25
|
/**
|
|
26
26
|
* Target helper for a Fjall CloudFront-fronted app.
|
|
27
27
|
*
|
|
28
|
-
* Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName
|
|
29
|
-
*
|
|
28
|
+
* Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
|
|
29
|
+
* — emitted by every `CloudFrontDistribution` given an `appName` (the
|
|
30
|
+
* `CdnFactory.build` path fills it from the App). Naming an app with no such
|
|
31
|
+
* export fails at CloudFormation execution, exactly as `fjallApp` documents.
|
|
30
32
|
*/
|
|
31
33
|
export function fjallCdn(appName) {
|
|
32
34
|
const resolved = resolveCdnTarget(appName);
|
|
@@ -42,7 +44,9 @@ export function fjallCdn(appName) {
|
|
|
42
44
|
* Target helper for a Fjall S3 static-site bucket.
|
|
43
45
|
*
|
|
44
46
|
* Requires the bucket stack to publish exports `${safeBucket}WebsiteEndpoint`
|
|
45
|
-
* and `${safeBucket}WebsiteHostedZoneId
|
|
47
|
+
* and `${safeBucket}WebsiteHostedZoneId` — emitted by website-enabled `S3`
|
|
48
|
+
* storage. Naming a bucket with no such exports fails at CloudFormation
|
|
49
|
+
* execution, exactly as `fjallApp` documents.
|
|
46
50
|
*/
|
|
47
51
|
export function fjallBucket(bucketName) {
|
|
48
52
|
const resolved = resolveBucketTarget(bucketName);
|
|
@@ -367,22 +367,28 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
367
367
|
...(geoLocation !== undefined && { geoLocation }),
|
|
368
368
|
...(setIdentifier !== undefined && { setIdentifier })
|
|
369
369
|
};
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
370
|
+
// apexRecord: "none" yields ONLY the domainName record itself (an
|
|
371
|
+
// ingress-migrated name now owned by a CDN's domainConfig) — routed-host,
|
|
372
|
+
// redirect-host records, listener, and certificates below are untouched
|
|
373
|
+
// (design 2026-08-17 cdn-domain-ownership C2).
|
|
374
|
+
if (domainConfig?.apexRecord !== "none") {
|
|
375
|
+
const apex = new AliasRecord(ctx.scope, `${props.clusterName}ARecord`, {
|
|
376
|
+
zone: hostedZone,
|
|
377
|
+
zoneName,
|
|
378
|
+
recordName: recordLabelWithin(domainName, zoneName),
|
|
379
|
+
target: new LoadBalancerTarget(loadBalancer, {
|
|
380
|
+
evaluateTargetHealth: hasRoutingPolicy
|
|
381
|
+
}),
|
|
382
|
+
// No-churn adoption: the pre-wrapper raw ARecord carried no comment,
|
|
383
|
+
// so adding one now would be a property delta on the live production
|
|
384
|
+
// apex record (flipless-retain-flip principle).
|
|
385
|
+
omitComment: true,
|
|
386
|
+
...routingProps
|
|
387
|
+
});
|
|
388
|
+
// Preserve the pre-wrapper logical ID — see preWrapperLogicalId.
|
|
389
|
+
apex.record.node.defaultChild.overrideLogicalId(preWrapperLogicalId(ctx.scope, `${props.clusterName}ARecord`));
|
|
390
|
+
aRecord = apex.record;
|
|
391
|
+
}
|
|
386
392
|
for (const host of routedHosts) {
|
|
387
393
|
if (host === domainName)
|
|
388
394
|
continue;
|
|
@@ -260,6 +260,16 @@ export interface DomainBaseConfig {
|
|
|
260
260
|
* Omit for a plain alias record.
|
|
261
261
|
*/
|
|
262
262
|
routingPolicy?: EcsDomainRoutingPolicy;
|
|
263
|
+
/**
|
|
264
|
+
* The cluster's own DNS record for `domainName`. "alias" (default) mints
|
|
265
|
+
* the alias A record targeting the ALB — the long-standing behaviour.
|
|
266
|
+
* "none" keeps EVERYTHING else (certificates, HTTPS listener, redirect
|
|
267
|
+
* hosts and their records, host-routed service records) but yields the
|
|
268
|
+
* `domainName` record itself — the final state of an ingress migration
|
|
269
|
+
* where a CDN's `domainConfig` has taken the name over
|
|
270
|
+
* (design 2026-08-17 cdn-domain-ownership C2).
|
|
271
|
+
*/
|
|
272
|
+
apexRecord?: "alias" | "none";
|
|
263
273
|
/**
|
|
264
274
|
* Hostnames (FQDNs within the zone, e.g. `"www.example.com"`) that
|
|
265
275
|
* permanently redirect to `domainName` at the ALB. Each host gets an alias
|
|
@@ -11,8 +11,35 @@ export class AliasRecord extends Construct {
|
|
|
11
11
|
this.description =
|
|
12
12
|
props.description ?? defaultDnsComment("alias", this.fqdn);
|
|
13
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
|
-
|
|
14
|
+
// an alias and a plain A record on the same name collide at synth. The
|
|
15
|
+
// routing variant rides along: policy siblings with distinct
|
|
16
|
+
// setIdentifiers (e.g. a compute apex alias and a CDN apex alias during
|
|
17
|
+
// an ingress migration) are legal and register cleanly.
|
|
18
|
+
const routingPolicy = props.region !== undefined
|
|
19
|
+
? "latency"
|
|
20
|
+
: props.weight !== undefined
|
|
21
|
+
? "weighted"
|
|
22
|
+
: props.geoLocation !== undefined
|
|
23
|
+
? "geolocation"
|
|
24
|
+
: undefined;
|
|
25
|
+
// Route53 allows one geolocation record per location VALUE — serialise
|
|
26
|
+
// the location so the registry can compare siblings ("*" = the default
|
|
27
|
+
// wildcard record, which is equally one-per-(name, type)).
|
|
28
|
+
const geoLocationKey = props.geoLocation !== undefined
|
|
29
|
+
? [
|
|
30
|
+
props.geoLocation.continentCode,
|
|
31
|
+
props.geoLocation.countryCode,
|
|
32
|
+
props.geoLocation.subdivisionCode
|
|
33
|
+
]
|
|
34
|
+
.filter((part) => part !== undefined)
|
|
35
|
+
.join("/") || "*"
|
|
36
|
+
: undefined;
|
|
37
|
+
claimDnsRecord(this, props, "A", this.fqdn, {
|
|
38
|
+
setIdentifier: props.setIdentifier,
|
|
39
|
+
routingPolicy,
|
|
40
|
+
region: props.region,
|
|
41
|
+
...(geoLocationKey !== undefined && { geoLocationKey })
|
|
42
|
+
});
|
|
16
43
|
// Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
|
|
17
44
|
// so we deliberately do not forward props.ttl to the underlying CDK resource.
|
|
18
45
|
this.record = new CdkARecord(this, "Record", {
|
|
@@ -14,13 +14,27 @@ export interface DnsRecordCommonProps {
|
|
|
14
14
|
export declare const DEFAULT_DNS_TTL_SECONDS = 300;
|
|
15
15
|
export declare function resolveTtl(ttlSeconds: number | undefined): Duration;
|
|
16
16
|
export declare function defaultDnsComment(recordType: string, fqdn: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Routing-variant half of a claim — filled in by wrappers whose records can
|
|
19
|
+
* carry a routing policy (today: AliasRecord). Sibling variants with the
|
|
20
|
+
* same policy type and distinct setIdentifiers legally share a (zone, name,
|
|
21
|
+
* type) triple; the registry encodes Route53's coexistence rules.
|
|
22
|
+
*/
|
|
23
|
+
export interface DnsRecordClaimVariant {
|
|
24
|
+
readonly setIdentifier?: string;
|
|
25
|
+
readonly routingPolicy?: "latency" | "weighted" | "geolocation";
|
|
26
|
+
readonly region?: string;
|
|
27
|
+
/** Serialised geolocation value (continent/country/subdivision). */
|
|
28
|
+
readonly geoLocationKey?: string;
|
|
29
|
+
}
|
|
17
30
|
/**
|
|
18
31
|
* Claim the (zone, name, type) triple in the app-scoped collision registry
|
|
19
32
|
* (design D5, synth-side). Every wrapper in the dnsRecord family calls this
|
|
20
|
-
* from its constructor so
|
|
21
|
-
* the app — fails at synth instead of at CloudFormation deploy.
|
|
22
|
-
* claim their underlying Route53 type ("A")
|
|
23
|
-
*
|
|
33
|
+
* from its constructor so an illegal second claimant — same stack or another
|
|
34
|
+
* stack in the app — fails at synth instead of at CloudFormation deploy.
|
|
35
|
+
* Alias records claim their underlying Route53 type ("A") and pass their
|
|
36
|
+
* routing variant so legal policy siblings register cleanly. Observation
|
|
37
|
+
* only: no constructs are created, so construct trees stay byte-identical.
|
|
24
38
|
*/
|
|
25
|
-
export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string): void;
|
|
39
|
+
export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string, variant?: DnsRecordClaimVariant): void;
|
|
26
40
|
export declare function applyDnsRecordTags(construct: Construct, props: Pick<DnsRecordCommonProps, "zoneName" | "costAllocationEnvironment" | "costAllocationDomain">): void;
|
|
@@ -15,17 +15,28 @@ export function defaultDnsComment(recordType, fqdn) {
|
|
|
15
15
|
/**
|
|
16
16
|
* Claim the (zone, name, type) triple in the app-scoped collision registry
|
|
17
17
|
* (design D5, synth-side). Every wrapper in the dnsRecord family calls this
|
|
18
|
-
* from its constructor so
|
|
19
|
-
* the app — fails at synth instead of at CloudFormation deploy.
|
|
20
|
-
* claim their underlying Route53 type ("A")
|
|
21
|
-
*
|
|
18
|
+
* from its constructor so an illegal second claimant — same stack or another
|
|
19
|
+
* stack in the app — fails at synth instead of at CloudFormation deploy.
|
|
20
|
+
* Alias records claim their underlying Route53 type ("A") and pass their
|
|
21
|
+
* routing variant so legal policy siblings register cleanly. Observation
|
|
22
|
+
* only: no constructs are created, so construct trees stay byte-identical.
|
|
22
23
|
*/
|
|
23
|
-
export function claimDnsRecord(construct, props, recordType, fqdn) {
|
|
24
|
+
export function claimDnsRecord(construct, props, recordType, fqdn, variant) {
|
|
24
25
|
registerDnsRecordClaim(construct, {
|
|
25
26
|
zone: props.zone,
|
|
26
27
|
zoneName: props.zoneName,
|
|
27
28
|
fqdn,
|
|
28
|
-
recordType
|
|
29
|
+
recordType,
|
|
30
|
+
...(variant?.setIdentifier !== undefined && {
|
|
31
|
+
setIdentifier: variant.setIdentifier
|
|
32
|
+
}),
|
|
33
|
+
...(variant?.routingPolicy !== undefined && {
|
|
34
|
+
routingPolicy: variant.routingPolicy
|
|
35
|
+
}),
|
|
36
|
+
...(variant?.region !== undefined && { region: variant.region }),
|
|
37
|
+
...(variant?.geoLocationKey !== undefined && {
|
|
38
|
+
geoLocationKey: variant.geoLocationKey
|
|
39
|
+
})
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
42
|
export function applyDnsRecordTags(construct, props) {
|
|
@@ -2,13 +2,23 @@ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
|
2
2
|
import type { IConstruct } from "constructs";
|
|
3
3
|
/**
|
|
4
4
|
* Synth-time DNS record-collision registry (design D5, synth-side half — the
|
|
5
|
-
* deploy-time preflight against live zones is
|
|
5
|
+
* deploy-time preflight against live zones is the CLI/deploy-core domain
|
|
6
|
+
* gate).
|
|
6
7
|
*
|
|
7
|
-
* Route53 allows exactly one record set per (zone, name, type)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Route53 allows exactly one record set per (zone, name, type) — EXCEPT for
|
|
9
|
+
* routing-policy variants, where sibling record sets share the (name, type)
|
|
10
|
+
* pair and are discriminated by `setIdentifier`. Sibling variants must all
|
|
11
|
+
* carry the same policy type, and latency variants must each use a distinct
|
|
12
|
+
* region. A shape Route53 rejects fails only at CloudFormation deploy with a
|
|
13
|
+
* raw "record set already exists" / "conflicting RRSet"; this registry makes
|
|
14
|
+
* it loud at synth: every construct in the dnsRecord wrapper family registers
|
|
15
|
+
* its claim here, and an illegal combination throws naming both claimant
|
|
16
|
+
* construct paths and the cure.
|
|
17
|
+
*
|
|
18
|
+
* Legal coexistence (design 2026-08-17 cdn-domain-ownership C3): two claims
|
|
19
|
+
* on one (zone, name, type) with the SAME routing-policy type and DISTINCT
|
|
20
|
+
* `setIdentifier`s — e.g. the compute cluster's latency apex alias and a
|
|
21
|
+
* CDN's latency apex alias during an ingress migration — register cleanly.
|
|
12
22
|
*
|
|
13
23
|
* Scoping: claims are held per App (the construct-tree root), so multiple CDK
|
|
14
24
|
* Apps in one process — the vitest convention of a fresh `new CdkApp()` per
|
|
@@ -22,6 +32,7 @@ import type { IConstruct } from "constructs";
|
|
|
22
32
|
* (resetForTesting + resetManifestCollector in beforeEach/afterEach) keeps
|
|
23
33
|
* working without a third per-test call.
|
|
24
34
|
*/
|
|
35
|
+
export type DnsRoutingPolicyType = "latency" | "weighted" | "geolocation";
|
|
25
36
|
export interface DnsRecordClaim {
|
|
26
37
|
/** Zone the record lands in — consulted for identity resolution. */
|
|
27
38
|
readonly zone: IHostedZone;
|
|
@@ -34,14 +45,34 @@ export interface DnsRecordClaim {
|
|
|
34
45
|
* — an alias and a plain A record on the same name are a real collision.
|
|
35
46
|
*/
|
|
36
47
|
readonly recordType: string;
|
|
48
|
+
/**
|
|
49
|
+
* Routing-variant discriminator. A claim is a policy variant when it
|
|
50
|
+
* carries a `routingPolicy` OR a `setIdentifier` (CDK auto-generates a
|
|
51
|
+
* SetIdentifier for a policy record declared without one, so policy
|
|
52
|
+
* presence alone makes a legal variant); a claim with neither is a simple
|
|
53
|
+
* record that tolerates no siblings.
|
|
54
|
+
*/
|
|
55
|
+
readonly setIdentifier?: string;
|
|
56
|
+
/** Policy type of a variant claim — siblings must all match. */
|
|
57
|
+
readonly routingPolicy?: DnsRoutingPolicyType;
|
|
58
|
+
/**
|
|
59
|
+
* Latency region of a latency variant — Route53 allows one latency record
|
|
60
|
+
* per region per (name, type).
|
|
61
|
+
*/
|
|
62
|
+
readonly region?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Serialised location of a geolocation variant — Route53 allows one
|
|
65
|
+
* geolocation record per location value per (name, type).
|
|
66
|
+
*/
|
|
67
|
+
readonly geoLocationKey?: string;
|
|
37
68
|
}
|
|
38
69
|
/**
|
|
39
70
|
* Register a record-set claim for `scope`, throwing when a DIFFERENT
|
|
40
|
-
* construct already claimed
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
71
|
+
* construct already claimed a shape Route53 cannot hold alongside it within
|
|
72
|
+
* the same App. Re-registration by the same construct path (CDK aspects
|
|
73
|
+
* re-visiting, repeated synth) is idempotent. Pure observation: no constructs
|
|
74
|
+
* are created or mutated, so construct trees — including the eject-contract
|
|
75
|
+
* byte-identical composer IDs — are untouched.
|
|
45
76
|
*/
|
|
46
77
|
export declare function registerDnsRecordClaim(scope: IConstruct, claim: DnsRecordClaim): void;
|
|
47
78
|
/**
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { Token } from "aws-cdk-lib";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* A claim is a policy variant when it declares a routing policy OR carries a
|
|
4
|
+
* setIdentifier: CDK's RecordSet auto-generates a SetIdentifier whenever a
|
|
5
|
+
* policy (region/weight/geoLocation) is set without one, so a policy claim
|
|
6
|
+
* with no explicit setIdentifier still deploys as a legal sibling variant —
|
|
7
|
+
* classifying it as simple would refuse shapes Route53 holds happily.
|
|
8
|
+
* (A setIdentifier WITHOUT a policy cannot deploy — CDK rejects it at synth —
|
|
9
|
+
* so its classification here never decides a real outcome.)
|
|
10
|
+
*/
|
|
11
|
+
function isVariantEntry(entry) {
|
|
12
|
+
return entry.routingPolicy !== undefined || entry.setIdentifier !== undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Claims per App root: claim key → claimant entries (variants share a key). */
|
|
3
15
|
let claimsByRoot = new WeakMap();
|
|
4
16
|
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
5
17
|
function normaliseDnsName(name) {
|
|
@@ -18,13 +30,20 @@ function resolveZoneIdentity(zone, zoneName) {
|
|
|
18
30
|
}
|
|
19
31
|
return normaliseDnsName(zoneName);
|
|
20
32
|
}
|
|
33
|
+
function collisionError(claim, recordType, existing, claimantPath, detail) {
|
|
34
|
+
return new Error(`DNS record '${claim.fqdn}' (${recordType}) in zone '${claim.zoneName}': ` +
|
|
35
|
+
`already claimed by construct '${existing.path}' — duplicate claim by '${claimantPath}'. ` +
|
|
36
|
+
`${detail} ` +
|
|
37
|
+
`Keep one owner per record — satellites own their app records; the domain stack owns zone-level records. ` +
|
|
38
|
+
`Remove or rename one claimant, or run 'fjall domain records list ${claim.zoneName}' to inspect the zone.`);
|
|
39
|
+
}
|
|
21
40
|
/**
|
|
22
41
|
* Register a record-set claim for `scope`, throwing when a DIFFERENT
|
|
23
|
-
* construct already claimed
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
42
|
+
* construct already claimed a shape Route53 cannot hold alongside it within
|
|
43
|
+
* the same App. Re-registration by the same construct path (CDK aspects
|
|
44
|
+
* re-visiting, repeated synth) is idempotent. Pure observation: no constructs
|
|
45
|
+
* are created or mutated, so construct trees — including the eject-contract
|
|
46
|
+
* byte-identical composer IDs — are untouched.
|
|
28
47
|
*/
|
|
29
48
|
export function registerDnsRecordClaim(scope, claim) {
|
|
30
49
|
const root = scope.node.root;
|
|
@@ -40,19 +59,54 @@ export function registerDnsRecordClaim(scope, claim) {
|
|
|
40
59
|
recordType
|
|
41
60
|
].join("|");
|
|
42
61
|
const claimantPath = scope.node.path;
|
|
62
|
+
const entry = {
|
|
63
|
+
path: claimantPath,
|
|
64
|
+
setIdentifier: claim.setIdentifier,
|
|
65
|
+
routingPolicy: claim.routingPolicy,
|
|
66
|
+
region: claim.region,
|
|
67
|
+
geoLocationKey: claim.geoLocationKey
|
|
68
|
+
};
|
|
43
69
|
const existing = claims.get(key);
|
|
44
70
|
if (existing === undefined) {
|
|
45
|
-
claims.set(key,
|
|
71
|
+
claims.set(key, [entry]);
|
|
46
72
|
return;
|
|
47
73
|
}
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
for (const other of existing) {
|
|
75
|
+
if (other.path === claimantPath) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const bothVariants = isVariantEntry(other) && isVariantEntry(entry);
|
|
79
|
+
if (!bothVariants) {
|
|
80
|
+
// Simple vs simple, or simple vs variant — Route53 holds neither.
|
|
81
|
+
const detail = isVariantEntry(other) || isVariantEntry(entry)
|
|
82
|
+
? "Route53 cannot mix a simple record set with routing-policy variants on one (zone, name, type) — give BOTH records the same routing-policy type with distinct setIdentifiers, or remove one."
|
|
83
|
+
: "Route53 allows one record set per (zone, name, type); CloudFormation would reject this at deploy.";
|
|
84
|
+
throw collisionError(claim, recordType, other, claimantPath, detail);
|
|
85
|
+
}
|
|
86
|
+
if (other.setIdentifier !== undefined &&
|
|
87
|
+
other.setIdentifier === entry.setIdentifier) {
|
|
88
|
+
throw collisionError(claim, recordType, other, claimantPath, `Both records carry setIdentifier '${String(entry.setIdentifier)}' — sibling routing-policy variants need distinct setIdentifiers.`);
|
|
89
|
+
}
|
|
90
|
+
// Policy types compare only when both are declared: a setIdentifier-only
|
|
91
|
+
// claim carries no policy for Route53 to mismatch (and cannot deploy —
|
|
92
|
+
// CDK rejects setIdentifier on simple records at synth).
|
|
93
|
+
if (other.routingPolicy !== undefined &&
|
|
94
|
+
entry.routingPolicy !== undefined &&
|
|
95
|
+
other.routingPolicy !== entry.routingPolicy) {
|
|
96
|
+
throw collisionError(claim, recordType, other, claimantPath, `Sibling routing-policy variants must share one policy type (got '${String(other.routingPolicy)}' vs '${String(entry.routingPolicy)}') — Route53 rejects mixed-policy siblings.`);
|
|
97
|
+
}
|
|
98
|
+
if (entry.routingPolicy === "latency" &&
|
|
99
|
+
other.region !== undefined &&
|
|
100
|
+
other.region === entry.region) {
|
|
101
|
+
throw collisionError(claim, recordType, other, claimantPath, `Both latency variants use region '${entry.region}' — Route53 allows one latency record per region; give the second variant a different region.`);
|
|
102
|
+
}
|
|
103
|
+
if (entry.routingPolicy === "geolocation" &&
|
|
104
|
+
other.geoLocationKey !== undefined &&
|
|
105
|
+
other.geoLocationKey === entry.geoLocationKey) {
|
|
106
|
+
throw collisionError(claim, recordType, other, claimantPath, `Both geolocation variants target location '${entry.geoLocationKey}' — Route53 allows one geolocation record per location value; give the second variant a different location.`);
|
|
107
|
+
}
|
|
50
108
|
}
|
|
51
|
-
|
|
52
|
-
`already claimed by construct '${existing}' — duplicate claim by '${claimantPath}'. ` +
|
|
53
|
-
`Route53 allows one record set per (zone, name, type); CloudFormation would reject this at deploy. ` +
|
|
54
|
-
`Keep one owner per record — satellites own their app records; the domain stack owns zone-level records. ` +
|
|
55
|
-
`Remove or rename one claimant, or run 'fjall domain records list ${claim.zoneName}' to inspect the zone.`);
|
|
109
|
+
existing.push(entry);
|
|
56
110
|
}
|
|
57
111
|
/**
|
|
58
112
|
* Reset all claims (for testing). Called by `App.resetForTesting()` alongside
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.1.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/fjall-tech/fjall.git",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@aws-sdk/client-organizations": "^3.1098.0",
|
|
83
|
-
"@fjall/generator": "^14.
|
|
84
|
-
"@fjall/util": "^14.
|
|
83
|
+
"@fjall/generator": "^14.1.0",
|
|
84
|
+
"@fjall/util": "^14.1.0",
|
|
85
85
|
"constructs": "^10.7.2"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|