@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,83 @@
|
|
|
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 type { IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
16
|
+
import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
17
|
+
import type { CdnOriginConfig } from "../../resources/aws/cdn/index.js";
|
|
18
|
+
import type { IEcsCompute } from "./interfaces/compute.js";
|
|
19
|
+
import { normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
|
|
20
|
+
export { normaliseDnsName };
|
|
21
|
+
/** A Cdn-owned origin alias record to mint after the distribution exists. */
|
|
22
|
+
export interface EcsOriginRecordPlan {
|
|
23
|
+
hostname: string;
|
|
24
|
+
hostedZone: IHostedZone;
|
|
25
|
+
zoneName: string;
|
|
26
|
+
recordName: string;
|
|
27
|
+
loadBalancer: IApplicationLoadBalancer;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolvedEcsOrigin {
|
|
30
|
+
hostname: string;
|
|
31
|
+
originConfig: CdnOriginConfig;
|
|
32
|
+
/** Present when the Cdn owns the record and `originRecord` is not "none". */
|
|
33
|
+
recordPlan?: EcsOriginRecordPlan;
|
|
34
|
+
/** True when the hostname is a compute-declared routing host (P1). */
|
|
35
|
+
computeOwnsRecord: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Explicit per-compute origin overrides: the distribution-level
|
|
39
|
+
* `originHostname`/`originRecord` for the default origin's compute, and each
|
|
40
|
+
* behaviour's own `originHostname`/`originRecord` for its compute — so every
|
|
41
|
+
* lane that can throw E3 can also cure it (design D3: behaviour origins get
|
|
42
|
+
* the same targeted validation AND the same override surface).
|
|
43
|
+
*/
|
|
44
|
+
export interface EcsOriginOverride {
|
|
45
|
+
originHostname?: string;
|
|
46
|
+
originRecord?: "alias" | "none";
|
|
47
|
+
}
|
|
48
|
+
export interface CdnEcsOriginResolverOptions {
|
|
49
|
+
cdnId: string;
|
|
50
|
+
/** The distribution's own alias names, normalised — the origin-loop guard. */
|
|
51
|
+
aliasNames: ReadonlySet<string>;
|
|
52
|
+
/** Per-compute explicit overrides — assembled by the Cdn from the
|
|
53
|
+
* distribution props (default origin) and each behaviour entry. */
|
|
54
|
+
overrides?: ReadonlyMap<IEcsCompute, EcsOriginOverride>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The origin-lane refusal/warning catalogue (design 2026-08-18
|
|
58
|
+
* cdn-app-origin § E-catalogue; C4 is the origin-loop guard from design
|
|
59
|
+
* 2026-08-17 cdn-domain-ownership). Every catalogue verdict is thrown or
|
|
60
|
+
* logged through the helpers below so its code rides at the head of the
|
|
61
|
+
* message — a synth or deploy log line maps straight back to the catalogue
|
|
62
|
+
* entry, and its cure, without a source dive.
|
|
63
|
+
*/
|
|
64
|
+
export type CdnOriginIssueCode = "C4" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6" | "E7" | "E8" | "E9" | "E9b" | "E10" | "E11" | "W1" | "W2";
|
|
65
|
+
export declare function cdnOriginRefusal(code: CdnOriginIssueCode, message: string): Error;
|
|
66
|
+
export declare function cdnOriginWarning(code: CdnOriginIssueCode, message: string): void;
|
|
67
|
+
/**
|
|
68
|
+
* E2's invariant middle, shared by the construct-origin and alb lanes —
|
|
69
|
+
* the lanes differ only in subject and cure, never in the defect statement.
|
|
70
|
+
*/
|
|
71
|
+
export declare function internalAlbMessage(subject: string, cure: string): string;
|
|
72
|
+
export declare class CdnEcsOriginResolver {
|
|
73
|
+
private readonly options;
|
|
74
|
+
private readonly resolutions;
|
|
75
|
+
constructor(options: CdnEcsOriginResolverOptions);
|
|
76
|
+
resolve(compute: IEcsCompute): ResolvedEcsOrigin;
|
|
77
|
+
/** Every Cdn-owned record the resolutions so far require, one per compute. */
|
|
78
|
+
getRecordPlans(): EcsOriginRecordPlan[];
|
|
79
|
+
private resolveFresh;
|
|
80
|
+
private explicitHostnameFor;
|
|
81
|
+
private effectiveOriginRecord;
|
|
82
|
+
private selectHostname;
|
|
83
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
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 { isWithinZone, recordLabelWithin } from "../../utils/domainTypes.js";
|
|
19
|
+
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
20
|
+
const SHARED_CURE = "declare the hostname as a routing host on the receiving service " +
|
|
21
|
+
"(services[].routing[].host) — one declaration adds the certificate SAN " +
|
|
22
|
+
"(cluster-minted certificates), the listener rule, and the DNS record. " +
|
|
23
|
+
"CAUTION: adding a SECOND routing rule to a service flips the listener " +
|
|
24
|
+
"default action from forward to fixed-404, so a cluster serving its " +
|
|
25
|
+
"domain through the default action must keep a forwarding path for it, " +
|
|
26
|
+
'e.g. routing: [{ path: "/*" }, { host: "<origin hostname>" }].';
|
|
27
|
+
export function cdnOriginRefusal(code, message) {
|
|
28
|
+
return new Error(`[${code}] ${message}`);
|
|
29
|
+
}
|
|
30
|
+
export function cdnOriginWarning(code, message) {
|
|
31
|
+
FjallLogger.warn(`[${code}] ${message}`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* E2's invariant middle, shared by the construct-origin and alb lanes —
|
|
35
|
+
* the lanes differ only in subject and cure, never in the defect statement.
|
|
36
|
+
*/
|
|
37
|
+
export function internalAlbMessage(subject, cure) {
|
|
38
|
+
return (`${subject} is internal — CloudFront reaches origins over the public ` +
|
|
39
|
+
`internet and cannot address an internal ALB. ${cure}`);
|
|
40
|
+
}
|
|
41
|
+
export class CdnEcsOriginResolver {
|
|
42
|
+
options;
|
|
43
|
+
resolutions = new Map();
|
|
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 cdnOriginRefusal("E1", `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 cdnOriginRefusal("E2", internalAlbMessage(`CDN '${cdnId}': the ECS compute origin's load balancer`, "Make the cluster's load balancer internet-facing (drop " +
|
|
78
|
+
'loadBalancer: "internal"), or front a different resource.'));
|
|
79
|
+
}
|
|
80
|
+
const { hostname, computeOwnsRecord } = this.selectHostname(compute, profile);
|
|
81
|
+
// Origin-loop guard (C4, extended): the resolved hostname must not be
|
|
82
|
+
// one of the distribution's own alias names.
|
|
83
|
+
if (this.options.aliasNames.has(normaliseDnsName(hostname))) {
|
|
84
|
+
throw cdnOriginRefusal("C4", `CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
|
|
85
|
+
"of the distribution's own domain names — once DNS points that " +
|
|
86
|
+
"name at the distribution, every request loops CloudFront → " +
|
|
87
|
+
"CloudFront. Pick a dedicated origin hostname (originHostname), " +
|
|
88
|
+
"or serve the alias from the compute directly.");
|
|
89
|
+
}
|
|
90
|
+
if (profile.listenerPort === 80) {
|
|
91
|
+
// E4 (port-80 arm) — the same designed condition as the no-domain arm
|
|
92
|
+
// in selectHostname: no domain anywhere ⇒ no certificate ⇒ the
|
|
93
|
+
// listener serves HTTP only, and a CloudFront origin fetch over HTTPS
|
|
94
|
+
// has nothing to shake hands with. One code, one cure.
|
|
95
|
+
throw cdnOriginRefusal("E4", `CDN '${cdnId}': the ECS compute origin's listener serves HTTP only ` +
|
|
96
|
+
"(the cluster has no domain, so no certificate resolved). A " +
|
|
97
|
+
"CloudFront origin needs a TLS-valid HTTPS origin. Give the " +
|
|
98
|
+
"cluster a domain (cluster.domainConfig), or use originType " +
|
|
99
|
+
'"alb" with protocolPolicy "HTTP_ONLY" if plaintext origin ' +
|
|
100
|
+
"traffic is acceptable.");
|
|
101
|
+
}
|
|
102
|
+
// Record-ownership feasibility BEFORE coverage/forwarding (design D3
|
|
103
|
+
// step 3): an out-of-zone hostname's only real cures are an in-zone
|
|
104
|
+
// name or originRecord: "none" — deciding coverage first would hand the
|
|
105
|
+
// user E6's routing-host cure, which ECS itself refuses out-of-zone.
|
|
106
|
+
let recordPlan;
|
|
107
|
+
if (!computeOwnsRecord && this.effectiveOriginRecord(compute) !== "none") {
|
|
108
|
+
// The Cdn owns the record (P1). Zone facts are guaranteed here: a
|
|
109
|
+
// 443 listener implies a domain, which implies zone identity.
|
|
110
|
+
if (profile.hostedZone === undefined ||
|
|
111
|
+
profile.zoneName === undefined ||
|
|
112
|
+
!isWithinZone(hostname, profile.zoneName)) {
|
|
113
|
+
// E5 — a record we cannot mint: outside the cluster's zone.
|
|
114
|
+
throw cdnOriginRefusal("E5", `CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
|
|
115
|
+
`origin's hosted zone ('${profile.zoneName ?? "none"}'), so its ` +
|
|
116
|
+
"record cannot be minted here. Use a hostname inside the zone, " +
|
|
117
|
+
'or set originRecord: "none" and manage the record where the ' +
|
|
118
|
+
"zone lives.");
|
|
119
|
+
}
|
|
120
|
+
recordPlan = {
|
|
121
|
+
hostname,
|
|
122
|
+
hostedZone: profile.hostedZone,
|
|
123
|
+
zoneName: profile.zoneName,
|
|
124
|
+
recordName: recordLabelWithin(hostname, profile.zoneName),
|
|
125
|
+
loadBalancer: profile.loadBalancer
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const coverage = certificateCovers(profile.certificateCoverage, hostname);
|
|
129
|
+
if (coverage === "not-covered") {
|
|
130
|
+
// E6 — provably uncovered: every attached certificate is enumerated
|
|
131
|
+
// and none matches. TLS to the origin fails on every request.
|
|
132
|
+
throw cdnOriginRefusal("E6", `CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
|
|
133
|
+
`'${hostname}' (attached certificates cover: ` +
|
|
134
|
+
`${profile.certificateCoverage.kind === "unknown" ? "unknown" : profile.certificateCoverage.hostnames.join(", ") || "nothing"}). ` +
|
|
135
|
+
`Every origin fetch would fail TLS validation. Cure: ${SHARED_CURE}`);
|
|
136
|
+
}
|
|
137
|
+
if (coverage === "unknown") {
|
|
138
|
+
// W1 — opaque certificates: not decidable at synth. Once per compute
|
|
139
|
+
// by construction: resolveFresh is memoized per compute, so every
|
|
140
|
+
// opaque compute names its own hostname exactly once (the old
|
|
141
|
+
// resolver-wide latch swallowed every compute after the first).
|
|
142
|
+
cdnOriginWarning("W1", `CDN '${cdnId}': cannot verify that the ECS origin's listener ` +
|
|
143
|
+
`certificates cover '${hostname}' (imported certificate ARNs are ` +
|
|
144
|
+
"opaque at synth). If the cluster uses a managed domain, redeploy " +
|
|
145
|
+
"the domain stack with engine >= 14.2.0 so it publishes " +
|
|
146
|
+
"certificate-coverage outputs and this becomes checkable; for " +
|
|
147
|
+
"imported certificates, verify the SANs cover the hostname — TLS " +
|
|
148
|
+
"fails at runtime if not.");
|
|
149
|
+
}
|
|
150
|
+
const verdict = forwardingVerdict(profile, hostname);
|
|
151
|
+
if (verdict.verdict === "none") {
|
|
152
|
+
// E7 — the listener's default action is a fixed 404 and no rule
|
|
153
|
+
// forwards the hostname: every origin fetch answers 404.
|
|
154
|
+
throw cdnOriginRefusal("E7", `CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
|
|
155
|
+
`'${hostname}' — its default action is a fixed 404 and no routing ` +
|
|
156
|
+
`rule forwards that hostname. Cure: ${SHARED_CURE}`);
|
|
157
|
+
}
|
|
158
|
+
if (verdict.verdict === "partial") {
|
|
159
|
+
// W2 — some paths forward, others hit the fixed-404 default.
|
|
160
|
+
cdnOriginWarning("W2", `CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
|
|
161
|
+
`some paths (matching rules: ${verdict.patterns.join("; ")}); ` +
|
|
162
|
+
"requests outside those patterns answer the listener's fixed-404 " +
|
|
163
|
+
"default. Verify the patterns against the distribution's " +
|
|
164
|
+
"behaviours.");
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
hostname,
|
|
168
|
+
// The hostname is certificate-covered, so the resource layer's
|
|
169
|
+
// HTTPS_ONLY default is exactly right — no protocol override.
|
|
170
|
+
originConfig: { type: "http", domainName: hostname },
|
|
171
|
+
...(recordPlan !== undefined && { recordPlan }),
|
|
172
|
+
computeOwnsRecord
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
explicitHostnameFor(compute) {
|
|
176
|
+
return this.options.overrides?.get(compute)?.originHostname;
|
|
177
|
+
}
|
|
178
|
+
effectiveOriginRecord(compute) {
|
|
179
|
+
return this.options.overrides?.get(compute)?.originRecord;
|
|
180
|
+
}
|
|
181
|
+
selectHostname(compute, profile) {
|
|
182
|
+
const { cdnId } = this.options;
|
|
183
|
+
const explicit = this.explicitHostnameFor(compute);
|
|
184
|
+
const originRecord = this.effectiveOriginRecord(compute);
|
|
185
|
+
if (explicit !== undefined) {
|
|
186
|
+
if (Token.isUnresolved(explicit)) {
|
|
187
|
+
throw new Error(`CDN '${cdnId}': originHostname must be a literal hostname (got ` +
|
|
188
|
+
"an unresolved token). Coverage, forwarding, and the origin " +
|
|
189
|
+
"record are synth-time decisions — pass the concrete name.");
|
|
190
|
+
}
|
|
191
|
+
// DNS names are case-insensitive (RFC 4343): every membership test
|
|
192
|
+
// compares normalised, so a case-differing spelling cannot slip past
|
|
193
|
+
// the redirect/ownership guards the way it cannot slip past ACM.
|
|
194
|
+
const explicitNormalised = normaliseDnsName(explicit);
|
|
195
|
+
if (profile.redirectHosts.some((host) => normaliseDnsName(host) === explicitNormalised)) {
|
|
196
|
+
// E10 — redirect hosts never serve traffic: the listener answers
|
|
197
|
+
// them with a 301 to the cluster domain, so an origin pointed there
|
|
198
|
+
// loops every viewer request through CloudFront → 301 → CloudFront.
|
|
199
|
+
throw cdnOriginRefusal("E10", `CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
|
|
200
|
+
"entry on the ECS origin — the listener 301s it to " +
|
|
201
|
+
`'${profile.domainName ?? "the cluster domain"}' and it never ` +
|
|
202
|
+
"serves traffic. Use a served hostname: a routing host, or the " +
|
|
203
|
+
"derived origin name (omit originHostname).");
|
|
204
|
+
}
|
|
205
|
+
const computeOwnsRecord = profile.routedHosts.some((host) => normaliseDnsName(host) === explicitNormalised);
|
|
206
|
+
if (computeOwnsRecord && originRecord !== undefined) {
|
|
207
|
+
// E8 — the compute already owns that hostname's record.
|
|
208
|
+
throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but originHostname ` +
|
|
209
|
+
`'${explicit}' is a routing host — the compute already owns its ` +
|
|
210
|
+
"record (P1: the construct that declares a hostname owns its " +
|
|
211
|
+
"record). Drop originRecord.");
|
|
212
|
+
}
|
|
213
|
+
return { hostname: explicit, computeOwnsRecord };
|
|
214
|
+
}
|
|
215
|
+
// Auto-resolution: routed hosts that are not the distribution's own
|
|
216
|
+
// aliases. (Redirect hosts can never appear — ECS enforces
|
|
217
|
+
// serve-XOR-redirect at synth.)
|
|
218
|
+
const candidates = profile.routedHosts.filter((host) => !this.options.aliasNames.has(normaliseDnsName(host)));
|
|
219
|
+
if (candidates.length === 1 && candidates[0] !== undefined) {
|
|
220
|
+
if (originRecord !== undefined) {
|
|
221
|
+
// E8 — same ownership rule as the explicit arm.
|
|
222
|
+
throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
|
|
223
|
+
`routing host '${candidates[0]}' — the compute already owns its ` +
|
|
224
|
+
"record. Drop originRecord.");
|
|
225
|
+
}
|
|
226
|
+
return { hostname: candidates[0], computeOwnsRecord: true };
|
|
227
|
+
}
|
|
228
|
+
if (candidates.length > 1) {
|
|
229
|
+
// E3 — ambiguous: several served hostnames could be the origin.
|
|
230
|
+
throw cdnOriginRefusal("E3", `CDN '${cdnId}': the ECS origin serves several hostnames ` +
|
|
231
|
+
`(${candidates.join(", ")}) and the origin is ambiguous. Set ` +
|
|
232
|
+
"originHostname — on the distribution for its default origin, or " +
|
|
233
|
+
"on the behaviour entry — to the one the distribution should " +
|
|
234
|
+
"fetch from.");
|
|
235
|
+
}
|
|
236
|
+
if (profile.domainName === undefined) {
|
|
237
|
+
// E4 (no-domain arm) — nothing to derive from (and nothing a viewer
|
|
238
|
+
// could reach over TLS anyway; the no-domain listener is HTTP-only,
|
|
239
|
+
// so an explicit originHostname would only trade this error for the
|
|
240
|
+
// port-80 arm — the catalogue cures are the honest ones).
|
|
241
|
+
throw cdnOriginRefusal("E4", `CDN '${cdnId}': the ECS compute origin has no domain to derive an ` +
|
|
242
|
+
"origin hostname from (and no certificate, so nothing serves " +
|
|
243
|
+
"HTTPS). Give the cluster a domain (cluster.domainConfig), or " +
|
|
244
|
+
'use originType "alb" with protocolPolicy "HTTP_ONLY" if ' +
|
|
245
|
+
"plaintext origin traffic is acceptable.");
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
hostname: `origin.${profile.domainName}`,
|
|
249
|
+
computeOwnsRecord: false
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -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);
|
|
@@ -76,7 +76,7 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
76
76
|
const nameServers = hostedZoneConstruct.nameServers ?? props.adoptedNameServers ?? [];
|
|
77
77
|
const records = props.records ?? [];
|
|
78
78
|
if (records.length > 0) {
|
|
79
|
-
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
|
|
79
|
+
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records, props.recordIds);
|
|
80
80
|
}
|
|
81
81
|
// Step 2 of the R2 two-step gate: issuing the cert before the delegation
|
|
82
82
|
// NS has propagated hangs ACM DNS-validation, so "zone" stops here.
|
|
@@ -11,8 +11,17 @@ import type { DnsRecord } from "./interfaces/domain.js";
|
|
|
11
11
|
* runtime guard catches user-crafted `{ kind, ... }` literals that lack
|
|
12
12
|
* `bind()`.
|
|
13
13
|
*
|
|
14
|
-
* Construct-id
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Construct-id formulas (`recordIds` on `DomainCommonProps`):
|
|
15
|
+
* - `"indexed"` (default): `${safeZone}${safeName}${type}Record${index}` —
|
|
16
|
+
* byte-identical to the legacy composer, an eject-contract invariant
|
|
17
|
+
* (Phase 3 depends on stable IDs). Position-coupled, so the records list
|
|
18
|
+
* is append-only for deployed zones.
|
|
19
|
+
* - `"stable"`: `${safeZone}${safeName}${type}Record` — position-free.
|
|
20
|
+
* {@link assertStableIdsDerivable} refuses the two shapes that would
|
|
21
|
+
* collide, with cures, before CDK's opaque duplicate-construct-id error
|
|
22
|
+
* can fire.
|
|
23
|
+
* A per-record `id` (PascalCase alphanumeric) replaces the `safeName`
|
|
24
|
+
* segment in either mode — the escape hatch the stable-mode collision
|
|
25
|
+
* refusal names.
|
|
17
26
|
*/
|
|
18
|
-
export declare function composeTypedDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecord[]): void;
|
|
27
|
+
export declare function composeTypedDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecord[], recordIds?: "indexed" | "stable"): void;
|
|
@@ -16,15 +16,29 @@ import { DNS_APEX } from "@fjall/util";
|
|
|
16
16
|
* runtime guard catches user-crafted `{ kind, ... }` literals that lack
|
|
17
17
|
* `bind()`.
|
|
18
18
|
*
|
|
19
|
-
* Construct-id
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Construct-id formulas (`recordIds` on `DomainCommonProps`):
|
|
20
|
+
* - `"indexed"` (default): `${safeZone}${safeName}${type}Record${index}` —
|
|
21
|
+
* byte-identical to the legacy composer, an eject-contract invariant
|
|
22
|
+
* (Phase 3 depends on stable IDs). Position-coupled, so the records list
|
|
23
|
+
* is append-only for deployed zones.
|
|
24
|
+
* - `"stable"`: `${safeZone}${safeName}${type}Record` — position-free.
|
|
25
|
+
* {@link assertStableIdsDerivable} refuses the two shapes that would
|
|
26
|
+
* collide, with cures, before CDK's opaque duplicate-construct-id error
|
|
27
|
+
* can fire.
|
|
28
|
+
* A per-record `id` (PascalCase alphanumeric) replaces the `safeName`
|
|
29
|
+
* segment in either mode — the escape hatch the stable-mode collision
|
|
30
|
+
* refusal names.
|
|
22
31
|
*/
|
|
23
|
-
export function composeTypedDnsRecords(scope, zone, zoneName, records) {
|
|
32
|
+
export function composeTypedDnsRecords(scope, zone, zoneName, records, recordIds = "indexed") {
|
|
24
33
|
const safeZone = toPascalCase(getSafeZoneName(zoneName));
|
|
34
|
+
if (recordIds === "stable") {
|
|
35
|
+
assertStableIdsDerivable(zoneName, records);
|
|
36
|
+
}
|
|
25
37
|
records.forEach((record, index) => {
|
|
26
|
-
const safeName =
|
|
27
|
-
const constructId =
|
|
38
|
+
const safeName = recordIdNameSegment(record);
|
|
39
|
+
const constructId = recordIds === "stable"
|
|
40
|
+
? `${safeZone}${safeName}${record.type}Record`
|
|
41
|
+
: `${safeZone}${safeName}${record.type}Record${index}`;
|
|
28
42
|
const common = {
|
|
29
43
|
zone,
|
|
30
44
|
zoneName,
|
|
@@ -40,6 +54,9 @@ export function composeTypedDnsRecords(scope, zone, zoneName, records) {
|
|
|
40
54
|
}
|
|
41
55
|
new AliasRecord(scope, constructId, {
|
|
42
56
|
...common,
|
|
57
|
+
// A declared AAAA alias must DEPLOY as AAAA — the resource defaults
|
|
58
|
+
// to "A" for its pattern-internal (IPv4) call sites only.
|
|
59
|
+
recordType: record.type,
|
|
43
60
|
target: target
|
|
44
61
|
});
|
|
45
62
|
return;
|
|
@@ -102,6 +119,71 @@ export function composeTypedDnsRecords(scope, zone, zoneName, records) {
|
|
|
102
119
|
}
|
|
103
120
|
});
|
|
104
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Construct-id name segment: an explicit `id` verbatim (validated — it
|
|
124
|
+
* becomes a CloudFormation logical-ID segment), else the pascal-cased
|
|
125
|
+
* record name. The no-`id` derivation is byte-identical to the legacy
|
|
126
|
+
* inline formula (eject contract).
|
|
127
|
+
*/
|
|
128
|
+
function recordIdNameSegment(record) {
|
|
129
|
+
if (record.id !== undefined) {
|
|
130
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(record.id)) {
|
|
131
|
+
throw new Error(`DNS record '${record.name}' (${record.type}): 'id' must be ` +
|
|
132
|
+
`alphanumeric starting with a letter (got '${record.id}') — it ` +
|
|
133
|
+
"becomes a CloudFormation logical-ID segment.");
|
|
134
|
+
}
|
|
135
|
+
return record.id;
|
|
136
|
+
}
|
|
137
|
+
return toPascalCase(record.name === DNS_APEX ? "Apex" : record.name);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Stable-mode pre-flight: refuse the two list shapes whose derived
|
|
141
|
+
* construct ids would collide, each with its own cure, BEFORE CDK's opaque
|
|
142
|
+
* "already a Construct with name" error can fire.
|
|
143
|
+
*
|
|
144
|
+
* - Duplicate (name, type) pairs are illegal Route53 regardless of id
|
|
145
|
+
* scheme (one record set per (zone, name, type) — the same doctrine the
|
|
146
|
+
* DNS claim registry enforces across constructs; this vocabulary cannot
|
|
147
|
+
* express routing-policy variants). Indexed mode surfaces them via the
|
|
148
|
+
* registry at claim time; stable mode must refuse before construction.
|
|
149
|
+
* - DISTINCT records whose derived construct ids concatenate to one string
|
|
150
|
+
* — same-type names that sanitise to one PascalCase segment (e.g.
|
|
151
|
+
* 'mail-eu' and 'mailEu'), or cross-type segment/type ambiguity (id
|
|
152
|
+
* 'ApiAAA' + type 'A' vs segment 'Api' + type 'AAAA' both derive
|
|
153
|
+
* 'ApiAAAARecord') — are legal Route53 but collide as construct ids; the
|
|
154
|
+
* per-record `id` escape hatch disambiguates. Keyed on the FULL derived
|
|
155
|
+
* id, never on (segment, type) pairs, precisely so the concatenation
|
|
156
|
+
* ambiguity cannot slip past to CDK.
|
|
157
|
+
*/
|
|
158
|
+
function assertStableIdsDerivable(zoneName, records) {
|
|
159
|
+
const byRecordSet = new Map();
|
|
160
|
+
const byConstructId = new Map();
|
|
161
|
+
for (const record of records) {
|
|
162
|
+
const setKey = [
|
|
163
|
+
record.name.toLowerCase().replace(/\.$/, ""),
|
|
164
|
+
record.type
|
|
165
|
+
].join("|");
|
|
166
|
+
const priorSet = byRecordSet.get(setKey);
|
|
167
|
+
if (priorSet !== undefined) {
|
|
168
|
+
throw new Error(`DNS record '${record.name}' (${record.type}) in zone '${zoneName}': ` +
|
|
169
|
+
"declared twice in this records list. Route53 allows one record " +
|
|
170
|
+
"set per (zone, name, type); CloudFormation would reject this at " +
|
|
171
|
+
"deploy. Merge the values into one entry (multi-value records " +
|
|
172
|
+
"take a string array), or remove one.");
|
|
173
|
+
}
|
|
174
|
+
byRecordSet.set(setKey, record);
|
|
175
|
+
const constructKey = `${recordIdNameSegment(record)}${record.type}Record`;
|
|
176
|
+
const prior = byConstructId.get(constructKey);
|
|
177
|
+
if (prior !== undefined) {
|
|
178
|
+
throw new Error(`DNS records '${prior.name}' (${prior.type}) and '${record.name}' ` +
|
|
179
|
+
`(${record.type}) in zone '${zoneName}': both derive record ` +
|
|
180
|
+
`construct id '${constructKey}' under recordIds: "stable". Give ` +
|
|
181
|
+
"one of them a distinct per-record 'id' (PascalCase " +
|
|
182
|
+
"alphanumeric) to disambiguate.");
|
|
183
|
+
}
|
|
184
|
+
byConstructId.set(constructKey, record);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
105
187
|
// Parse "10 mail.example.com" → { priority: 10, hostName: "mail.example.com" }.
|
|
106
188
|
function parseMxValue(raw) {
|
|
107
189
|
const parts = raw.trim().split(/\s+/);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CfnOutput, Stack, Stage, Token } from "aws-cdk-lib";
|
|
2
2
|
import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
|
|
3
|
-
import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
|
|
3
|
+
import { getDomainExportNames, getDomainUsEast1CertificatesStackName, hostsExportPartName, serialiseHostsChunks } from "@fjall/util";
|
|
4
4
|
import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
|
|
5
5
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
6
6
|
const US_EAST_1 = "us-east-1";
|
|
@@ -42,6 +42,13 @@ export function composeDomainCertificates(scope, composition) {
|
|
|
42
42
|
costAllocationEnvironment: composition.costAllocationEnvironment,
|
|
43
43
|
costAllocationDomain: composition.costAllocationDomain
|
|
44
44
|
});
|
|
45
|
+
// D5 (design 2026-08-18 cdn-app-origin): publish the hostnames this
|
|
46
|
+
// certificate covers beside its ARN export — SAME per-certificate
|
|
47
|
+
// identity, so a consumer can bind coverage for exactly the
|
|
48
|
+
// certificates whose ARNs it binds, never a zone-level aggregate
|
|
49
|
+
// (which would list hostnames of certificates that are not on the
|
|
50
|
+
// consumer's listener and over-claim coverage).
|
|
51
|
+
emitHostsOutputs(scope, `${certId}Hosts`, getDomainExportNames(normalised.domainName).certificateHosts, certificateHosts(normalised));
|
|
45
52
|
certificates.set(normalised.domainName, dc.certificate);
|
|
46
53
|
return;
|
|
47
54
|
}
|
|
@@ -80,6 +87,7 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
|
|
|
80
87
|
exportCertificateArn: false
|
|
81
88
|
});
|
|
82
89
|
emitUsEast1Export(scope, composition, dc);
|
|
90
|
+
emitUsEast1HostsOutput(scope, composition, cert);
|
|
83
91
|
return dc.certificate;
|
|
84
92
|
}
|
|
85
93
|
const pairedStack = resolvePairedUsEast1Stack(domainStack, composition, cert);
|
|
@@ -104,8 +112,65 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
|
|
|
104
112
|
exportCertificateArn: false
|
|
105
113
|
});
|
|
106
114
|
emitUsEast1Export(pairedStack, composition, dc);
|
|
115
|
+
// The hosts output lands on the MAIN domain stack even though the
|
|
116
|
+
// certificate lives in the paired stack: the SAN list is a synth-time
|
|
117
|
+
// literal (no cross-region reference), and the CLI's existing main-stack
|
|
118
|
+
// DescribeStacks read must reach it without touching the paired-stack
|
|
119
|
+
// reader (design 2026-08-18 cdn-app-origin, D5).
|
|
120
|
+
emitUsEast1HostsOutput(domainStack, composition, cert);
|
|
107
121
|
return dc.certificate;
|
|
108
122
|
}
|
|
123
|
+
/** Hostnames a certificate covers: its domainName plus every SAN, deduped. */
|
|
124
|
+
function certificateHosts(cert) {
|
|
125
|
+
return [
|
|
126
|
+
...new Set([cert.domainName, ...(cert.subjectAlternativeNames ?? [])])
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* CloudFormation caps output values at 1024 bytes: a certificate with
|
|
131
|
+
* enough — or long enough — SANs would fail the WHOLE domain-stack deploy
|
|
132
|
+
* with an opaque CFN error if its hosts list rode one output. Hosts lists
|
|
133
|
+
* that fit keep the base export name and construct ID byte-identical to
|
|
134
|
+
* the single-output era (no logical-ID churn on deployed stacks);
|
|
135
|
+
* oversized lists are emitted as a part family (`<base>-1`, `<base>-2`, …,
|
|
136
|
+
* each a self-contained JSON array under the threshold) with the base name
|
|
137
|
+
* OMITTED — a consumer that only knows the base name reads absence as
|
|
138
|
+
* coverage-unknown, never a partial list it would misread as complete (see
|
|
139
|
+
* `hostsExportPartName`). Coverage is therefore always published; the old
|
|
140
|
+
* over-limit fail-open (skip the output, coverage permanently
|
|
141
|
+
* unverifiable) is gone. The chunking algorithm (`serialiseHostsChunks`)
|
|
142
|
+
* is homed in @fjall/util so the CLI's eject templates split identically.
|
|
143
|
+
*/
|
|
144
|
+
function emitHostsOutputs(scope, keyPrefix, baseExportName, hosts) {
|
|
145
|
+
const chunks = serialiseHostsChunks(hosts);
|
|
146
|
+
const single = chunks.length === 1 ? chunks[0] : undefined;
|
|
147
|
+
if (single !== undefined) {
|
|
148
|
+
new CfnOutput(scope, keyPrefix, {
|
|
149
|
+
key: keyPrefix,
|
|
150
|
+
value: single,
|
|
151
|
+
exportName: baseExportName
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
chunks.forEach((chunk, index) => {
|
|
156
|
+
const id = `${keyPrefix}${index + 1}`;
|
|
157
|
+
new CfnOutput(scope, id, {
|
|
158
|
+
key: id,
|
|
159
|
+
value: chunk,
|
|
160
|
+
exportName: hostsExportPartName(baseExportName, index + 1)
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The zone-level `<zone>-us-east-1-certificate-hosts` output (D5 companion
|
|
166
|
+
* to `emitUsEast1Export`): the hostnames the viewer certificate covers,
|
|
167
|
+
* always minted on the MAIN domain stack so the D2 DescribeStacks read
|
|
168
|
+
* reaches it in one call regardless of where the certificate itself lives.
|
|
169
|
+
*/
|
|
170
|
+
function emitUsEast1HostsOutput(scope, composition, cert) {
|
|
171
|
+
const exports = getDomainExportNames(composition.effectiveZoneName);
|
|
172
|
+
emitHostsOutputs(scope, `${composition.safeZone}UsEast1CertificateHosts`, exports.usEast1CertificateHosts, certificateHosts(cert));
|
|
173
|
+
}
|
|
109
174
|
/**
|
|
110
175
|
* The zone-level `<zone>-us-east-1-certificate-arn` export
|
|
111
176
|
* (`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.
|