@fjall/components-infrastructure 14.1.0 → 14.2.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 +55 -1
- package/dist/lib/patterns/aws/cdn.js +216 -48
- package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +74 -0
- package/dist/lib/patterns/aws/cdnAppOrigin.js +251 -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/domainCertificateComposer.js +69 -0
- package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -0
- 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 +76 -15
- package/dist/lib/resources/aws/compute/ingressProfile.d.ts +174 -0
- package/dist/lib/resources/aws/compute/ingressProfile.js +195 -0
- package/dist/lib/utils/managedDomainContext.d.ts +26 -1
- package/dist/lib/utils/managedDomainContext.js +69 -0
- package/package.json +3 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECS construct-reference origin resolution for the `Cdn` pattern (design
|
|
3
|
+
* 2026-08-18 cdn-app-origin, D3). Given a fronted ECS compute, resolve a
|
|
4
|
+
* TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile, decide
|
|
5
|
+
* who owns its DNS record (P1: the construct that declares a hostname owns
|
|
6
|
+
* its record — a `routing[].host` hostname is the compute's; a derived or
|
|
7
|
+
* free hostname is the Cdn's), and validate certificate coverage and
|
|
8
|
+
* listener forwarding at synth — fail closed where decidable, warn where
|
|
9
|
+
* opaque or partial (P3).
|
|
10
|
+
*
|
|
11
|
+
* The resolver is memoized per compute within one `Cdn`: the default origin
|
|
12
|
+
* and every behaviour referencing the same compute share one resolution and
|
|
13
|
+
* at most one record.
|
|
14
|
+
*/
|
|
15
|
+
import { Token } from "aws-cdk-lib";
|
|
16
|
+
import { certificateCovers, forwardingVerdict, normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
|
|
17
|
+
export { normaliseDnsName };
|
|
18
|
+
import { DNS_APEX } from "@fjall/util";
|
|
19
|
+
import { isWithinZone } from "../../utils/domainTypes.js";
|
|
20
|
+
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
21
|
+
/**
|
|
22
|
+
* Relative record label for `domain` within `zoneName` (the staticSite
|
|
23
|
+
* `recordLabelFor` convention): the zone apex maps to the canonical apex
|
|
24
|
+
* label, sub-names drop the zone suffix.
|
|
25
|
+
*/
|
|
26
|
+
export function recordLabelWithin(domain, zoneName) {
|
|
27
|
+
if (domain === zoneName) {
|
|
28
|
+
return DNS_APEX;
|
|
29
|
+
}
|
|
30
|
+
const suffix = `.${zoneName}`;
|
|
31
|
+
return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
|
|
32
|
+
}
|
|
33
|
+
const SHARED_CURE = "declare the hostname as a routing host on the receiving service " +
|
|
34
|
+
"(services[].routing[].host) — one declaration adds the certificate SAN " +
|
|
35
|
+
"(cluster-minted certificates), the listener rule, and the DNS record. " +
|
|
36
|
+
"CAUTION: adding a SECOND routing rule to a service flips the listener " +
|
|
37
|
+
"default action from forward to fixed-404, so a cluster serving its " +
|
|
38
|
+
"domain through the default action must keep a forwarding path for it, " +
|
|
39
|
+
'e.g. routing: [{ path: "/*" }, { host: "<origin hostname>" }].';
|
|
40
|
+
export class CdnEcsOriginResolver {
|
|
41
|
+
options;
|
|
42
|
+
resolutions = new Map();
|
|
43
|
+
warnedUnknownCoverage = false;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.options = options;
|
|
46
|
+
}
|
|
47
|
+
resolve(compute) {
|
|
48
|
+
const memoized = this.resolutions.get(compute);
|
|
49
|
+
if (memoized !== undefined) {
|
|
50
|
+
return memoized;
|
|
51
|
+
}
|
|
52
|
+
const resolution = this.resolveFresh(compute);
|
|
53
|
+
this.resolutions.set(compute, resolution);
|
|
54
|
+
return resolution;
|
|
55
|
+
}
|
|
56
|
+
/** Every Cdn-owned record the resolutions so far require, one per compute. */
|
|
57
|
+
getRecordPlans() {
|
|
58
|
+
const plans = [];
|
|
59
|
+
for (const resolution of this.resolutions.values()) {
|
|
60
|
+
if (resolution.recordPlan !== undefined) {
|
|
61
|
+
plans.push(resolution.recordPlan);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return plans;
|
|
65
|
+
}
|
|
66
|
+
resolveFresh(compute) {
|
|
67
|
+
const { cdnId } = this.options;
|
|
68
|
+
const profile = compute.getIngressProfile();
|
|
69
|
+
if (profile === undefined) {
|
|
70
|
+
// E1 — pre-existing shape, kept: a cluster with no ALB cannot origin.
|
|
71
|
+
throw new Error(`CDN '${cdnId}': the ECS compute origin has no load balancer. ` +
|
|
72
|
+
"Enable loadBalancer in the compute configuration, or front a " +
|
|
73
|
+
"different resource.");
|
|
74
|
+
}
|
|
75
|
+
if (profile.internal) {
|
|
76
|
+
// E2 — CloudFront has no path into a VPC-internal ALB.
|
|
77
|
+
throw new Error(`CDN '${cdnId}': the ECS compute origin's load balancer is internal ` +
|
|
78
|
+
"— CloudFront reaches origins over the public internet and cannot " +
|
|
79
|
+
"address an internal ALB. Make the cluster's load balancer " +
|
|
80
|
+
'internet-facing (drop loadBalancer: "internal"), or front a ' +
|
|
81
|
+
"different resource.");
|
|
82
|
+
}
|
|
83
|
+
const { hostname, computeOwnsRecord } = this.selectHostname(compute, profile);
|
|
84
|
+
// Origin-loop guard (C4, extended): the resolved hostname must not be
|
|
85
|
+
// one of the distribution's own alias names.
|
|
86
|
+
if (this.options.aliasNames.has(normaliseDnsName(hostname))) {
|
|
87
|
+
throw new Error(`CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
|
|
88
|
+
"of the distribution's own domain names — once DNS points that " +
|
|
89
|
+
"name at the distribution, every request loops CloudFront → " +
|
|
90
|
+
"CloudFront. Pick a dedicated origin hostname (originHostname), " +
|
|
91
|
+
"or serve the alias from the compute directly.");
|
|
92
|
+
}
|
|
93
|
+
if (profile.listenerPort === 80) {
|
|
94
|
+
// E4 — no certificate anywhere: the listener serves HTTP only, and a
|
|
95
|
+
// CloudFront origin fetch over HTTPS has nothing to shake hands with.
|
|
96
|
+
throw new Error(`CDN '${cdnId}': the ECS compute origin's listener serves HTTP only ` +
|
|
97
|
+
"(the cluster has no domain, so no certificate resolved). A " +
|
|
98
|
+
"CloudFront origin needs a TLS-valid HTTPS origin. Give the " +
|
|
99
|
+
"cluster a domain (cluster.domainConfig), or use originType " +
|
|
100
|
+
'"alb" with protocolPolicy "HTTP_ONLY" if plaintext origin ' +
|
|
101
|
+
"traffic is acceptable.");
|
|
102
|
+
}
|
|
103
|
+
// Record-ownership feasibility BEFORE coverage/forwarding (design D3
|
|
104
|
+
// step 3): an out-of-zone hostname's only real cures are an in-zone
|
|
105
|
+
// name or originRecord: "none" — deciding coverage first would hand the
|
|
106
|
+
// user E6's routing-host cure, which ECS itself refuses out-of-zone.
|
|
107
|
+
let recordPlan;
|
|
108
|
+
if (!computeOwnsRecord && this.effectiveOriginRecord(compute) !== "none") {
|
|
109
|
+
// The Cdn owns the record (P1). Zone facts are guaranteed here: a
|
|
110
|
+
// 443 listener implies a domain, which implies zone identity.
|
|
111
|
+
if (profile.hostedZone === undefined ||
|
|
112
|
+
profile.zoneName === undefined ||
|
|
113
|
+
!isWithinZone(hostname, profile.zoneName)) {
|
|
114
|
+
// E5 — a record we cannot mint: outside the cluster's zone.
|
|
115
|
+
throw new Error(`CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
|
|
116
|
+
`origin's hosted zone ('${profile.zoneName ?? "none"}'), so its ` +
|
|
117
|
+
"record cannot be minted here. Use a hostname inside the zone, " +
|
|
118
|
+
'or set originRecord: "none" and manage the record where the ' +
|
|
119
|
+
"zone lives.");
|
|
120
|
+
}
|
|
121
|
+
recordPlan = {
|
|
122
|
+
hostname,
|
|
123
|
+
hostedZone: profile.hostedZone,
|
|
124
|
+
zoneName: profile.zoneName,
|
|
125
|
+
recordName: recordLabelWithin(hostname, profile.zoneName),
|
|
126
|
+
loadBalancer: profile.loadBalancer
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const coverage = certificateCovers(profile.certificateCoverage, hostname);
|
|
130
|
+
if (coverage === "not-covered") {
|
|
131
|
+
// E6 — provably uncovered: every attached certificate is enumerated
|
|
132
|
+
// and none matches. TLS to the origin fails on every request.
|
|
133
|
+
throw new Error(`CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
|
|
134
|
+
`'${hostname}' (attached certificates cover: ` +
|
|
135
|
+
`${profile.certificateCoverage.kind === "unknown" ? "unknown" : profile.certificateCoverage.hostnames.join(", ") || "nothing"}). ` +
|
|
136
|
+
`Every origin fetch would fail TLS validation. Cure: ${SHARED_CURE}`);
|
|
137
|
+
}
|
|
138
|
+
if (coverage === "unknown" && !this.warnedUnknownCoverage) {
|
|
139
|
+
this.warnedUnknownCoverage = true;
|
|
140
|
+
// W1 — opaque certificates: not decidable at synth, so warn once.
|
|
141
|
+
FjallLogger.warn(`CDN '${cdnId}': cannot verify that the ECS origin's listener ` +
|
|
142
|
+
`certificates cover '${hostname}' (imported certificate ARNs are ` +
|
|
143
|
+
"opaque at synth). If the cluster uses a managed domain, redeploy " +
|
|
144
|
+
"the domain stack with engine >= 14.2.0 so it publishes " +
|
|
145
|
+
"certificate-coverage outputs and this becomes checkable; for " +
|
|
146
|
+
"imported certificates, verify the SANs cover the hostname — TLS " +
|
|
147
|
+
"fails at runtime if not.");
|
|
148
|
+
}
|
|
149
|
+
const verdict = forwardingVerdict(profile, hostname);
|
|
150
|
+
if (verdict.verdict === "none") {
|
|
151
|
+
// E7 — the listener's default action is a fixed 404 and no rule
|
|
152
|
+
// forwards the hostname: every origin fetch answers 404.
|
|
153
|
+
throw new Error(`CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
|
|
154
|
+
`'${hostname}' — its default action is a fixed 404 and no routing ` +
|
|
155
|
+
`rule forwards that hostname. Cure: ${SHARED_CURE}`);
|
|
156
|
+
}
|
|
157
|
+
if (verdict.verdict === "partial") {
|
|
158
|
+
// W2 — some paths forward, others hit the fixed-404 default.
|
|
159
|
+
FjallLogger.warn(`CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
|
|
160
|
+
`some paths (matching rules: ${verdict.patterns.join("; ")}); ` +
|
|
161
|
+
"requests outside those patterns answer the listener's fixed-404 " +
|
|
162
|
+
"default. Verify the patterns against the distribution's " +
|
|
163
|
+
"behaviours.");
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
hostname,
|
|
167
|
+
// The hostname is certificate-covered, so the resource layer's
|
|
168
|
+
// HTTPS_ONLY default is exactly right — no protocol override.
|
|
169
|
+
originConfig: { type: "http", domainName: hostname },
|
|
170
|
+
...(recordPlan !== undefined && { recordPlan }),
|
|
171
|
+
computeOwnsRecord
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
explicitHostnameFor(compute) {
|
|
175
|
+
return this.options.overrides?.get(compute)?.originHostname;
|
|
176
|
+
}
|
|
177
|
+
effectiveOriginRecord(compute) {
|
|
178
|
+
return this.options.overrides?.get(compute)?.originRecord;
|
|
179
|
+
}
|
|
180
|
+
selectHostname(compute, profile) {
|
|
181
|
+
const { cdnId } = this.options;
|
|
182
|
+
const explicit = this.explicitHostnameFor(compute);
|
|
183
|
+
const originRecord = this.effectiveOriginRecord(compute);
|
|
184
|
+
if (explicit !== undefined) {
|
|
185
|
+
if (Token.isUnresolved(explicit)) {
|
|
186
|
+
throw new Error(`CDN '${cdnId}': originHostname must be a literal hostname (got ` +
|
|
187
|
+
"an unresolved token). Coverage, forwarding, and the origin " +
|
|
188
|
+
"record are synth-time decisions — pass the concrete name.");
|
|
189
|
+
}
|
|
190
|
+
// DNS names are case-insensitive (RFC 4343): every membership test
|
|
191
|
+
// compares normalised, so a case-differing spelling cannot slip past
|
|
192
|
+
// the redirect/ownership guards the way it cannot slip past ACM.
|
|
193
|
+
const explicitNormalised = normaliseDnsName(explicit);
|
|
194
|
+
if (profile.redirectHosts.some((host) => normaliseDnsName(host) === explicitNormalised)) {
|
|
195
|
+
// E10 — redirect hosts never serve traffic: the listener answers
|
|
196
|
+
// them with a 301 to the cluster domain, so an origin pointed there
|
|
197
|
+
// loops every viewer request through CloudFront → 301 → CloudFront.
|
|
198
|
+
throw new Error(`CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
|
|
199
|
+
"entry on the ECS origin — the listener 301s it to " +
|
|
200
|
+
`'${profile.domainName ?? "the cluster domain"}' and it never ` +
|
|
201
|
+
"serves traffic. Use a served hostname: a routing host, or the " +
|
|
202
|
+
"derived origin name (omit originHostname).");
|
|
203
|
+
}
|
|
204
|
+
const computeOwnsRecord = profile.routedHosts.some((host) => normaliseDnsName(host) === explicitNormalised);
|
|
205
|
+
if (computeOwnsRecord && originRecord !== undefined) {
|
|
206
|
+
// E8 — the compute already owns that hostname's record.
|
|
207
|
+
throw new Error(`CDN '${cdnId}': originRecord is set, but originHostname ` +
|
|
208
|
+
`'${explicit}' is a routing host — the compute already owns its ` +
|
|
209
|
+
"record (P1: the construct that declares a hostname owns its " +
|
|
210
|
+
"record). Drop originRecord.");
|
|
211
|
+
}
|
|
212
|
+
return { hostname: explicit, computeOwnsRecord };
|
|
213
|
+
}
|
|
214
|
+
// Auto-resolution: routed hosts that are not the distribution's own
|
|
215
|
+
// aliases. (Redirect hosts can never appear — ECS enforces
|
|
216
|
+
// serve-XOR-redirect at synth.)
|
|
217
|
+
const candidates = profile.routedHosts.filter((host) => !this.options.aliasNames.has(normaliseDnsName(host)));
|
|
218
|
+
if (candidates.length === 1 && candidates[0] !== undefined) {
|
|
219
|
+
if (originRecord !== undefined) {
|
|
220
|
+
// E8 — same ownership rule as the explicit arm.
|
|
221
|
+
throw new Error(`CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
|
|
222
|
+
`routing host '${candidates[0]}' — the compute already owns its ` +
|
|
223
|
+
"record. Drop originRecord.");
|
|
224
|
+
}
|
|
225
|
+
return { hostname: candidates[0], computeOwnsRecord: true };
|
|
226
|
+
}
|
|
227
|
+
if (candidates.length > 1) {
|
|
228
|
+
// E3 — ambiguous: several served hostnames could be the origin.
|
|
229
|
+
throw new Error(`CDN '${cdnId}': the ECS origin serves several hostnames ` +
|
|
230
|
+
`(${candidates.join(", ")}) and the origin is ambiguous. Set ` +
|
|
231
|
+
"originHostname — on the distribution for its default origin, or " +
|
|
232
|
+
"on the behaviour entry — to the one the distribution should " +
|
|
233
|
+
"fetch from.");
|
|
234
|
+
}
|
|
235
|
+
if (profile.domainName === undefined) {
|
|
236
|
+
// E4 — nothing to derive from (and nothing a viewer could reach over
|
|
237
|
+
// TLS anyway; the no-domain listener is HTTP-only, so an explicit
|
|
238
|
+
// originHostname would only trade this error for the port-80 one —
|
|
239
|
+
// the catalogue cures are the honest ones).
|
|
240
|
+
throw new Error(`CDN '${cdnId}': the ECS compute origin has no domain to derive an ` +
|
|
241
|
+
"origin hostname from (and no certificate, so nothing serves " +
|
|
242
|
+
"HTTPS). Give the cluster a domain (cluster.domainConfig), or " +
|
|
243
|
+
'use originType "alb" with protocolPolicy "HTTP_ONLY" if ' +
|
|
244
|
+
"plaintext origin traffic is acceptable.");
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
hostname: `origin.${profile.domainName}`,
|
|
248
|
+
computeOwnsRecord: false
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -5,7 +5,7 @@ import { type IApplicationLoadBalancer, type ApplicationListener } from "aws-cdk
|
|
|
5
5
|
import { Construct } from "constructs";
|
|
6
6
|
import { type IEcsCompute } from "./interfaces/compute.js";
|
|
7
7
|
import { type SecretImport } from "../../resources/aws/secrets/index.js";
|
|
8
|
-
import EcsCluster, { type EcsClusterProps } from "../../resources/aws/compute/ecs.js";
|
|
8
|
+
import EcsCluster, { type EcsClusterProps, type EcsIngressProfile } from "../../resources/aws/compute/ecs.js";
|
|
9
9
|
export { ScalingType } from "./computeEcsTypes.js";
|
|
10
10
|
export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsServiceConfig, ServiceLogAlarm, EcsComputeProps } from "./computeEcsTypes.js";
|
|
11
11
|
import { ScalingType, type EcsCapacityProviderConfig, type EcsCapacityProvider, type EcsContainerConfig, type EcsScalingConfig, type EcsServiceConfig, type EcsComputeProps, type QueueScalingConfig } from "./computeEcsTypes.js";
|
|
@@ -227,6 +227,14 @@ export declare class EcsCompute extends Construct implements IEcsCompute {
|
|
|
227
227
|
getCluster(): ICluster;
|
|
228
228
|
/** Get the Application Load Balancer if one was created. */
|
|
229
229
|
getLoadBalancer(): IApplicationLoadBalancer | undefined;
|
|
230
|
+
/**
|
|
231
|
+
* Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
|
|
232
|
+
* listener facts, zone identity, and certificate coverage for constructs
|
|
233
|
+
* that front this compute — the `Cdn` construct-reference origin lane
|
|
234
|
+
* resolves its origin hostname against it. Undefined when the compute has
|
|
235
|
+
* no load balancer.
|
|
236
|
+
*/
|
|
237
|
+
getIngressProfile(): EcsIngressProfile | undefined;
|
|
230
238
|
/** Get a specific service by name. */
|
|
231
239
|
getService(name: string): IBaseService | undefined;
|
|
232
240
|
/** Get all services in the cluster. */
|
|
@@ -1653,6 +1653,16 @@ export class EcsCompute extends Construct {
|
|
|
1653
1653
|
getLoadBalancer() {
|
|
1654
1654
|
return this.ecsCluster.getLoadBalancer();
|
|
1655
1655
|
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
|
|
1658
|
+
* listener facts, zone identity, and certificate coverage for constructs
|
|
1659
|
+
* that front this compute — the `Cdn` construct-reference origin lane
|
|
1660
|
+
* resolves its origin hostname against it. Undefined when the compute has
|
|
1661
|
+
* no load balancer.
|
|
1662
|
+
*/
|
|
1663
|
+
getIngressProfile() {
|
|
1664
|
+
return this.ecsCluster.getIngressProfile();
|
|
1665
|
+
}
|
|
1656
1666
|
/** Get a specific service by name. */
|
|
1657
1667
|
getService(name) {
|
|
1658
1668
|
return this.ecsCluster.getService(name);
|
|
@@ -3,6 +3,7 @@ import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
|
|
|
3
3
|
import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
|
|
4
4
|
import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
|
|
5
5
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
6
|
+
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
6
7
|
const US_EAST_1 = "us-east-1";
|
|
7
8
|
/**
|
|
8
9
|
* Certificate composition shared by `composeApexDomain` and
|
|
@@ -42,6 +43,21 @@ export function composeDomainCertificates(scope, composition) {
|
|
|
42
43
|
costAllocationEnvironment: composition.costAllocationEnvironment,
|
|
43
44
|
costAllocationDomain: composition.costAllocationDomain
|
|
44
45
|
});
|
|
46
|
+
// D5 (design 2026-08-18 cdn-app-origin): publish the hostnames this
|
|
47
|
+
// certificate covers beside its ARN export — SAME per-certificate
|
|
48
|
+
// identity, so a consumer can bind coverage for exactly the
|
|
49
|
+
// certificates whose ARNs it binds, never a zone-level aggregate
|
|
50
|
+
// (which would list hostnames of certificates that are not on the
|
|
51
|
+
// consumer's listener and over-claim coverage).
|
|
52
|
+
const serialisedHosts = JSON.stringify(certificateHosts(normalised));
|
|
53
|
+
if (hostsOutputWithinCfnLimit(serialisedHosts, normalised.domainName)) {
|
|
54
|
+
new CfnOutput(scope, `${certId}Hosts`, {
|
|
55
|
+
key: `${certId}Hosts`,
|
|
56
|
+
value: serialisedHosts,
|
|
57
|
+
exportName: getDomainExportNames(normalised.domainName)
|
|
58
|
+
.certificateHosts
|
|
59
|
+
});
|
|
60
|
+
}
|
|
45
61
|
certificates.set(normalised.domainName, dc.certificate);
|
|
46
62
|
return;
|
|
47
63
|
}
|
|
@@ -80,6 +96,7 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
|
|
|
80
96
|
exportCertificateArn: false
|
|
81
97
|
});
|
|
82
98
|
emitUsEast1Export(scope, composition, dc);
|
|
99
|
+
emitUsEast1HostsOutput(scope, composition, cert);
|
|
83
100
|
return dc.certificate;
|
|
84
101
|
}
|
|
85
102
|
const pairedStack = resolvePairedUsEast1Stack(domainStack, composition, cert);
|
|
@@ -104,8 +121,60 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
|
|
|
104
121
|
exportCertificateArn: false
|
|
105
122
|
});
|
|
106
123
|
emitUsEast1Export(pairedStack, composition, dc);
|
|
124
|
+
// The hosts output lands on the MAIN domain stack even though the
|
|
125
|
+
// certificate lives in the paired stack: the SAN list is a synth-time
|
|
126
|
+
// literal (no cross-region reference), and the CLI's existing main-stack
|
|
127
|
+
// DescribeStacks read must reach it without touching the paired-stack
|
|
128
|
+
// reader (design 2026-08-18 cdn-app-origin, D5).
|
|
129
|
+
emitUsEast1HostsOutput(domainStack, composition, cert);
|
|
107
130
|
return dc.certificate;
|
|
108
131
|
}
|
|
132
|
+
/** Hostnames a certificate covers: its domainName plus every SAN, deduped. */
|
|
133
|
+
function certificateHosts(cert) {
|
|
134
|
+
return [
|
|
135
|
+
...new Set([cert.domainName, ...(cert.subjectAlternativeNames ?? [])])
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Guard on CloudFormation's 1024-byte output-value limit (1000-byte
|
|
140
|
+
* threshold for headroom under the hard cap): a certificate with enough —
|
|
141
|
+
* or long enough — SANs would otherwise fail the WHOLE domain-stack deploy
|
|
142
|
+
* with an opaque CFN error. Per the D5 fail-open contract the hosts output
|
|
143
|
+
* is SKIPPED instead: the deploy keeps succeeding, and consumers see
|
|
144
|
+
* coverage-unknown for this certificate (W1 — its coverage stays
|
|
145
|
+
* unverifiable at CDN synth), never a false verdict.
|
|
146
|
+
*/
|
|
147
|
+
const HOSTS_OUTPUT_MAX_BYTES = 1000;
|
|
148
|
+
function hostsOutputWithinCfnLimit(serialisedHosts, certificateDomainName) {
|
|
149
|
+
const bytes = Buffer.byteLength(serialisedHosts, "utf8");
|
|
150
|
+
if (bytes <= HOSTS_OUTPUT_MAX_BYTES)
|
|
151
|
+
return true;
|
|
152
|
+
FjallLogger.warn(`Certificate '${certificateDomainName}': its hosts list serialises to ` +
|
|
153
|
+
`${bytes} bytes, over the ${HOSTS_OUTPUT_MAX_BYTES}-byte guard for ` +
|
|
154
|
+
"CloudFormation's 1024-byte output-value limit, so its " +
|
|
155
|
+
"certificate-hosts output is skipped and the certificate's coverage " +
|
|
156
|
+
"stays unverifiable at CDN synth (W1) — the deploy itself keeps " +
|
|
157
|
+
"succeeding. Trim the certificate's SAN list, or split the hosts " +
|
|
158
|
+
"across multiple certificates.");
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The zone-level `<zone>-us-east-1-certificate-hosts` output (D5 companion
|
|
163
|
+
* to `emitUsEast1Export`): the hostnames the viewer certificate covers,
|
|
164
|
+
* always minted on the MAIN domain stack so the D2 DescribeStacks read
|
|
165
|
+
* reaches it in one call regardless of where the certificate itself lives.
|
|
166
|
+
*/
|
|
167
|
+
function emitUsEast1HostsOutput(scope, composition, cert) {
|
|
168
|
+
const serialisedHosts = JSON.stringify(certificateHosts(cert));
|
|
169
|
+
if (!hostsOutputWithinCfnLimit(serialisedHosts, cert.domainName))
|
|
170
|
+
return;
|
|
171
|
+
const exports = getDomainExportNames(composition.effectiveZoneName);
|
|
172
|
+
new CfnOutput(scope, `${composition.safeZone}UsEast1CertificateHosts`, {
|
|
173
|
+
key: `${composition.safeZone}UsEast1CertificateHosts`,
|
|
174
|
+
value: serialisedHosts,
|
|
175
|
+
exportName: exports.usEast1CertificateHosts
|
|
176
|
+
});
|
|
177
|
+
}
|
|
109
178
|
/**
|
|
110
179
|
* The zone-level `<zone>-us-east-1-certificate-arn` export
|
|
111
180
|
* (`getDomainExportNames(...).usEast1CertificateArn` — the SSOT
|
|
@@ -19,6 +19,7 @@ import { type IAutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
|
|
|
19
19
|
import { type ISecurityGroup, type IConnectable } from "aws-cdk-lib/aws-ec2";
|
|
20
20
|
import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
|
|
21
21
|
import { type Construct } from "constructs";
|
|
22
|
+
import type { EcsIngressProfile } from "../../../resources/aws/compute/ingressProfile.js";
|
|
22
23
|
/**
|
|
23
24
|
* Compute type discriminator.
|
|
24
25
|
* Used to determine which specific interface applies.
|
|
@@ -66,6 +67,12 @@ export interface IEcsCompute extends ICompute, IConnectable {
|
|
|
66
67
|
* for HTTPS, 80 for HTTP); does not indicate protocol.
|
|
67
68
|
*/
|
|
68
69
|
getPrimaryListenerPort(): number | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
|
|
72
|
+
* listener facts, zone identity, and certificate coverage for constructs
|
|
73
|
+
* that front this compute. Undefined when there is no load balancer.
|
|
74
|
+
*/
|
|
75
|
+
getIngressProfile(): EcsIngressProfile | undefined;
|
|
69
76
|
}
|
|
70
77
|
/**
|
|
71
78
|
* Lambda compute interface.
|
|
@@ -4,11 +4,13 @@ import { Construct } from "constructs";
|
|
|
4
4
|
import type { StackBuilder } from "../base/awsStack.js";
|
|
5
5
|
import type { ApplicationListener, ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
6
6
|
import { type EcsClusterProps } from "./ecsTypes.js";
|
|
7
|
+
import { type EcsIngressProfile } from "./ingressProfile.js";
|
|
7
8
|
export * from "./ecsTypes.js";
|
|
8
9
|
export * from "./ecsConstants.js";
|
|
9
10
|
export * from "./ecsContext.js";
|
|
10
11
|
export * from "./ecsTaskDefinition.js";
|
|
11
12
|
export * from "./ecsNetworking.js";
|
|
13
|
+
export * from "./ingressProfile.js";
|
|
12
14
|
export { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
|
|
13
15
|
export { validateEcsClusterProps, validateEcsDomainConfig, validateSsmPathComponent } from "./ecsValidation.js";
|
|
14
16
|
export * from "./ecsServiceFactory.js";
|
|
@@ -54,6 +56,7 @@ export default class EcsCluster extends Construct implements IConnectable {
|
|
|
54
56
|
private loadBalancer?;
|
|
55
57
|
private loadBalancerListener?;
|
|
56
58
|
private certificate?;
|
|
59
|
+
private ingressProfile?;
|
|
57
60
|
private asgState;
|
|
58
61
|
private services;
|
|
59
62
|
private scheduledTaskDefinitions;
|
|
@@ -69,6 +72,14 @@ export default class EcsCluster extends Construct implements IConnectable {
|
|
|
69
72
|
getLoadBalancer(): ApplicationLoadBalancer | undefined;
|
|
70
73
|
/** Get the load balancer's listener. Undefined if disabled. */
|
|
71
74
|
getListener(): ApplicationListener | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
|
|
77
|
+
* listener facts (default-action predicate, rule structure, port), the
|
|
78
|
+
* cluster's zone identity, and certificate coverage — for downstream
|
|
79
|
+
* constructs that front this cluster. Undefined when the cluster has no
|
|
80
|
+
* load balancer.
|
|
81
|
+
*/
|
|
82
|
+
getIngressProfile(): EcsIngressProfile | undefined;
|
|
72
83
|
/** Get a specific service by name. */
|
|
73
84
|
getService(name: string): FargateService | Ec2Service | undefined;
|
|
74
85
|
/** Get all services in this cluster. */
|
|
@@ -12,12 +12,14 @@ import { validateEcsClusterProps } from "./ecsValidation.js";
|
|
|
12
12
|
import { createExecutionRole, createTaskRole, createTaskDefinition, addContainersToTask, isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
|
|
13
13
|
import { addLoadBalancer, addLoadBalancerListener, addHostedZone, addDirectAccessOutputs, addRedirectHostRules, registerServiceWithALB } from "./ecsNetworking.js";
|
|
14
14
|
import { createService, addServiceScaling, getOrCreateAsgCapacityProvider } from "./ecsServiceFactory.js";
|
|
15
|
+
import { buildIngressProfile, warnWhenRecordedApexUnforwarded } from "./ingressProfile.js";
|
|
15
16
|
// Re-export all types/enums/constants so existing consumers are not broken
|
|
16
17
|
export * from "./ecsTypes.js";
|
|
17
18
|
export * from "./ecsConstants.js";
|
|
18
19
|
export * from "./ecsContext.js";
|
|
19
20
|
export * from "./ecsTaskDefinition.js";
|
|
20
21
|
export * from "./ecsNetworking.js";
|
|
22
|
+
export * from "./ingressProfile.js";
|
|
21
23
|
export { CapacityProviderDependencyAspect } from "./ecsCapacityProviderAspect.js";
|
|
22
24
|
export { validateEcsClusterProps, validateEcsDomainConfig, validateSsmPathComponent } from "./ecsValidation.js";
|
|
23
25
|
export * from "./ecsServiceFactory.js";
|
|
@@ -64,6 +66,7 @@ export default class EcsCluster extends Construct {
|
|
|
64
66
|
loadBalancer;
|
|
65
67
|
loadBalancerListener;
|
|
66
68
|
certificate;
|
|
69
|
+
ingressProfile;
|
|
67
70
|
// EC2-specific (mutable state shared with ecsServiceFactory)
|
|
68
71
|
asgState = {
|
|
69
72
|
providers: new Map(),
|
|
@@ -113,6 +116,20 @@ export default class EcsCluster extends Construct {
|
|
|
113
116
|
if (hzResult?.redirectRules !== undefined) {
|
|
114
117
|
addRedirectHostRules(this.ctx, this.loadBalancerListener, hzResult.redirectRules, this.priorityState);
|
|
115
118
|
}
|
|
119
|
+
// D2 (design 2026-08-18 cdn-app-origin): the synth-time answer to
|
|
120
|
+
// "what does this cluster's listener actually do?", assembled from the
|
|
121
|
+
// same facts the emitters above used — never re-derived from props by
|
|
122
|
+
// downstream consumers (the Cdn construct-reference origin lane).
|
|
123
|
+
this.ingressProfile = buildIngressProfile({
|
|
124
|
+
loadBalancer: this.loadBalancer,
|
|
125
|
+
internal: props.cluster?.loadBalancer === "internal",
|
|
126
|
+
services: props.services,
|
|
127
|
+
certificateAttached: this.certificate !== undefined,
|
|
128
|
+
zoneFacts: hzResult?.ingress
|
|
129
|
+
});
|
|
130
|
+
if (hzResult?.ingress !== undefined) {
|
|
131
|
+
warnWhenRecordedApexUnforwarded(this.ingressProfile, hzResult.ingress.apexRecordMinted, props.clusterName);
|
|
132
|
+
}
|
|
116
133
|
}
|
|
117
134
|
else if (this.directAccessEnabled) {
|
|
118
135
|
addDirectAccessOutputs(this.ctx, this.asgState.autoScalingGroup);
|
|
@@ -154,6 +171,16 @@ export default class EcsCluster extends Construct {
|
|
|
154
171
|
getListener() {
|
|
155
172
|
return this.loadBalancerListener;
|
|
156
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
|
|
176
|
+
* listener facts (default-action predicate, rule structure, port), the
|
|
177
|
+
* cluster's zone identity, and certificate coverage — for downstream
|
|
178
|
+
* constructs that front this cluster. Undefined when the cluster has no
|
|
179
|
+
* load balancer.
|
|
180
|
+
*/
|
|
181
|
+
getIngressProfile() {
|
|
182
|
+
return this.ingressProfile;
|
|
183
|
+
}
|
|
157
184
|
/** Get a specific service by name. */
|
|
158
185
|
getService(name) {
|
|
159
186
|
return this.services.get(name)?.service;
|
|
@@ -5,6 +5,7 @@ import { type ARecord, type IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
|
5
5
|
import type { AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
|
|
6
6
|
import type { ContainerDefinition, FargateService, Ec2Service } from "aws-cdk-lib/aws-ecs";
|
|
7
7
|
import { SecurityGroup } from "../networking/securityGroup.js";
|
|
8
|
+
import { type EcsIngressZoneFacts } from "./ingressProfile.js";
|
|
8
9
|
import type { EcsConstructContext } from "./ecsContext.js";
|
|
9
10
|
import type { EcsServiceProps } from "./ecsTypes.js";
|
|
10
11
|
import { type PriorityState } from "./hostHeaderListenerRule.js";
|
|
@@ -40,6 +41,7 @@ export declare function addHostedZone(ctx: EcsConstructContext, loadBalancer?: A
|
|
|
40
41
|
aRecord?: ARecord;
|
|
41
42
|
additionalListenerCertificates?: IListenerCertificate[];
|
|
42
43
|
redirectRules?: EcsRedirectRuleConfig;
|
|
44
|
+
ingress?: EcsIngressZoneFacts;
|
|
43
45
|
};
|
|
44
46
|
export declare function addDirectAccessOutputs(ctx: EcsConstructContext, autoScalingGroup?: AutoScalingGroup): void;
|
|
45
47
|
export declare function registerServiceWithALB(ctx: EcsConstructContext, listener: ApplicationListener, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service, primaryContainer: ContainerDefinition, priorityState: PriorityState): IApplicationTargetGroup;
|
|
@@ -9,7 +9,8 @@ import { DomainCertificate } from "../networking/domainCertificate.js";
|
|
|
9
9
|
import { AliasRecord } from "../networking/dnsRecord/aliasRecord.js";
|
|
10
10
|
import { SecurityGroup } from "../networking/securityGroup.js";
|
|
11
11
|
import { DNS_APEX, isManagedDomainBinding, isWithinZone } from "../../../utils/domainTypes.js";
|
|
12
|
-
import { readInjectedManagedDomainBinding } from "../../../utils/managedDomainContext.js";
|
|
12
|
+
import { readInjectedManagedDomainBinding, readInjectedManagedDomainCoverage } from "../../../utils/managedDomainContext.js";
|
|
13
|
+
import { computeListenerDefault404 } from "./ingressProfile.js";
|
|
13
14
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
14
15
|
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
15
16
|
import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
|
|
@@ -99,24 +100,15 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
|
|
|
99
100
|
}
|
|
100
101
|
export function addLoadBalancerListener(ctx, loadBalancer, certificate, additionalCertificates) {
|
|
101
102
|
const port = certificate ? 443 : 80;
|
|
102
|
-
const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
103
|
-
const willHaveMultipleRoutes = servicesWithPorts.length > 1 ||
|
|
104
|
-
servicesWithPorts.some((s) => {
|
|
105
|
-
const rules = Array.isArray(s.routing)
|
|
106
|
-
? s.routing
|
|
107
|
-
: s.routing
|
|
108
|
-
? [s.routing]
|
|
109
|
-
: [];
|
|
110
|
-
return rules.length > 1;
|
|
111
|
-
});
|
|
112
|
-
// CDK rejects listeners with neither a default action nor target groups.
|
|
113
|
-
const noServicePorts = servicesWithPorts.length === 0;
|
|
114
103
|
return addRoutingListener(loadBalancer, `${ctx.props.clusterName}Listener`, {
|
|
115
104
|
port,
|
|
116
105
|
...(certificate && { certificate }),
|
|
117
106
|
...(additionalCertificates !== undefined &&
|
|
118
107
|
additionalCertificates.length > 0 && { additionalCertificates }),
|
|
119
|
-
|
|
108
|
+
// Single home for the default-action predicate (ingressProfile.ts): the
|
|
109
|
+
// ingress profile exports the same value, so downstream verdicts model
|
|
110
|
+
// the listener the emitter actually built (design cdn-app-origin, P5).
|
|
111
|
+
default404: computeListenerDefault404(ctx.props.services)
|
|
120
112
|
});
|
|
121
113
|
}
|
|
122
114
|
/**
|
|
@@ -302,6 +294,10 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
302
294
|
}
|
|
303
295
|
}
|
|
304
296
|
const subjectAlternativeNames = [...routedHosts, ...redirectHosts].filter((h) => h !== domainName);
|
|
297
|
+
// Captured BEFORE the BYO override below: whether the managed lane
|
|
298
|
+
// attached the binding's (or export fallback's) certificate — the D5
|
|
299
|
+
// coverage context describes exactly that certificate, never a BYO one.
|
|
300
|
+
const managedCertificateAttached = certificate !== undefined;
|
|
305
301
|
if (domainConfig?.certificate) {
|
|
306
302
|
certificate = domainConfig.certificate;
|
|
307
303
|
}
|
|
@@ -333,6 +329,62 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
333
329
|
}).certificate;
|
|
334
330
|
}
|
|
335
331
|
const additionalListenerCertificates = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed);
|
|
332
|
+
// D5 certificate coverage (design 2026-08-18 cdn-app-origin): fold the SAN
|
|
333
|
+
// set of every certificate the listener will carry, where knowable at
|
|
334
|
+
// synth. The cluster-minted certificate's set is the literal minted above;
|
|
335
|
+
// managed certificates are described by the CLI-injected coverage context
|
|
336
|
+
// (absent → opaque, a domain stack predating the hosts outputs); BYO
|
|
337
|
+
// imports are opaque by nature. Fail-open to "partial"/"unknown", never to
|
|
338
|
+
// a false "covered" — consumers (the Cdn origin lane) error only on
|
|
339
|
+
// enumerated non-coverage and warn on opacity.
|
|
340
|
+
//
|
|
341
|
+
// Provenance gate: the coverage context describes the zone's CURRENT
|
|
342
|
+
// certificates — the same D2 resolution that produces an injected binding.
|
|
343
|
+
// An EXPLICIT `domainConfig.managedDomain` BINDING may pin an OLDER
|
|
344
|
+
// certificateArn (or usEast1CertificateArn) the current coverage does not
|
|
345
|
+
// describe, so folding it would over-claim and invert P3 (TLS fails at
|
|
346
|
+
// runtime after E6 passes the hostname as covered). Explicit bindings
|
|
347
|
+
// therefore stay opaque, exactly as if no coverage context existed. The
|
|
348
|
+
// explicit EXPORTS form is unaffected: Fn.importValue resolves at deploy
|
|
349
|
+
// to the zone's current certificate — the one the coverage describes.
|
|
350
|
+
const explicitPinnedBinding = domainConfig?.managedDomain !== undefined &&
|
|
351
|
+
isManagedDomainBinding(domainConfig.managedDomain);
|
|
352
|
+
const injectedCoverage = managed && !explicitPinnedBinding
|
|
353
|
+
? readInjectedManagedDomainCoverage(ctx.scope.node, managed.zoneName, `Cluster '${props.clusterName}'`)
|
|
354
|
+
: undefined;
|
|
355
|
+
const knownCertificateHosts = [];
|
|
356
|
+
let anyOpaqueCertificate = false;
|
|
357
|
+
if (domainConfig?.certificate) {
|
|
358
|
+
anyOpaqueCertificate = true;
|
|
359
|
+
}
|
|
360
|
+
else if (managedCertificateAttached) {
|
|
361
|
+
if (injectedCoverage?.certificateHosts !== undefined) {
|
|
362
|
+
knownCertificateHosts.push(...injectedCoverage.certificateHosts);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
anyOpaqueCertificate = true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
knownCertificateHosts.push(domainName, ...subjectAlternativeNames);
|
|
370
|
+
}
|
|
371
|
+
for (const source of domainConfig?.additionalCertificates ?? []) {
|
|
372
|
+
if ("certificateArn" in source) {
|
|
373
|
+
anyOpaqueCertificate = true;
|
|
374
|
+
}
|
|
375
|
+
else if (injectedCoverage?.usEast1CertificateHosts !== undefined) {
|
|
376
|
+
knownCertificateHosts.push(...injectedCoverage.usEast1CertificateHosts);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
anyOpaqueCertificate = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const dedupedCertificateHosts = [...new Set(knownCertificateHosts)];
|
|
383
|
+
const certificateCoverage = anyOpaqueCertificate
|
|
384
|
+
? dedupedCertificateHosts.length > 0
|
|
385
|
+
? { kind: "partial", hostnames: dedupedCertificateHosts }
|
|
386
|
+
: { kind: "unknown" }
|
|
387
|
+
: { kind: "enumerated", hostnames: dedupedCertificateHosts };
|
|
336
388
|
let aRecord;
|
|
337
389
|
if (loadBalancer) {
|
|
338
390
|
const routingPolicy = domainConfig?.routingPolicy;
|
|
@@ -430,7 +482,16 @@ export function addHostedZone(ctx, loadBalancer) {
|
|
|
430
482
|
}),
|
|
431
483
|
...(redirectHosts.length > 0 && {
|
|
432
484
|
redirectRules: { hostFqdns: redirectHosts, targetHost: domainName }
|
|
433
|
-
})
|
|
485
|
+
}),
|
|
486
|
+
ingress: {
|
|
487
|
+
hostedZone,
|
|
488
|
+
zoneName,
|
|
489
|
+
domainName,
|
|
490
|
+
routedHosts,
|
|
491
|
+
redirectHosts,
|
|
492
|
+
apexRecordMinted: loadBalancer !== undefined && domainConfig?.apexRecord !== "none",
|
|
493
|
+
certificateCoverage
|
|
494
|
+
}
|
|
434
495
|
};
|
|
435
496
|
}
|
|
436
497
|
const US_EAST_1 = "us-east-1";
|