@fjall/components-infrastructure 14.1.0 → 14.3.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/apexDomainPattern.js +1 -1
- package/dist/lib/patterns/aws/cdn.d.ts +83 -4
- package/dist/lib/patterns/aws/cdn.js +279 -69
- package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +83 -0
- package/dist/lib/patterns/aws/cdnAppOrigin.js +252 -0
- package/dist/lib/patterns/aws/computeEcs.d.ts +9 -1
- package/dist/lib/patterns/aws/computeEcs.js +10 -0
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +1 -1
- package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +13 -4
- package/dist/lib/patterns/aws/dnsRecordComposer.js +88 -6
- package/dist/lib/patterns/aws/domainCertificateComposer.js +66 -1
- package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -0
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +32 -0
- package/dist/lib/patterns/aws/patternDomain.d.ts +9 -0
- package/dist/lib/patterns/aws/patternDomain.js +60 -27
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +37 -0
- package/dist/lib/resources/aws/cdn/cloudFront.js +96 -4
- package/dist/lib/resources/aws/compute/ecs.d.ts +11 -0
- package/dist/lib/resources/aws/compute/ecs.js +27 -0
- package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -0
- package/dist/lib/resources/aws/compute/ecsNetworking.js +117 -61
- package/dist/lib/resources/aws/compute/ingressProfile.d.ts +203 -0
- package/dist/lib/resources/aws/compute/ingressProfile.js +215 -0
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +10 -2
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +18 -9
- package/dist/lib/utils/dnsRecordRegistry.d.ts +3 -5
- package/dist/lib/utils/dnsRecordRegistry.js +11 -14
- package/dist/lib/utils/domainTypes.d.ts +11 -0
- package/dist/lib/utils/domainTypes.js +17 -0
- package/dist/lib/utils/managedDomainContext.d.ts +58 -1
- package/dist/lib/utils/managedDomainContext.js +96 -0
- package/package.json +3 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synth-time ingress profile for an ECS cluster's ALB (design 2026-08-18
|
|
3
|
+
* cdn-app-origin, D2). The profile is the one sanctioned answer to "what
|
|
4
|
+
* does this cluster's listener actually do?" for downstream constructs —
|
|
5
|
+
* today the `Cdn` construct-reference origin lane, which needs a TLS-valid
|
|
6
|
+
* origin hostname, certificate coverage, and a forwarding verdict without
|
|
7
|
+
* re-deriving any of it from props.
|
|
8
|
+
*
|
|
9
|
+
* P5 (model the machinery, don't paraphrase it): every fact here is exported
|
|
10
|
+
* from the SAME computation the emitting code uses — `default404` is the
|
|
11
|
+
* predicate `addLoadBalancerListener` passes to the listener factory, the
|
|
12
|
+
* rule structure derives from the `planListenerRules` plan that
|
|
13
|
+
* `registerServiceWithALB` itself emits from (including the single-service
|
|
14
|
+
* conditions-dropped branch), and the covered SAN set is the one
|
|
15
|
+
* `addHostedZone` mints. Never recompute these facts from cluster props
|
|
16
|
+
* elsewhere; extend the plan or the profile instead.
|
|
17
|
+
*/
|
|
18
|
+
import { FjallLogger } from "../../../utils/validationLogger.js";
|
|
19
|
+
function normaliseRoutingRules(routing) {
|
|
20
|
+
return Array.isArray(routing) ? routing : routing ? [routing] : [];
|
|
21
|
+
}
|
|
22
|
+
function servicesWithPorts(services) {
|
|
23
|
+
return services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The listener's rule structure, planned once (P5). This is the SINGLE home
|
|
27
|
+
* of the branching: `registerServiceWithALB` emits from a service's plan
|
|
28
|
+
* entry, and the ingress-profile facts below (`computeListenerDefault404`,
|
|
29
|
+
* `enumerateListenerRules`) derive from the same plan — the emitter and the
|
|
30
|
+
* model cannot drift because neither re-derives the decisions.
|
|
31
|
+
*/
|
|
32
|
+
export function planListenerRules(services, redirectHosts) {
|
|
33
|
+
const withPorts = servicesWithPorts(services);
|
|
34
|
+
const isSingleService = withPorts.length === 1;
|
|
35
|
+
return {
|
|
36
|
+
services: withPorts.map((service) => {
|
|
37
|
+
const routingRules = normaliseRoutingRules(service.routing);
|
|
38
|
+
return {
|
|
39
|
+
service,
|
|
40
|
+
routingRules,
|
|
41
|
+
mode: isSingleService && routingRules.length <= 1
|
|
42
|
+
? "default"
|
|
43
|
+
: "rules"
|
|
44
|
+
};
|
|
45
|
+
}),
|
|
46
|
+
redirectHosts
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The listener's default-action predicate — the value
|
|
51
|
+
* `addLoadBalancerListener` passes to the listener factory as `default404`.
|
|
52
|
+
* `true` exactly when no service plan supplies an unconditioned default
|
|
53
|
+
* target group: ≥2 routes will exist (the emitter then attaches a fixed-404
|
|
54
|
+
* default and conditions every rule), or no service has a port (CDK rejects
|
|
55
|
+
* a listener with neither a default action nor targets).
|
|
56
|
+
*/
|
|
57
|
+
export function computeListenerDefault404(services) {
|
|
58
|
+
return !planListenerRules(services, []).services.some((plan) => plan.mode === "default");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The listener's rule structure as `registerServiceWithALB` and
|
|
62
|
+
* `addRedirectHostRules` will emit it — derived from the same
|
|
63
|
+
* `planListenerRules` plan the emitters consume: a `mode: "default"`
|
|
64
|
+
* service contributes NO conditioned rule (its target group is the listener
|
|
65
|
+
* default), and every redirect host contributes a host-matched 301 rule.
|
|
66
|
+
*/
|
|
67
|
+
export function enumerateListenerRules(services, redirectHosts) {
|
|
68
|
+
const plan = planListenerRules(services, redirectHosts);
|
|
69
|
+
const rules = [];
|
|
70
|
+
for (const servicePlan of plan.services) {
|
|
71
|
+
if (servicePlan.mode === "default")
|
|
72
|
+
continue;
|
|
73
|
+
for (const rule of servicePlan.routingRules) {
|
|
74
|
+
if (rule.host === undefined && rule.path === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
rules.push({
|
|
77
|
+
...(rule.host !== undefined && { host: rule.host }),
|
|
78
|
+
...(rule.path !== undefined && { path: rule.path }),
|
|
79
|
+
kind: "forward"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const host of plan.redirectHosts) {
|
|
84
|
+
rules.push({ host, kind: "redirect" });
|
|
85
|
+
}
|
|
86
|
+
return rules;
|
|
87
|
+
}
|
|
88
|
+
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
89
|
+
export function normaliseDnsName(name) {
|
|
90
|
+
return name.toLowerCase().replace(/\.$/, "");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
|
|
94
|
+
* (matches exactly one additional label — `*.example.com` covers
|
|
95
|
+
* `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
|
|
96
|
+
* compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
|
|
97
|
+
*/
|
|
98
|
+
export function certNameMatches(pattern, hostname) {
|
|
99
|
+
const p = pattern.toLowerCase();
|
|
100
|
+
const h = hostname.toLowerCase();
|
|
101
|
+
if (p === h)
|
|
102
|
+
return true;
|
|
103
|
+
if (!p.startsWith("*."))
|
|
104
|
+
return false;
|
|
105
|
+
const suffix = p.slice(1); // ".example.com"
|
|
106
|
+
if (!h.endsWith(suffix))
|
|
107
|
+
return false;
|
|
108
|
+
const label = h.slice(0, h.length - suffix.length);
|
|
109
|
+
return label.length > 0 && !label.includes(".");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Does the listener's attached-certificate set cover `hostname`?
|
|
113
|
+
* Fail closed where decidable, honest where opaque (P3): only `enumerated`
|
|
114
|
+
* coverage may answer `not-covered`; a miss under `partial`/`unknown` is
|
|
115
|
+
* `unknown` because an opaque certificate may still cover the name.
|
|
116
|
+
*/
|
|
117
|
+
export function certificateCovers(coverage, hostname) {
|
|
118
|
+
if (coverage.kind === "unknown")
|
|
119
|
+
return "unknown";
|
|
120
|
+
if (coverage.hostnames.some((p) => certNameMatches(p, hostname))) {
|
|
121
|
+
return "covered";
|
|
122
|
+
}
|
|
123
|
+
return coverage.kind === "enumerated" ? "not-covered" : "unknown";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Would the listener forward `Host: <hostname>` requests, for all paths?
|
|
127
|
+
* Computed on the real rule model, never a flattened hostname list:
|
|
128
|
+
*
|
|
129
|
+
* - no fixed-404 default → the default action forwards every hostname;
|
|
130
|
+
* - a host-only rule for the hostname, a host-matched catch-all `/*` path
|
|
131
|
+
* rule, or a host-less catch-all `/*` path rule, forwards all of its
|
|
132
|
+
* paths;
|
|
133
|
+
* - rules that involve the hostname only together with a narrower path
|
|
134
|
+
* condition (ANDed host+path, or host-less non-catch-all paths) forward
|
|
135
|
+
* SOME paths — `partial`, with the patterns named so the caller can
|
|
136
|
+
* surface them;
|
|
137
|
+
* - otherwise every request answers the fixed-404 default — `none`.
|
|
138
|
+
*
|
|
139
|
+
* Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
|
|
140
|
+
* carries the declared spelling, the caller's hostname may differ in case.
|
|
141
|
+
*/
|
|
142
|
+
export function forwardingVerdict(profile, hostname) {
|
|
143
|
+
if (!profile.default404)
|
|
144
|
+
return { verdict: "forwards" };
|
|
145
|
+
const wanted = normaliseDnsName(hostname);
|
|
146
|
+
const partial = [];
|
|
147
|
+
for (const rule of profile.rules) {
|
|
148
|
+
if (rule.kind !== "forward")
|
|
149
|
+
continue;
|
|
150
|
+
const hostMatches = rule.host !== undefined && normaliseDnsName(rule.host) === wanted;
|
|
151
|
+
if (hostMatches && (rule.path === undefined || rule.path === "/*")) {
|
|
152
|
+
return { verdict: "forwards" };
|
|
153
|
+
}
|
|
154
|
+
if (rule.host === undefined && rule.path === "/*") {
|
|
155
|
+
return { verdict: "forwards" };
|
|
156
|
+
}
|
|
157
|
+
if (hostMatches && rule.path !== undefined) {
|
|
158
|
+
partial.push(`Host=${rule.host} AND Path=${rule.path}`);
|
|
159
|
+
}
|
|
160
|
+
else if (rule.host === undefined && rule.path !== undefined) {
|
|
161
|
+
partial.push(`Path=${rule.path} (any host)`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return partial.length > 0
|
|
165
|
+
? { verdict: "partial", patterns: partial }
|
|
166
|
+
: { verdict: "none" };
|
|
167
|
+
}
|
|
168
|
+
export function buildIngressProfile(options) {
|
|
169
|
+
const { zoneFacts } = options;
|
|
170
|
+
const redirectHosts = zoneFacts?.redirectHosts ?? [];
|
|
171
|
+
return {
|
|
172
|
+
loadBalancer: options.loadBalancer,
|
|
173
|
+
internal: options.internal,
|
|
174
|
+
listenerPort: options.certificateAttached ? 443 : 80,
|
|
175
|
+
...(zoneFacts !== undefined && {
|
|
176
|
+
hostedZone: zoneFacts.hostedZone,
|
|
177
|
+
zoneName: zoneFacts.zoneName,
|
|
178
|
+
domainName: zoneFacts.domainName
|
|
179
|
+
}),
|
|
180
|
+
routedHosts: zoneFacts?.routedHosts ?? [],
|
|
181
|
+
redirectHosts,
|
|
182
|
+
default404: computeListenerDefault404(options.services),
|
|
183
|
+
rules: enumerateListenerRules(options.services, redirectHosts),
|
|
184
|
+
certificateCoverage: zoneFacts?.certificateCoverage ?? {
|
|
185
|
+
// No domain → no certificates attached: the attached set is fully
|
|
186
|
+
// known (empty), which is the honest enumerated answer, not "unknown".
|
|
187
|
+
kind: "enumerated",
|
|
188
|
+
hostnames: []
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
|
|
194
|
+
* fixed-404 while its `domainName` has a minted apex record and NO rule
|
|
195
|
+
* forwards it answers 404 on its own primary domain — usually the aftermath
|
|
196
|
+
* of adding a second routing rule (which flips the default action from
|
|
197
|
+
* forward to 404, `computeListenerDefault404`). A warning, not an error:
|
|
198
|
+
* pre-existing clusters can already be in this state, and narrowing them is
|
|
199
|
+
* not this check's mandate. Partial forwarding (path-split services) is the
|
|
200
|
+
* normal multi-route shape and does not warn.
|
|
201
|
+
*/
|
|
202
|
+
export function warnWhenRecordedApexUnforwarded(profile, apexRecordMinted, clusterName) {
|
|
203
|
+
if (!profile.default404)
|
|
204
|
+
return;
|
|
205
|
+
if (profile.domainName === undefined || !apexRecordMinted)
|
|
206
|
+
return;
|
|
207
|
+
if (forwardingVerdict(profile, profile.domainName).verdict !== "none")
|
|
208
|
+
return;
|
|
209
|
+
FjallLogger.warn(`Cluster '${clusterName}': the listener's default action is a fixed 404 ` +
|
|
210
|
+
`and no routing rule forwards '${profile.domainName}', but its alias ` +
|
|
211
|
+
"record is minted — requests to the cluster's own domain will answer " +
|
|
212
|
+
"404 Not Found. Multiple routing rules flip the listener default from " +
|
|
213
|
+
"forward to 404; keep a forwarding path for the domain, e.g. a " +
|
|
214
|
+
'routing rule { path: "/*" } on the service that should serve it.');
|
|
215
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { ARecord as CdkARecord, type GeoLocation, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
|
|
2
|
+
import { ARecord as CdkARecord, AaaaRecord as CdkAaaaRecord, 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
|
+
* Record-set type the alias deploys as: `"A"` (IPv4, the default — every
|
|
8
|
+
* pattern-internal alias predates this knob and is a deployed A record)
|
|
9
|
+
* or `"AAAA"` (IPv6). A declared AAAA alias MUST deploy as AAAA — mapping
|
|
10
|
+
* it to A silently answers IPv4 for an IPv6 declaration and collides with
|
|
11
|
+
* a legal dual-stack sibling in the DNS claim registry.
|
|
12
|
+
*/
|
|
13
|
+
readonly recordType?: "A" | "AAAA";
|
|
6
14
|
/**
|
|
7
15
|
* Latency routing region (CDK `RecordSetOptions.region`). Route53 requires
|
|
8
16
|
* a `setIdentifier` alongside it — callers derive one when unset.
|
|
@@ -27,7 +35,7 @@ export interface AliasRecordProps extends DnsRecordCommonProps {
|
|
|
27
35
|
readonly omitComment?: boolean;
|
|
28
36
|
}
|
|
29
37
|
export declare class AliasRecord extends Construct {
|
|
30
|
-
readonly record: CdkARecord;
|
|
38
|
+
readonly record: CdkARecord | CdkAaaaRecord;
|
|
31
39
|
readonly description: string;
|
|
32
40
|
readonly fqdn: string;
|
|
33
41
|
constructor(scope: Construct, id: string, props: AliasRecordProps);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { ARecord as CdkARecord, RecordTarget } from "aws-cdk-lib/aws-route53";
|
|
2
|
+
import { ARecord as CdkARecord, AaaaRecord as CdkAaaaRecord, RecordTarget } from "aws-cdk-lib/aws-route53";
|
|
3
3
|
import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn } from "./dnsRecordBase.js";
|
|
4
4
|
export class AliasRecord extends Construct {
|
|
5
5
|
record;
|
|
@@ -7,14 +7,17 @@ export class AliasRecord extends Construct {
|
|
|
7
7
|
fqdn;
|
|
8
8
|
constructor(scope, id, props) {
|
|
9
9
|
super(scope, id);
|
|
10
|
+
const recordType = props.recordType ?? "A";
|
|
10
11
|
this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
|
|
11
12
|
this.description =
|
|
12
13
|
props.description ?? defaultDnsComment("alias", this.fqdn);
|
|
13
|
-
// An alias record IS an A record set in Route53 — it claims
|
|
14
|
-
// an alias and a plain
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// An alias record IS an A (or AAAA) record set in Route53 — it claims
|
|
15
|
+
// its deployed type so an alias and a plain record of that type on the
|
|
16
|
+
// same name collide at synth, while dual-stack A + AAAA siblings
|
|
17
|
+
// register cleanly. The routing variant rides along: policy siblings
|
|
18
|
+
// with distinct setIdentifiers (e.g. a compute apex alias and a CDN
|
|
19
|
+
// apex alias during an ingress migration) are legal and register
|
|
20
|
+
// cleanly.
|
|
18
21
|
const routingPolicy = props.region !== undefined
|
|
19
22
|
? "latency"
|
|
20
23
|
: props.weight !== undefined
|
|
@@ -34,7 +37,7 @@ export class AliasRecord extends Construct {
|
|
|
34
37
|
.filter((part) => part !== undefined)
|
|
35
38
|
.join("/") || "*"
|
|
36
39
|
: undefined;
|
|
37
|
-
claimDnsRecord(this, props,
|
|
40
|
+
claimDnsRecord(this, props, recordType, this.fqdn, {
|
|
38
41
|
setIdentifier: props.setIdentifier,
|
|
39
42
|
routingPolicy,
|
|
40
43
|
region: props.region,
|
|
@@ -42,7 +45,9 @@ export class AliasRecord extends Construct {
|
|
|
42
45
|
});
|
|
43
46
|
// Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
|
|
44
47
|
// so we deliberately do not forward props.ttl to the underlying CDK resource.
|
|
45
|
-
|
|
48
|
+
// Both branches share the "Record" child id: an existing A alias's
|
|
49
|
+
// logical ID is byte-frozen, and a declared type is the ONLY delta.
|
|
50
|
+
const recordProps = {
|
|
46
51
|
zone: props.zone,
|
|
47
52
|
recordName: this.fqdn,
|
|
48
53
|
target: RecordTarget.fromAlias(props.target),
|
|
@@ -55,7 +60,11 @@ export class AliasRecord extends Construct {
|
|
|
55
60
|
...(props.setIdentifier !== undefined && {
|
|
56
61
|
setIdentifier: props.setIdentifier
|
|
57
62
|
})
|
|
58
|
-
}
|
|
63
|
+
};
|
|
64
|
+
this.record =
|
|
65
|
+
recordType === "AAAA"
|
|
66
|
+
? new CdkAaaaRecord(this, "Record", recordProps)
|
|
67
|
+
: new CdkARecord(this, "Record", recordProps);
|
|
59
68
|
applyDnsRecordTags(this, props);
|
|
60
69
|
}
|
|
61
70
|
}
|
|
@@ -46,11 +46,9 @@ export interface DnsRecordClaim {
|
|
|
46
46
|
*/
|
|
47
47
|
readonly recordType: string;
|
|
48
48
|
/**
|
|
49
|
-
* Routing-variant discriminator
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* presence alone makes a legal variant); a claim with neither is a simple
|
|
53
|
-
* record that tolerates no siblings.
|
|
49
|
+
* Routing-variant discriminator among sibling variants. Variant-ness
|
|
50
|
+
* itself is decided by `routingPolicy` alone (see {@link isVariantEntry});
|
|
51
|
+
* a claim without one is a simple record that tolerates no siblings.
|
|
54
52
|
*/
|
|
55
53
|
readonly setIdentifier?: string;
|
|
56
54
|
/** Policy type of a variant claim — siblings must all match. */
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { Token } from "aws-cdk-lib";
|
|
2
2
|
/**
|
|
3
|
-
* A claim is a policy variant when it declares a routing policy
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* classifying it as simple would refuse shapes Route53 holds happily.
|
|
8
|
-
*
|
|
9
|
-
* so
|
|
3
|
+
* A claim is a policy variant exactly when it declares a routing policy:
|
|
4
|
+
* CDK's RecordSet auto-generates a SetIdentifier whenever a policy
|
|
5
|
+
* (region/weight/geoLocation) is set without one, so a policy claim with no
|
|
6
|
+
* explicit setIdentifier still deploys as a legal sibling variant —
|
|
7
|
+
* classifying it as simple would refuse shapes Route53 holds happily. A
|
|
8
|
+
* setIdentifier WITHOUT a policy cannot deploy at all (CDK rejects it at
|
|
9
|
+
* synth), so it classifies as simple and the simple-vs-variant refusal —
|
|
10
|
+
* whose cure names the missing routing policy — fires first.
|
|
10
11
|
*/
|
|
11
12
|
function isVariantEntry(entry) {
|
|
12
|
-
return entry.routingPolicy !== undefined
|
|
13
|
+
return entry.routingPolicy !== undefined;
|
|
13
14
|
}
|
|
14
15
|
/** Claims per App root: claim key → claimant entries (variants share a key). */
|
|
15
16
|
let claimsByRoot = new WeakMap();
|
|
@@ -87,12 +88,8 @@ export function registerDnsRecordClaim(scope, claim) {
|
|
|
87
88
|
other.setIdentifier === entry.setIdentifier) {
|
|
88
89
|
throw collisionError(claim, recordType, other, claimantPath, `Both records carry setIdentifier '${String(entry.setIdentifier)}' — sibling routing-policy variants need distinct setIdentifiers.`);
|
|
89
90
|
}
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
// CDK rejects setIdentifier on simple records at synth).
|
|
93
|
-
if (other.routingPolicy !== undefined &&
|
|
94
|
-
entry.routingPolicy !== undefined &&
|
|
95
|
-
other.routingPolicy !== entry.routingPolicy) {
|
|
91
|
+
// Both sides passed isVariantEntry, so both declare a policy.
|
|
92
|
+
if (other.routingPolicy !== entry.routingPolicy) {
|
|
96
93
|
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
94
|
}
|
|
98
95
|
if (entry.routingPolicy === "latency" &&
|
|
@@ -8,6 +8,17 @@ export { DNS_APEX, getDomainExportNames, isManagedDomainBinding, type ManagedDom
|
|
|
8
8
|
* § Infrastructure Layer Boundaries).
|
|
9
9
|
*/
|
|
10
10
|
export declare function isWithinZone(candidate: string, zoneName: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Relative record label for `domain` within `zoneName` — the inverse of
|
|
13
|
+
* `resolveRecordFqdn` for names already known to sit inside the zone: the
|
|
14
|
+
* zone apex maps to the canonical apex label, sub-names drop the zone
|
|
15
|
+
* suffix. A name outside the zone passes through unchanged (callers
|
|
16
|
+
* validate zone membership with `isWithinZone` first). Single home shared
|
|
17
|
+
* by the patterns-layer CDN origin lane and the resources-layer ECS
|
|
18
|
+
* networking (lowest common layer per generator-standards § Infrastructure
|
|
19
|
+
* Layer Boundaries).
|
|
20
|
+
*/
|
|
21
|
+
export declare function recordLabelWithin(domain: string, zoneName: string): string;
|
|
11
22
|
/**
|
|
12
23
|
* Canonical BIND-semantics resolver for DNS record names — the single
|
|
13
24
|
* authority shared by the patterns-layer domain validation and the
|
|
@@ -14,6 +14,23 @@ export function isWithinZone(candidate, zoneName) {
|
|
|
14
14
|
}
|
|
15
15
|
return candidate.endsWith(`.${zoneName}`);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Relative record label for `domain` within `zoneName` — the inverse of
|
|
19
|
+
* `resolveRecordFqdn` for names already known to sit inside the zone: the
|
|
20
|
+
* zone apex maps to the canonical apex label, sub-names drop the zone
|
|
21
|
+
* suffix. A name outside the zone passes through unchanged (callers
|
|
22
|
+
* validate zone membership with `isWithinZone` first). Single home shared
|
|
23
|
+
* by the patterns-layer CDN origin lane and the resources-layer ECS
|
|
24
|
+
* networking (lowest common layer per generator-standards § Infrastructure
|
|
25
|
+
* Layer Boundaries).
|
|
26
|
+
*/
|
|
27
|
+
export function recordLabelWithin(domain, zoneName) {
|
|
28
|
+
if (domain === zoneName) {
|
|
29
|
+
return DNS_APEX;
|
|
30
|
+
}
|
|
31
|
+
const suffix = `.${zoneName}`;
|
|
32
|
+
return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
|
|
33
|
+
}
|
|
17
34
|
/**
|
|
18
35
|
* Canonical BIND-semantics resolver for DNS record names — the single
|
|
19
36
|
* authority shared by the patterns-layer domain validation and the
|
|
@@ -20,9 +20,18 @@
|
|
|
20
20
|
* layer (generator-standards § Infrastructure Layer Boundaries).
|
|
21
21
|
*/
|
|
22
22
|
import type { Node } from "constructs";
|
|
23
|
-
import type
|
|
23
|
+
import { type ManagedDomainBinding, type ManagedDomainCoverage, type ManagedDomainExports } from "@fjall/util";
|
|
24
24
|
export declare const MANAGED_DOMAIN_CONTEXT_PREFIX: "fjall:managedDomain:";
|
|
25
25
|
export declare function getManagedDomainContextKey(zoneName: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Companion channel to the binding (design 2026-08-18 cdn-app-origin, D5):
|
|
28
|
+
* the hostnames covered by exactly the certificates the same zone's binding
|
|
29
|
+
* names. A SEPARATE key on purpose — the binding parser above fails closed
|
|
30
|
+
* on unknown fields, so coverage could never ride the binding JSON without
|
|
31
|
+
* breaking older engines fed by a newer CLI.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX: "fjall:managedDomainCoverage:";
|
|
34
|
+
export declare function getManagedDomainCoverageContextKey(zoneName: string): string;
|
|
26
35
|
/**
|
|
27
36
|
* Read the CLI-injected {@link ManagedDomainBinding} for `domainName`,
|
|
28
37
|
* walking exact → parent zones (mirroring the CLI's
|
|
@@ -35,3 +44,51 @@ export declare function getManagedDomainContextKey(zoneName: string): string;
|
|
|
35
44
|
* `context` prefixes every error, e.g. `Static site 'marketing'`.
|
|
36
45
|
*/
|
|
37
46
|
export declare function readInjectedManagedDomainBinding(node: Node, domainName: string, context: string): ManagedDomainBinding | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Read the CLI-injected {@link ManagedDomainCoverage} for the zone a binding
|
|
49
|
+
* already resolved to. Exact-key lookup on purpose (no parent-zone walk):
|
|
50
|
+
* the CLI injects coverage for precisely the zones it injects bindings for,
|
|
51
|
+
* and the caller passes the binding's own `zoneName`.
|
|
52
|
+
*
|
|
53
|
+
* Absent → undefined (a domain stack that predates the hosts outputs, or a
|
|
54
|
+
* bare-CDK synth) — consumers treat that as coverage-unknown and warn, never
|
|
55
|
+
* as not-covered. A present-but-corrupt value still throws (repo posture:
|
|
56
|
+
* corrupt context fails the synth rather than falling through), but UNKNOWN
|
|
57
|
+
* FIELDS ARE IGNORED, unlike the binding parser: coverage is advisory
|
|
58
|
+
* validation input, and forward tolerance here is what lets a future CLI add
|
|
59
|
+
* coverage fields without breaking older engines — the exact trap that
|
|
60
|
+
* forced this channel off the binding JSON in the first place.
|
|
61
|
+
*/
|
|
62
|
+
export declare function readInjectedManagedDomainCoverage(node: Node, zoneName: string, context: string): ManagedDomainCoverage | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The one managed-domain identity a consumer acts on, resolved by the D2
|
|
65
|
+
* precedence every lane shares: an EXPLICIT `managedDomain` prop wins over
|
|
66
|
+
* the CLI-injected context binding (explicit beats injected); bare-CDK
|
|
67
|
+
* synth carries no context entry and yields undefined.
|
|
68
|
+
*/
|
|
69
|
+
export interface EffectiveManagedDomain {
|
|
70
|
+
managed: ManagedDomainBinding | ManagedDomainExports;
|
|
71
|
+
zoneName: string;
|
|
72
|
+
/**
|
|
73
|
+
* Zone id ready for `HostedZone.fromHostedZoneAttributes`: the binding's
|
|
74
|
+
* literal (crosses accounts and regions), or an `Fn.importValue` token on
|
|
75
|
+
* the exports form (same-account, same-region only).
|
|
76
|
+
*/
|
|
77
|
+
hostedZoneId: string;
|
|
78
|
+
/**
|
|
79
|
+
* True when the identity is an EXPLICIT binding prop. An explicit binding
|
|
80
|
+
* may pin certificate ARNs older than the zone's current ones, so the
|
|
81
|
+
* injected coverage context — which describes the CURRENT certificates —
|
|
82
|
+
* must not be applied to it (the D5 provenance gate): folding it would
|
|
83
|
+
* over-claim coverage and invert P3. Injected bindings and the exports
|
|
84
|
+
* form (which resolves at deploy to the current certificate) are safe.
|
|
85
|
+
*/
|
|
86
|
+
explicitPinnedBinding: boolean;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the effective managed-domain identity for `domainName` — the
|
|
90
|
+
* SINGLE home of the explicit-beats-injected precedence and of the
|
|
91
|
+
* binding-vs-exports zone-id branch, shared by the resources layer
|
|
92
|
+
* (`ecsNetworking.addHostedZone`) and the patterns layer (`patternDomain`).
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveEffectiveManagedDomain(node: Node, explicit: ManagedDomainBinding | ManagedDomainExports | undefined, domainName: string, context: string): EffectiveManagedDomain | undefined;
|
|
@@ -19,10 +19,23 @@
|
|
|
19
19
|
* (`patternDomain.resolvePatternZone`), and utils is their lowest common
|
|
20
20
|
* layer (generator-standards § Infrastructure Layer Boundaries).
|
|
21
21
|
*/
|
|
22
|
+
import { Fn } from "aws-cdk-lib";
|
|
23
|
+
import { isManagedDomainBinding } from "@fjall/util";
|
|
22
24
|
export const MANAGED_DOMAIN_CONTEXT_PREFIX = "fjall:managedDomain:";
|
|
23
25
|
export function getManagedDomainContextKey(zoneName) {
|
|
24
26
|
return `${MANAGED_DOMAIN_CONTEXT_PREFIX}${zoneName}`;
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Companion channel to the binding (design 2026-08-18 cdn-app-origin, D5):
|
|
30
|
+
* the hostnames covered by exactly the certificates the same zone's binding
|
|
31
|
+
* names. A SEPARATE key on purpose — the binding parser above fails closed
|
|
32
|
+
* on unknown fields, so coverage could never ride the binding JSON without
|
|
33
|
+
* breaking older engines fed by a newer CLI.
|
|
34
|
+
*/
|
|
35
|
+
export const MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX = "fjall:managedDomainCoverage:";
|
|
36
|
+
export function getManagedDomainCoverageContextKey(zoneName) {
|
|
37
|
+
return `${MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX}${zoneName}`;
|
|
38
|
+
}
|
|
26
39
|
const REQUIRED_STRING_FIELDS = ["zoneName", "hostedZoneId"];
|
|
27
40
|
const OPTIONAL_STRING_FIELDS = [
|
|
28
41
|
"certificateArn",
|
|
@@ -56,6 +69,89 @@ export function readInjectedManagedDomainBinding(node, domainName, context) {
|
|
|
56
69
|
}
|
|
57
70
|
return undefined;
|
|
58
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Read the CLI-injected {@link ManagedDomainCoverage} for the zone a binding
|
|
74
|
+
* already resolved to. Exact-key lookup on purpose (no parent-zone walk):
|
|
75
|
+
* the CLI injects coverage for precisely the zones it injects bindings for,
|
|
76
|
+
* and the caller passes the binding's own `zoneName`.
|
|
77
|
+
*
|
|
78
|
+
* Absent → undefined (a domain stack that predates the hosts outputs, or a
|
|
79
|
+
* bare-CDK synth) — consumers treat that as coverage-unknown and warn, never
|
|
80
|
+
* as not-covered. A present-but-corrupt value still throws (repo posture:
|
|
81
|
+
* corrupt context fails the synth rather than falling through), but UNKNOWN
|
|
82
|
+
* FIELDS ARE IGNORED, unlike the binding parser: coverage is advisory
|
|
83
|
+
* validation input, and forward tolerance here is what lets a future CLI add
|
|
84
|
+
* coverage fields without breaking older engines — the exact trap that
|
|
85
|
+
* forced this channel off the binding JSON in the first place.
|
|
86
|
+
*/
|
|
87
|
+
export function readInjectedManagedDomainCoverage(node, zoneName, context) {
|
|
88
|
+
const key = getManagedDomainCoverageContextKey(zoneName);
|
|
89
|
+
const raw = node.tryGetContext(key);
|
|
90
|
+
if (raw === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
let value = raw;
|
|
93
|
+
if (typeof raw === "string") {
|
|
94
|
+
try {
|
|
95
|
+
value = JSON.parse(raw);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error(`${context}: CDK context '${key}' is not valid JSON (got '${raw}'). ` +
|
|
99
|
+
"The value must be a JSON ManagedDomainCoverage " +
|
|
100
|
+
"({ certificateHosts?, usEast1CertificateHosts? }) — re-run the " +
|
|
101
|
+
"deploy through the Fjall CLI, or correct the hand-set context " +
|
|
102
|
+
"entry.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
106
|
+
throw new Error(`${context}: CDK context '${key}' must be a JSON object ` +
|
|
107
|
+
`ManagedDomainCoverage (got ${JSON.stringify(value)}). Re-run the ` +
|
|
108
|
+
"deploy through the Fjall CLI, or correct the hand-set context entry.");
|
|
109
|
+
}
|
|
110
|
+
const record = value;
|
|
111
|
+
const coverage = {};
|
|
112
|
+
for (const field of [
|
|
113
|
+
"certificateHosts",
|
|
114
|
+
"usEast1CertificateHosts"
|
|
115
|
+
]) {
|
|
116
|
+
const fieldValue = record[field];
|
|
117
|
+
if (fieldValue === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
// An empty array is corruption, never data: a real certificate always
|
|
120
|
+
// covers at least its domain name, and the CLI never emits []. Letting
|
|
121
|
+
// it through would read as an enumerated "covers nothing" fact and turn
|
|
122
|
+
// an UNKNOWN coverage state into a definite (false) not-covered verdict.
|
|
123
|
+
if (!Array.isArray(fieldValue) ||
|
|
124
|
+
fieldValue.length === 0 ||
|
|
125
|
+
fieldValue.some((h) => typeof h !== "string" || h === "")) {
|
|
126
|
+
throw new Error(`${context}: CDK context '${key}' field '${field}' must be a ` +
|
|
127
|
+
`non-empty array of non-empty strings when present (got ` +
|
|
128
|
+
`${JSON.stringify(fieldValue)}). Re-run the deploy through the ` +
|
|
129
|
+
"Fjall CLI, or correct the hand-set context entry.");
|
|
130
|
+
}
|
|
131
|
+
coverage[field] = fieldValue;
|
|
132
|
+
}
|
|
133
|
+
return coverage;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the effective managed-domain identity for `domainName` — the
|
|
137
|
+
* SINGLE home of the explicit-beats-injected precedence and of the
|
|
138
|
+
* binding-vs-exports zone-id branch, shared by the resources layer
|
|
139
|
+
* (`ecsNetworking.addHostedZone`) and the patterns layer (`patternDomain`).
|
|
140
|
+
*/
|
|
141
|
+
export function resolveEffectiveManagedDomain(node, explicit, domainName, context) {
|
|
142
|
+
const managed = explicit ?? readInjectedManagedDomainBinding(node, domainName, context);
|
|
143
|
+
if (managed === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
const isBinding = isManagedDomainBinding(managed);
|
|
146
|
+
return {
|
|
147
|
+
managed,
|
|
148
|
+
zoneName: managed.zoneName,
|
|
149
|
+
hostedZoneId: isBinding
|
|
150
|
+
? managed.hostedZoneId
|
|
151
|
+
: Fn.importValue(managed.hostedZoneIdExport),
|
|
152
|
+
explicitPinnedBinding: explicit !== undefined && isBinding
|
|
153
|
+
};
|
|
154
|
+
}
|
|
59
155
|
function parseManagedDomainBinding(raw, zoneName, key, context) {
|
|
60
156
|
let value = raw;
|
|
61
157
|
if (typeof raw === "string") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.3.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.3.0",
|
|
84
|
+
"@fjall/util": "^14.3.0",
|
|
85
85
|
"constructs": "^10.7.2"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|