@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,174 @@
|
|
|
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 mirrors `registerServiceWithALB`'s exact branching
|
|
13
|
+
* (including the single-service conditions-dropped branch), and the covered
|
|
14
|
+
* SAN set is the one `addHostedZone` mints. Never recompute these facts from
|
|
15
|
+
* cluster props elsewhere; extend the profile instead.
|
|
16
|
+
*/
|
|
17
|
+
import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
18
|
+
import type { IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
19
|
+
import type { EcsServiceProps } from "./ecsTypes.js";
|
|
20
|
+
/**
|
|
21
|
+
* What the certificates ATTACHED to the cluster's listener are known to
|
|
22
|
+
* cover at synth:
|
|
23
|
+
*
|
|
24
|
+
* - `enumerated` — every attached certificate's host set is synth-known
|
|
25
|
+
* (cluster-minted certs, or managed certs described by the D5 coverage
|
|
26
|
+
* context). `hostnames` is complete: a non-matching name is provably
|
|
27
|
+
* uncovered.
|
|
28
|
+
* - `partial` — `hostnames` collects the known certs' hosts, but at least
|
|
29
|
+
* one attached certificate is opaque (imported ARN with no coverage
|
|
30
|
+
* context), so a non-matching name may still be covered.
|
|
31
|
+
* - `unknown` — only opaque certificates are attached.
|
|
32
|
+
*/
|
|
33
|
+
export type CertificateCoverage = {
|
|
34
|
+
kind: "enumerated";
|
|
35
|
+
hostnames: string[];
|
|
36
|
+
} | {
|
|
37
|
+
kind: "partial";
|
|
38
|
+
hostnames: string[];
|
|
39
|
+
} | {
|
|
40
|
+
kind: "unknown";
|
|
41
|
+
};
|
|
42
|
+
export type CoverageAnswer = "covered" | "unknown" | "not-covered";
|
|
43
|
+
/**
|
|
44
|
+
* One listener rule as it will actually be emitted. `host` and `path` are
|
|
45
|
+
* ANDed when both present (`buildRoutingConditions`). Rules the emitter
|
|
46
|
+
* DROPS — a single ports-bearing service with ≤1 routing rule loses its
|
|
47
|
+
* conditions and becomes the listener default (`registerServiceWithALB`) —
|
|
48
|
+
* are deliberately absent here, exactly as they are absent from the ALB.
|
|
49
|
+
*/
|
|
50
|
+
export interface EcsIngressRule {
|
|
51
|
+
host?: string;
|
|
52
|
+
path?: string;
|
|
53
|
+
kind: "forward" | "redirect";
|
|
54
|
+
}
|
|
55
|
+
export interface EcsIngressProfile {
|
|
56
|
+
loadBalancer: IApplicationLoadBalancer;
|
|
57
|
+
/** `cluster.loadBalancer === "internal"` — unreachable as a CloudFront origin. */
|
|
58
|
+
internal: boolean;
|
|
59
|
+
/** 443 iff a certificate resolved — the cluster ALB has exactly one listener. */
|
|
60
|
+
listenerPort: 443 | 80;
|
|
61
|
+
/**
|
|
62
|
+
* The cluster's resolved zone — the SAME `IHostedZone` instance the
|
|
63
|
+
* cluster's own records claim with, so a downstream record (the Cdn's
|
|
64
|
+
* origin record) claims under the identical zone identity. The DNS claim
|
|
65
|
+
* registry keys on the literal hosted-zone ID; a name-derived re-import
|
|
66
|
+
* would register under a different key and silently defeat same-name
|
|
67
|
+
* collision detection.
|
|
68
|
+
*/
|
|
69
|
+
hostedZone?: IHostedZone;
|
|
70
|
+
zoneName?: string;
|
|
71
|
+
domainName?: string;
|
|
72
|
+
/** Every `services[].routing[].host`, deduped, in declaration order. */
|
|
73
|
+
routedHosts: string[];
|
|
74
|
+
redirectHosts: string[];
|
|
75
|
+
/**
|
|
76
|
+
* The listener's actual default-action predicate: `true` → fixed-404
|
|
77
|
+
* default; `false` → the sole target group IS the default action and the
|
|
78
|
+
* listener forwards EVERY hostname.
|
|
79
|
+
*/
|
|
80
|
+
default404: boolean;
|
|
81
|
+
rules: EcsIngressRule[];
|
|
82
|
+
certificateCoverage: CertificateCoverage;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Zone-derived profile ingredients assembled by `addHostedZone`, where the
|
|
86
|
+
* zone, hosts, and certificate coverage are already computed for emission.
|
|
87
|
+
*/
|
|
88
|
+
export interface EcsIngressZoneFacts {
|
|
89
|
+
hostedZone: IHostedZone;
|
|
90
|
+
zoneName: string;
|
|
91
|
+
domainName: string;
|
|
92
|
+
routedHosts: string[];
|
|
93
|
+
redirectHosts: string[];
|
|
94
|
+
/** False when `apexRecord: "none"` yielded the domainName record. */
|
|
95
|
+
apexRecordMinted: boolean;
|
|
96
|
+
certificateCoverage: CertificateCoverage;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The listener's default-action predicate — the SINGLE home of the
|
|
100
|
+
* computation `addLoadBalancerListener` passes to the listener factory as
|
|
101
|
+
* `default404`. `true` when ≥2 routes will exist (the emitter then attaches
|
|
102
|
+
* a fixed-404 default and conditions every rule) or when no service has a
|
|
103
|
+
* port (CDK rejects a listener with neither a default action nor targets).
|
|
104
|
+
*/
|
|
105
|
+
export declare function computeListenerDefault404(services: EcsServiceProps[]): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The listener's rule structure as `registerServiceWithALB` and
|
|
108
|
+
* `addRedirectHostRules` will emit it. Mirrors the emitters' branching
|
|
109
|
+
* exactly: a single ports-bearing service with ≤1 rule contributes NO
|
|
110
|
+
* conditioned rule (its target group is the listener default — even a
|
|
111
|
+
* declared `routing.host` does not gate it), and every redirect host
|
|
112
|
+
* contributes a host-matched 301 rule.
|
|
113
|
+
*/
|
|
114
|
+
export declare function enumerateListenerRules(services: EcsServiceProps[], redirectHosts: string[]): EcsIngressRule[];
|
|
115
|
+
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
116
|
+
export declare function normaliseDnsName(name: string): string;
|
|
117
|
+
/**
|
|
118
|
+
* RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
|
|
119
|
+
* (matches exactly one additional label — `*.example.com` covers
|
|
120
|
+
* `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
|
|
121
|
+
* compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
|
|
122
|
+
*/
|
|
123
|
+
export declare function certNameMatches(pattern: string, hostname: string): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Does the listener's attached-certificate set cover `hostname`?
|
|
126
|
+
* Fail closed where decidable, honest where opaque (P3): only `enumerated`
|
|
127
|
+
* coverage may answer `not-covered`; a miss under `partial`/`unknown` is
|
|
128
|
+
* `unknown` because an opaque certificate may still cover the name.
|
|
129
|
+
*/
|
|
130
|
+
export declare function certificateCovers(coverage: CertificateCoverage, hostname: string): CoverageAnswer;
|
|
131
|
+
export type ForwardingVerdict = {
|
|
132
|
+
verdict: "forwards";
|
|
133
|
+
} | {
|
|
134
|
+
verdict: "partial";
|
|
135
|
+
patterns: string[];
|
|
136
|
+
} | {
|
|
137
|
+
verdict: "none";
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Would the listener forward `Host: <hostname>` requests, for all paths?
|
|
141
|
+
* Computed on the real rule model, never a flattened hostname list:
|
|
142
|
+
*
|
|
143
|
+
* - no fixed-404 default → the default action forwards every hostname;
|
|
144
|
+
* - a host-only rule for the hostname, a host-matched catch-all `/*` path
|
|
145
|
+
* rule, or a host-less catch-all `/*` path rule, forwards all of its
|
|
146
|
+
* paths;
|
|
147
|
+
* - rules that involve the hostname only together with a narrower path
|
|
148
|
+
* condition (ANDed host+path, or host-less non-catch-all paths) forward
|
|
149
|
+
* SOME paths — `partial`, with the patterns named so the caller can
|
|
150
|
+
* surface them;
|
|
151
|
+
* - otherwise every request answers the fixed-404 default — `none`.
|
|
152
|
+
*
|
|
153
|
+
* Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
|
|
154
|
+
* carries the declared spelling, the caller's hostname may differ in case.
|
|
155
|
+
*/
|
|
156
|
+
export declare function forwardingVerdict(profile: EcsIngressProfile, hostname: string): ForwardingVerdict;
|
|
157
|
+
export declare function buildIngressProfile(options: {
|
|
158
|
+
loadBalancer: IApplicationLoadBalancer;
|
|
159
|
+
internal: boolean;
|
|
160
|
+
services: EcsServiceProps[];
|
|
161
|
+
certificateAttached: boolean;
|
|
162
|
+
zoneFacts?: EcsIngressZoneFacts;
|
|
163
|
+
}): EcsIngressProfile;
|
|
164
|
+
/**
|
|
165
|
+
* W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
|
|
166
|
+
* fixed-404 while its `domainName` has a minted apex record and NO rule
|
|
167
|
+
* forwards it answers 404 on its own primary domain — usually the aftermath
|
|
168
|
+
* of adding a second routing rule (which flips the default action from
|
|
169
|
+
* forward to 404, `computeListenerDefault404`). A warning, not an error:
|
|
170
|
+
* pre-existing clusters can already be in this state, and narrowing them is
|
|
171
|
+
* not this check's mandate. Partial forwarding (path-split services) is the
|
|
172
|
+
* normal multi-route shape and does not warn.
|
|
173
|
+
*/
|
|
174
|
+
export declare function warnWhenRecordedApexUnforwarded(profile: EcsIngressProfile, apexRecordMinted: boolean, clusterName: string): void;
|
|
@@ -0,0 +1,195 @@
|
|
|
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 mirrors `registerServiceWithALB`'s exact branching
|
|
13
|
+
* (including the single-service conditions-dropped branch), and the covered
|
|
14
|
+
* SAN set is the one `addHostedZone` mints. Never recompute these facts from
|
|
15
|
+
* cluster props elsewhere; extend the profile instead.
|
|
16
|
+
*/
|
|
17
|
+
import { FjallLogger } from "../../../utils/validationLogger.js";
|
|
18
|
+
function normaliseRoutingRules(routing) {
|
|
19
|
+
return Array.isArray(routing) ? routing : routing ? [routing] : [];
|
|
20
|
+
}
|
|
21
|
+
function servicesWithPorts(services) {
|
|
22
|
+
return services.filter((s) => s.containers.some((c) => c.port !== undefined));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The listener's default-action predicate — the SINGLE home of the
|
|
26
|
+
* computation `addLoadBalancerListener` passes to the listener factory as
|
|
27
|
+
* `default404`. `true` when ≥2 routes will exist (the emitter then attaches
|
|
28
|
+
* a fixed-404 default and conditions every rule) or when no service has a
|
|
29
|
+
* port (CDK rejects a listener with neither a default action nor targets).
|
|
30
|
+
*/
|
|
31
|
+
export function computeListenerDefault404(services) {
|
|
32
|
+
const withPorts = servicesWithPorts(services);
|
|
33
|
+
const willHaveMultipleRoutes = withPorts.length > 1 ||
|
|
34
|
+
withPorts.some((s) => normaliseRoutingRules(s.routing).length > 1);
|
|
35
|
+
return willHaveMultipleRoutes || withPorts.length === 0;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The listener's rule structure as `registerServiceWithALB` and
|
|
39
|
+
* `addRedirectHostRules` will emit it. Mirrors the emitters' branching
|
|
40
|
+
* exactly: a single ports-bearing service with ≤1 rule contributes NO
|
|
41
|
+
* conditioned rule (its target group is the listener default — even a
|
|
42
|
+
* declared `routing.host` does not gate it), and every redirect host
|
|
43
|
+
* contributes a host-matched 301 rule.
|
|
44
|
+
*/
|
|
45
|
+
export function enumerateListenerRules(services, redirectHosts) {
|
|
46
|
+
const withPorts = servicesWithPorts(services);
|
|
47
|
+
const isSingleService = withPorts.length === 1;
|
|
48
|
+
const rules = [];
|
|
49
|
+
for (const service of withPorts) {
|
|
50
|
+
const routingRules = normaliseRoutingRules(service.routing);
|
|
51
|
+
if (isSingleService && routingRules.length <= 1)
|
|
52
|
+
continue;
|
|
53
|
+
for (const rule of routingRules) {
|
|
54
|
+
if (rule.host === undefined && rule.path === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
rules.push({
|
|
57
|
+
...(rule.host !== undefined && { host: rule.host }),
|
|
58
|
+
...(rule.path !== undefined && { path: rule.path }),
|
|
59
|
+
kind: "forward"
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const host of redirectHosts) {
|
|
64
|
+
rules.push({ host, kind: "redirect" });
|
|
65
|
+
}
|
|
66
|
+
return rules;
|
|
67
|
+
}
|
|
68
|
+
/** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
|
|
69
|
+
export function normaliseDnsName(name) {
|
|
70
|
+
return name.toLowerCase().replace(/\.$/, "");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
|
|
74
|
+
* (matches exactly one additional label — `*.example.com` covers
|
|
75
|
+
* `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
|
|
76
|
+
* compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
|
|
77
|
+
*/
|
|
78
|
+
export function certNameMatches(pattern, hostname) {
|
|
79
|
+
const p = pattern.toLowerCase();
|
|
80
|
+
const h = hostname.toLowerCase();
|
|
81
|
+
if (p === h)
|
|
82
|
+
return true;
|
|
83
|
+
if (!p.startsWith("*."))
|
|
84
|
+
return false;
|
|
85
|
+
const suffix = p.slice(1); // ".example.com"
|
|
86
|
+
if (!h.endsWith(suffix))
|
|
87
|
+
return false;
|
|
88
|
+
const label = h.slice(0, h.length - suffix.length);
|
|
89
|
+
return label.length > 0 && !label.includes(".");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Does the listener's attached-certificate set cover `hostname`?
|
|
93
|
+
* Fail closed where decidable, honest where opaque (P3): only `enumerated`
|
|
94
|
+
* coverage may answer `not-covered`; a miss under `partial`/`unknown` is
|
|
95
|
+
* `unknown` because an opaque certificate may still cover the name.
|
|
96
|
+
*/
|
|
97
|
+
export function certificateCovers(coverage, hostname) {
|
|
98
|
+
if (coverage.kind === "unknown")
|
|
99
|
+
return "unknown";
|
|
100
|
+
if (coverage.hostnames.some((p) => certNameMatches(p, hostname))) {
|
|
101
|
+
return "covered";
|
|
102
|
+
}
|
|
103
|
+
return coverage.kind === "enumerated" ? "not-covered" : "unknown";
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Would the listener forward `Host: <hostname>` requests, for all paths?
|
|
107
|
+
* Computed on the real rule model, never a flattened hostname list:
|
|
108
|
+
*
|
|
109
|
+
* - no fixed-404 default → the default action forwards every hostname;
|
|
110
|
+
* - a host-only rule for the hostname, a host-matched catch-all `/*` path
|
|
111
|
+
* rule, or a host-less catch-all `/*` path rule, forwards all of its
|
|
112
|
+
* paths;
|
|
113
|
+
* - rules that involve the hostname only together with a narrower path
|
|
114
|
+
* condition (ANDed host+path, or host-less non-catch-all paths) forward
|
|
115
|
+
* SOME paths — `partial`, with the patterns named so the caller can
|
|
116
|
+
* surface them;
|
|
117
|
+
* - otherwise every request answers the fixed-404 default — `none`.
|
|
118
|
+
*
|
|
119
|
+
* Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
|
|
120
|
+
* carries the declared spelling, the caller's hostname may differ in case.
|
|
121
|
+
*/
|
|
122
|
+
export function forwardingVerdict(profile, hostname) {
|
|
123
|
+
if (!profile.default404)
|
|
124
|
+
return { verdict: "forwards" };
|
|
125
|
+
const wanted = normaliseDnsName(hostname);
|
|
126
|
+
const partial = [];
|
|
127
|
+
for (const rule of profile.rules) {
|
|
128
|
+
if (rule.kind !== "forward")
|
|
129
|
+
continue;
|
|
130
|
+
const hostMatches = rule.host !== undefined && normaliseDnsName(rule.host) === wanted;
|
|
131
|
+
if (hostMatches && (rule.path === undefined || rule.path === "/*")) {
|
|
132
|
+
return { verdict: "forwards" };
|
|
133
|
+
}
|
|
134
|
+
if (rule.host === undefined && rule.path === "/*") {
|
|
135
|
+
return { verdict: "forwards" };
|
|
136
|
+
}
|
|
137
|
+
if (hostMatches && rule.path !== undefined) {
|
|
138
|
+
partial.push(`Host=${rule.host} AND Path=${rule.path}`);
|
|
139
|
+
}
|
|
140
|
+
else if (rule.host === undefined && rule.path !== undefined) {
|
|
141
|
+
partial.push(`Path=${rule.path} (any host)`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return partial.length > 0
|
|
145
|
+
? { verdict: "partial", patterns: partial }
|
|
146
|
+
: { verdict: "none" };
|
|
147
|
+
}
|
|
148
|
+
export function buildIngressProfile(options) {
|
|
149
|
+
const { zoneFacts } = options;
|
|
150
|
+
const redirectHosts = zoneFacts?.redirectHosts ?? [];
|
|
151
|
+
return {
|
|
152
|
+
loadBalancer: options.loadBalancer,
|
|
153
|
+
internal: options.internal,
|
|
154
|
+
listenerPort: options.certificateAttached ? 443 : 80,
|
|
155
|
+
...(zoneFacts !== undefined && {
|
|
156
|
+
hostedZone: zoneFacts.hostedZone,
|
|
157
|
+
zoneName: zoneFacts.zoneName,
|
|
158
|
+
domainName: zoneFacts.domainName
|
|
159
|
+
}),
|
|
160
|
+
routedHosts: zoneFacts?.routedHosts ?? [],
|
|
161
|
+
redirectHosts,
|
|
162
|
+
default404: computeListenerDefault404(options.services),
|
|
163
|
+
rules: enumerateListenerRules(options.services, redirectHosts),
|
|
164
|
+
certificateCoverage: zoneFacts?.certificateCoverage ?? {
|
|
165
|
+
// No domain → no certificates attached: the attached set is fully
|
|
166
|
+
// known (empty), which is the honest enumerated answer, not "unknown".
|
|
167
|
+
kind: "enumerated",
|
|
168
|
+
hostnames: []
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
|
|
174
|
+
* fixed-404 while its `domainName` has a minted apex record and NO rule
|
|
175
|
+
* forwards it answers 404 on its own primary domain — usually the aftermath
|
|
176
|
+
* of adding a second routing rule (which flips the default action from
|
|
177
|
+
* forward to 404, `computeListenerDefault404`). A warning, not an error:
|
|
178
|
+
* pre-existing clusters can already be in this state, and narrowing them is
|
|
179
|
+
* not this check's mandate. Partial forwarding (path-split services) is the
|
|
180
|
+
* normal multi-route shape and does not warn.
|
|
181
|
+
*/
|
|
182
|
+
export function warnWhenRecordedApexUnforwarded(profile, apexRecordMinted, clusterName) {
|
|
183
|
+
if (!profile.default404)
|
|
184
|
+
return;
|
|
185
|
+
if (profile.domainName === undefined || !apexRecordMinted)
|
|
186
|
+
return;
|
|
187
|
+
if (forwardingVerdict(profile, profile.domainName).verdict !== "none")
|
|
188
|
+
return;
|
|
189
|
+
FjallLogger.warn(`Cluster '${clusterName}': the listener's default action is a fixed 404 ` +
|
|
190
|
+
`and no routing rule forwards '${profile.domainName}', but its alias ` +
|
|
191
|
+
"record is minted — requests to the cluster's own domain will answer " +
|
|
192
|
+
"404 Not Found. Multiple routing rules flip the listener default from " +
|
|
193
|
+
"forward to 404; keep a forwarding path for the domain, e.g. a " +
|
|
194
|
+
'routing rule { path: "/*" } on the service that should serve it.');
|
|
195
|
+
}
|
|
@@ -20,9 +20,18 @@
|
|
|
20
20
|
* layer (generator-standards § Infrastructure Layer Boundaries).
|
|
21
21
|
*/
|
|
22
22
|
import type { Node } from "constructs";
|
|
23
|
-
import type { ManagedDomainBinding } from "@fjall/util";
|
|
23
|
+
import type { ManagedDomainBinding, ManagedDomainCoverage } 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,19 @@ 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;
|
|
@@ -23,6 +23,17 @@ export const MANAGED_DOMAIN_CONTEXT_PREFIX = "fjall:managedDomain:";
|
|
|
23
23
|
export function getManagedDomainContextKey(zoneName) {
|
|
24
24
|
return `${MANAGED_DOMAIN_CONTEXT_PREFIX}${zoneName}`;
|
|
25
25
|
}
|
|
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 const MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX = "fjall:managedDomainCoverage:";
|
|
34
|
+
export function getManagedDomainCoverageContextKey(zoneName) {
|
|
35
|
+
return `${MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX}${zoneName}`;
|
|
36
|
+
}
|
|
26
37
|
const REQUIRED_STRING_FIELDS = ["zoneName", "hostedZoneId"];
|
|
27
38
|
const OPTIONAL_STRING_FIELDS = [
|
|
28
39
|
"certificateArn",
|
|
@@ -56,6 +67,64 @@ export function readInjectedManagedDomainBinding(node, domainName, context) {
|
|
|
56
67
|
}
|
|
57
68
|
return undefined;
|
|
58
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Read the CLI-injected {@link ManagedDomainCoverage} for the zone a binding
|
|
72
|
+
* already resolved to. Exact-key lookup on purpose (no parent-zone walk):
|
|
73
|
+
* the CLI injects coverage for precisely the zones it injects bindings for,
|
|
74
|
+
* and the caller passes the binding's own `zoneName`.
|
|
75
|
+
*
|
|
76
|
+
* Absent → undefined (a domain stack that predates the hosts outputs, or a
|
|
77
|
+
* bare-CDK synth) — consumers treat that as coverage-unknown and warn, never
|
|
78
|
+
* as not-covered. A present-but-corrupt value still throws (repo posture:
|
|
79
|
+
* corrupt context fails the synth rather than falling through), but UNKNOWN
|
|
80
|
+
* FIELDS ARE IGNORED, unlike the binding parser: coverage is advisory
|
|
81
|
+
* validation input, and forward tolerance here is what lets a future CLI add
|
|
82
|
+
* coverage fields without breaking older engines — the exact trap that
|
|
83
|
+
* forced this channel off the binding JSON in the first place.
|
|
84
|
+
*/
|
|
85
|
+
export function readInjectedManagedDomainCoverage(node, zoneName, context) {
|
|
86
|
+
const key = getManagedDomainCoverageContextKey(zoneName);
|
|
87
|
+
const raw = node.tryGetContext(key);
|
|
88
|
+
if (raw === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
let value = raw;
|
|
91
|
+
if (typeof raw === "string") {
|
|
92
|
+
try {
|
|
93
|
+
value = JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new Error(`${context}: CDK context '${key}' is not valid JSON (got '${raw}'). ` +
|
|
97
|
+
"The value must be a JSON ManagedDomainCoverage " +
|
|
98
|
+
"({ certificateHosts?, usEast1CertificateHosts? }) — re-run the " +
|
|
99
|
+
"deploy through the Fjall CLI, or correct the hand-set context " +
|
|
100
|
+
"entry.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
104
|
+
throw new Error(`${context}: CDK context '${key}' must be a JSON object ` +
|
|
105
|
+
`ManagedDomainCoverage (got ${JSON.stringify(value)}). Re-run the ` +
|
|
106
|
+
"deploy through the Fjall CLI, or correct the hand-set context entry.");
|
|
107
|
+
}
|
|
108
|
+
const record = value;
|
|
109
|
+
const coverage = {};
|
|
110
|
+
for (const field of [
|
|
111
|
+
"certificateHosts",
|
|
112
|
+
"usEast1CertificateHosts"
|
|
113
|
+
]) {
|
|
114
|
+
const fieldValue = record[field];
|
|
115
|
+
if (fieldValue === undefined)
|
|
116
|
+
continue;
|
|
117
|
+
if (!Array.isArray(fieldValue) ||
|
|
118
|
+
fieldValue.some((h) => typeof h !== "string" || h === "")) {
|
|
119
|
+
throw new Error(`${context}: CDK context '${key}' field '${field}' must be an array ` +
|
|
120
|
+
`of non-empty strings when present (got ` +
|
|
121
|
+
`${JSON.stringify(fieldValue)}). Re-run the deploy through the ` +
|
|
122
|
+
"Fjall CLI, or correct the hand-set context entry.");
|
|
123
|
+
}
|
|
124
|
+
coverage[field] = fieldValue;
|
|
125
|
+
}
|
|
126
|
+
return coverage;
|
|
127
|
+
}
|
|
59
128
|
function parseManagedDomainBinding(raw, zoneName, key, context) {
|
|
60
129
|
let value = raw;
|
|
61
130
|
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.2.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.2.0",
|
|
84
|
+
"@fjall/util": "^14.2.0",
|
|
85
85
|
"constructs": "^10.7.2"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|