@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
|
@@ -33,12 +33,22 @@ export interface StandardRecord {
|
|
|
33
33
|
readonly name: string;
|
|
34
34
|
readonly value: string | string[];
|
|
35
35
|
readonly ttl?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Overrides the record's derived construct-id name segment (PascalCase
|
|
38
|
+
* alphanumeric). The rare escape hatch for two record names that sanitise
|
|
39
|
+
* to the same PascalCase segment under `recordIds: "stable"` — the
|
|
40
|
+
* composer's collision refusal names it. Round-tripped by the generator's
|
|
41
|
+
* zone importer and the webapp record splice.
|
|
42
|
+
*/
|
|
43
|
+
readonly id?: string;
|
|
36
44
|
}
|
|
37
45
|
export interface AliasRecord {
|
|
38
46
|
readonly type: "A" | "AAAA";
|
|
39
47
|
readonly name: string;
|
|
40
48
|
readonly target: FjallTarget;
|
|
41
49
|
readonly ttl?: never;
|
|
50
|
+
/** Same construct-id override as {@link StandardRecord.id}. */
|
|
51
|
+
readonly id?: string;
|
|
42
52
|
}
|
|
43
53
|
export type DnsRecord = StandardRecord | AliasRecord;
|
|
44
54
|
export type Certificate = string | {
|
|
@@ -65,6 +75,28 @@ export type Certificate = string | {
|
|
|
65
75
|
export interface DomainCommonProps {
|
|
66
76
|
readonly zoneName: string;
|
|
67
77
|
readonly records?: DnsRecord[];
|
|
78
|
+
/**
|
|
79
|
+
* Construct-id scheme for the records list.
|
|
80
|
+
*
|
|
81
|
+
* `"indexed"` (default) embeds each record's LIST POSITION in its logical
|
|
82
|
+
* ID (`...Record0`, `...Record1`, …) — the legacy eject-contract formula,
|
|
83
|
+
* byte-frozen for deployed stacks. Under it the records list is
|
|
84
|
+
* APPEND-ONLY: inserting or removing an entry renumbers every later
|
|
85
|
+
* record, which CloudFormation executes as delete + recreate of live
|
|
86
|
+
* record sets.
|
|
87
|
+
*
|
|
88
|
+
* `"stable"` derives position-independent IDs
|
|
89
|
+
* (`<Zone><Name><Type>Record`) so entries can be inserted, removed and
|
|
90
|
+
* reordered freely. Collision-free by the same doctrine the DNS claim
|
|
91
|
+
* registry enforces (one owner per (zone, name, type); this vocabulary
|
|
92
|
+
* cannot express routing-policy variants) — the composer refuses
|
|
93
|
+
* duplicate (name, type) pairs and sanitised-name collisions at synth,
|
|
94
|
+
* naming the per-record `id` escape hatch. `fjall domain import` emits it
|
|
95
|
+
* for fresh zones. Flipping an ALREADY-DEPLOYED zone renames every
|
|
96
|
+
* record's logical ID (delete + recreate at deploy) — a deliberate
|
|
97
|
+
* two-deploy migration, never a casual edit.
|
|
98
|
+
*/
|
|
99
|
+
readonly recordIds?: "indexed" | "stable";
|
|
68
100
|
readonly certificates?: Certificate[];
|
|
69
101
|
readonly tags?: Record<string, string>;
|
|
70
102
|
readonly description?: string;
|
|
@@ -57,6 +57,15 @@ export interface ResolvedPatternZone {
|
|
|
57
57
|
readonly hostedZone: IHostedZone;
|
|
58
58
|
readonly zoneName: string;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the hosted zone for a pattern domain from explicit identity only
|
|
62
|
+
* (H2). Throws when no identity is given or when the domain falls outside
|
|
63
|
+
* the declared zone. The D2 precedence (explicit `managedDomain` beats the
|
|
64
|
+
* CLI-injected binding, ahead of the BYO `zoneName`/`hostedZoneId` chain)
|
|
65
|
+
* and the binding-vs-exports zone-id branch live in the shared
|
|
66
|
+
* `resolveEffectiveManagedDomain` — the same resolution the ECS networking
|
|
67
|
+
* lane consumes.
|
|
68
|
+
*/
|
|
60
69
|
export declare function resolvePatternZone(request: PatternDomainRequest): ResolvedPatternZone;
|
|
61
70
|
/**
|
|
62
71
|
* Resolve the CloudFront viewer certificate for a pattern domain (H3/D3).
|
|
@@ -30,37 +30,30 @@ import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
|
30
30
|
import { HostedZone } from "aws-cdk-lib/aws-route53";
|
|
31
31
|
import { DomainCertificate } from "../../resources/aws/networking/index.js";
|
|
32
32
|
import { isManagedDomainBinding, isWithinZone } from "../../utils/domainTypes.js";
|
|
33
|
-
import {
|
|
33
|
+
import { readInjectedManagedDomainCoverage, resolveEffectiveManagedDomain } from "../../utils/managedDomainContext.js";
|
|
34
|
+
import { certNameMatches } from "../../resources/aws/compute/ingressProfile.js";
|
|
35
|
+
import { cdnOriginRefusal } from "./cdnAppOrigin.js";
|
|
34
36
|
const US_EAST_1 = "us-east-1";
|
|
35
37
|
/**
|
|
36
38
|
* Resolve the hosted zone for a pattern domain from explicit identity only
|
|
37
|
-
* (H2). Throws when no identity is given or when the domain falls outside
|
|
38
|
-
* declared zone.
|
|
39
|
+
* (H2). Throws when no identity is given or when the domain falls outside
|
|
40
|
+
* the declared zone. The D2 precedence (explicit `managedDomain` beats the
|
|
41
|
+
* CLI-injected binding, ahead of the BYO `zoneName`/`hostedZoneId` chain)
|
|
42
|
+
* and the binding-vs-exports zone-id branch live in the shared
|
|
43
|
+
* `resolveEffectiveManagedDomain` — the same resolution the ECS networking
|
|
44
|
+
* lane consumes.
|
|
39
45
|
*/
|
|
40
|
-
/**
|
|
41
|
-
* Effective managed-domain identity for a request (D2): an EXPLICIT
|
|
42
|
-
* `managedDomain` prop wins over the CLI-injected context binding (explicit
|
|
43
|
-
* beats injected), and the context read sits ahead of the BYO
|
|
44
|
-
* `zoneName`/`hostedZoneId` chain. Bare-CDK synth carries no context entry
|
|
45
|
-
* and falls straight through to the explicit props.
|
|
46
|
-
*/
|
|
47
|
-
function resolveEffectiveManagedDomain(request) {
|
|
48
|
-
return (request.identity.managedDomain ??
|
|
49
|
-
readInjectedManagedDomainBinding(request.scope.node, request.domain, request.context));
|
|
50
|
-
}
|
|
51
46
|
export function resolvePatternZone(request) {
|
|
52
47
|
const { scope, idPrefix, context, domain, identity } = request;
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
assertDomainWithinZone(context, domain,
|
|
56
|
-
const hostedZoneId = isManagedDomainBinding(managed)
|
|
57
|
-
? // CLI-injected literal (D2) — carries no Fn.importValue constraint.
|
|
58
|
-
managed.hostedZoneId
|
|
59
|
-
: // Legacy export-name fallback — same-account, same-region only.
|
|
60
|
-
Fn.importValue(managed.hostedZoneIdExport);
|
|
48
|
+
const effective = effectiveManagedDomainFor(request);
|
|
49
|
+
if (effective !== undefined) {
|
|
50
|
+
assertDomainWithinZone(context, domain, effective.zoneName);
|
|
61
51
|
return {
|
|
62
|
-
hostedZone: HostedZone.fromHostedZoneAttributes(scope, `${idPrefix}ManagedHostedZone`, {
|
|
63
|
-
|
|
52
|
+
hostedZone: HostedZone.fromHostedZoneAttributes(scope, `${idPrefix}ManagedHostedZone`, {
|
|
53
|
+
hostedZoneId: effective.hostedZoneId,
|
|
54
|
+
zoneName: effective.zoneName
|
|
55
|
+
}),
|
|
56
|
+
zoneName: effective.zoneName
|
|
64
57
|
};
|
|
65
58
|
}
|
|
66
59
|
if (identity.zoneName !== undefined) {
|
|
@@ -110,8 +103,11 @@ export function resolvePatternZone(request) {
|
|
|
110
103
|
*/
|
|
111
104
|
export function resolvePatternCloudFrontCertificate(request, zone) {
|
|
112
105
|
const { scope, app, idPrefix, context, domain, identity } = request;
|
|
113
|
-
const
|
|
114
|
-
|
|
106
|
+
const effective = effectiveManagedDomainFor(request);
|
|
107
|
+
const managed = effective?.managed;
|
|
108
|
+
if (effective !== undefined &&
|
|
109
|
+
managed !== undefined &&
|
|
110
|
+
isManagedDomainBinding(managed)) {
|
|
115
111
|
const arn = managed.usEast1CertificateArn;
|
|
116
112
|
if (arn === undefined) {
|
|
117
113
|
throw new Error(`${context}: managed domain binding for zone '${managed.zoneName}' ` +
|
|
@@ -122,10 +118,13 @@ export function resolvePatternCloudFrontCertificate(request, zone) {
|
|
|
122
118
|
"'zoneName' to provision an app-owned us-east-1 certificate.");
|
|
123
119
|
}
|
|
124
120
|
assertUsEast1CertificateArn(context, arn);
|
|
121
|
+
assertViewerHostCovered(request, effective);
|
|
125
122
|
return Certificate.fromCertificateArn(scope, `${idPrefix}ManagedCertificate`, arn);
|
|
126
123
|
}
|
|
127
124
|
const region = resolvedRegionOf(scope);
|
|
128
|
-
if (managed !== undefined &&
|
|
125
|
+
if (managed !== undefined &&
|
|
126
|
+
!isManagedDomainBinding(managed) &&
|
|
127
|
+
(region === undefined || region === US_EAST_1)) {
|
|
129
128
|
// Legacy export-name form: Fn.importValue resolves same-account,
|
|
130
129
|
// same-region only, so the imported certificate satisfies CloudFront
|
|
131
130
|
// exactly when the app itself deploys to us-east-1. A region-unresolved
|
|
@@ -170,6 +169,40 @@ export function resolvePatternCloudFrontCertificate(request, zone) {
|
|
|
170
169
|
exportCertificateArn: false
|
|
171
170
|
}).certificate;
|
|
172
171
|
}
|
|
172
|
+
function effectiveManagedDomainFor(request) {
|
|
173
|
+
return resolveEffectiveManagedDomain(request.scope.node, request.identity.managedDomain, request.domain, request.context);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* E11 — the requested viewer hostname against the managed us-east-1 viewer
|
|
177
|
+
* certificate's declared hosts (D5 coverage context). A definite miss fails
|
|
178
|
+
* CloudFront's own alias-vs-certificate validation at deploy with an opaque
|
|
179
|
+
* InvalidViewerCertificate — refuse at synth with the cure instead. Absent
|
|
180
|
+
* or provenance-gated coverage stays SILENT (no warning): CloudFront
|
|
181
|
+
* validates at deploy regardless, so unknown coverage carries no runtime
|
|
182
|
+
* risk here — unlike the origin lane's W1, where an uncovered hostname only
|
|
183
|
+
* fails at request time.
|
|
184
|
+
*/
|
|
185
|
+
function assertViewerHostCovered(request, effective) {
|
|
186
|
+
// D5 provenance gate: an explicit pinned binding may name an OLDER
|
|
187
|
+
// certificate the current coverage does not describe.
|
|
188
|
+
if (effective.explicitPinnedBinding)
|
|
189
|
+
return;
|
|
190
|
+
const coverage = readInjectedManagedDomainCoverage(request.scope.node, effective.zoneName, request.context);
|
|
191
|
+
const hosts = coverage?.usEast1CertificateHosts;
|
|
192
|
+
if (hosts === undefined || hosts.length === 0)
|
|
193
|
+
return;
|
|
194
|
+
if (hosts.some((pattern) => certNameMatches(pattern, request.domain))) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
throw cdnOriginRefusal("E11", `${request.context}: the managed domain's us-east-1 viewer certificate ` +
|
|
198
|
+
`covers ${hosts.join(", ")} — none matches '${request.domain}'. ` +
|
|
199
|
+
"CloudFront rejects a distribution whose alias its viewer " +
|
|
200
|
+
"certificate does not cover (InvalidViewerCertificate, at deploy). " +
|
|
201
|
+
`Declare '${request.domain}' (or a covering wildcard) on the domain ` +
|
|
202
|
+
"stack's cloudFront certificate and redeploy it, or set 'zoneName' " +
|
|
203
|
+
"instead of the managed domain to provision an app-owned us-east-1 " +
|
|
204
|
+
"certificate.");
|
|
205
|
+
}
|
|
173
206
|
function assertDomainWithinZone(context, domain, zoneName) {
|
|
174
207
|
if (!isWithinZone(domain, zoneName)) {
|
|
175
208
|
throw new Error(`${context}: domain '${domain}' is not within zone '${zoneName}' ` +
|
|
@@ -90,6 +90,11 @@ export interface CloudFrontDistributionProps {
|
|
|
90
90
|
export declare class CloudFrontDistribution extends Construct {
|
|
91
91
|
readonly id: string;
|
|
92
92
|
private distribution;
|
|
93
|
+
private readonly origins;
|
|
94
|
+
/** Shared S3 access identities, keyed by the owning stack (see
|
|
95
|
+
* `s3OriginAccessIdentityFor` for why the key is a Stack). */
|
|
96
|
+
private readonly s3AccessIdentities;
|
|
97
|
+
private s3AccessControl?;
|
|
93
98
|
constructor(scope: Construct, id: string, props: CloudFrontDistributionProps);
|
|
94
99
|
/**
|
|
95
100
|
* Build a composable CloudFront Function for VIEWER_REQUEST.
|
|
@@ -107,7 +112,39 @@ export declare class CloudFrontDistribution extends Construct {
|
|
|
107
112
|
/** SPA fallback: serve /index.html @200 for 403/404 so client-side routing
|
|
108
113
|
* handles the path. CloudFront returns 403 (not 404) for missing S3 keys. */
|
|
109
114
|
private buildSpaErrorResponses;
|
|
115
|
+
/**
|
|
116
|
+
* One `IOrigin` per distinct origin configuration: behaviours that
|
|
117
|
+
* resolve to the same origin (same bucket/ALB/hostname with the same
|
|
118
|
+
* options) share a single distribution origin entry instead of minting an
|
|
119
|
+
* identical one per behaviour. Token-bearing hostnames (e.g. a Lambda
|
|
120
|
+
* function URL) memoize per token instance — two independently-derived
|
|
121
|
+
* tokens never share, which is the safe direction.
|
|
122
|
+
*/
|
|
110
123
|
private createOrigin;
|
|
124
|
+
private static originKey;
|
|
125
|
+
/**
|
|
126
|
+
* S3 access identities are created HERE, at a position-independent scope,
|
|
127
|
+
* and handed to `S3BucketOrigin` — never left to CDK's default, which
|
|
128
|
+
* creates them under the distribution's positional `Origin${n}` child.
|
|
129
|
+
* An OAI/OAC is semantically "the identity this distribution presents to
|
|
130
|
+
* S3": one per distribution, shared by every S3 origin. Position-scoped
|
|
131
|
+
* identities couple a DEPLOYED IAM resource's logical ID to the origin
|
|
132
|
+
* list's order — origin dedup (or any behaviour insertion) would then
|
|
133
|
+
* silently delete/repurpose a live OAI and atomically swap the bucket
|
|
134
|
+
* policy it gates, 403-ing the paths the edge still serves with the old
|
|
135
|
+
* identity until the distribution update propagates.
|
|
136
|
+
*
|
|
137
|
+
* Cross-stack buckets get their identity in the BUCKET's stack (same rule
|
|
138
|
+
* CDK's default applies): the bucket policy must reference the OAI, and a
|
|
139
|
+
* distribution-stack OAI would complete a cycle with the CDN stack's
|
|
140
|
+
* reference to the bucket.
|
|
141
|
+
*/
|
|
142
|
+
private s3OriginAccessIdentityFor;
|
|
143
|
+
/** OAC twin of `s3OriginAccessIdentityFor` — an OAC is a signing config,
|
|
144
|
+
* not a bucket grant (bucket policies key on the distribution ARN), so a
|
|
145
|
+
* single distribution-scoped one serves every OAC origin. */
|
|
146
|
+
private s3OriginAccessControl;
|
|
147
|
+
private buildOrigin;
|
|
111
148
|
private resolveCachePolicy;
|
|
112
149
|
private resolveAllowedMethods;
|
|
113
150
|
private resolveOriginProtocolPolicy;
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { CfnOutput } from "aws-cdk-lib";
|
|
2
|
+
import { CfnOutput, Names, Stack } from "aws-cdk-lib";
|
|
3
3
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
4
4
|
import { cdnDomainExportName } from "@fjall/util";
|
|
5
|
-
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType } from "aws-cdk-lib/aws-cloudfront";
|
|
5
|
+
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
|
|
6
6
|
import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
|
|
7
7
|
export class CloudFrontDistribution extends Construct {
|
|
8
8
|
id;
|
|
9
9
|
distribution;
|
|
10
|
+
origins = new Map();
|
|
11
|
+
/** Shared S3 access identities, keyed by the owning stack (see
|
|
12
|
+
* `s3OriginAccessIdentityFor` for why the key is a Stack). */
|
|
13
|
+
s3AccessIdentities = new Map();
|
|
14
|
+
s3AccessControl;
|
|
10
15
|
constructor(scope, id, props) {
|
|
11
16
|
super(scope, id);
|
|
12
17
|
this.id = id;
|
|
@@ -144,7 +149,92 @@ export class CloudFrontDistribution extends Construct {
|
|
|
144
149
|
responsePagePath: "/index.html"
|
|
145
150
|
}));
|
|
146
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* One `IOrigin` per distinct origin configuration: behaviours that
|
|
154
|
+
* resolve to the same origin (same bucket/ALB/hostname with the same
|
|
155
|
+
* options) share a single distribution origin entry instead of minting an
|
|
156
|
+
* identical one per behaviour. Token-bearing hostnames (e.g. a Lambda
|
|
157
|
+
* function URL) memoize per token instance — two independently-derived
|
|
158
|
+
* tokens never share, which is the safe direction.
|
|
159
|
+
*/
|
|
147
160
|
createOrigin(config) {
|
|
161
|
+
const key = CloudFrontDistribution.originKey(config);
|
|
162
|
+
const memoized = this.origins.get(key);
|
|
163
|
+
if (memoized !== undefined) {
|
|
164
|
+
return memoized;
|
|
165
|
+
}
|
|
166
|
+
const origin = this.buildOrigin(config);
|
|
167
|
+
this.origins.set(key, origin);
|
|
168
|
+
return origin;
|
|
169
|
+
}
|
|
170
|
+
static originKey(config) {
|
|
171
|
+
switch (config.type) {
|
|
172
|
+
case "s3":
|
|
173
|
+
return [
|
|
174
|
+
"s3",
|
|
175
|
+
config.bucket.node.addr,
|
|
176
|
+
config.originPath ?? "",
|
|
177
|
+
config.originAccess ?? ""
|
|
178
|
+
].join("|");
|
|
179
|
+
case "alb":
|
|
180
|
+
return [
|
|
181
|
+
"alb",
|
|
182
|
+
config.loadBalancer.node.addr,
|
|
183
|
+
config.httpPort ?? "",
|
|
184
|
+
config.httpsPort ?? "",
|
|
185
|
+
config.protocolPolicy ?? ""
|
|
186
|
+
].join("|");
|
|
187
|
+
case "http":
|
|
188
|
+
return [
|
|
189
|
+
"http",
|
|
190
|
+
config.domainName,
|
|
191
|
+
config.originPath ?? "",
|
|
192
|
+
config.httpPort ?? "",
|
|
193
|
+
config.httpsPort ?? "",
|
|
194
|
+
config.protocolPolicy ?? ""
|
|
195
|
+
].join("|");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* S3 access identities are created HERE, at a position-independent scope,
|
|
200
|
+
* and handed to `S3BucketOrigin` — never left to CDK's default, which
|
|
201
|
+
* creates them under the distribution's positional `Origin${n}` child.
|
|
202
|
+
* An OAI/OAC is semantically "the identity this distribution presents to
|
|
203
|
+
* S3": one per distribution, shared by every S3 origin. Position-scoped
|
|
204
|
+
* identities couple a DEPLOYED IAM resource's logical ID to the origin
|
|
205
|
+
* list's order — origin dedup (or any behaviour insertion) would then
|
|
206
|
+
* silently delete/repurpose a live OAI and atomically swap the bucket
|
|
207
|
+
* policy it gates, 403-ing the paths the edge still serves with the old
|
|
208
|
+
* identity until the distribution update propagates.
|
|
209
|
+
*
|
|
210
|
+
* Cross-stack buckets get their identity in the BUCKET's stack (same rule
|
|
211
|
+
* CDK's default applies): the bucket policy must reference the OAI, and a
|
|
212
|
+
* distribution-stack OAI would complete a cycle with the CDN stack's
|
|
213
|
+
* reference to the bucket.
|
|
214
|
+
*/
|
|
215
|
+
s3OriginAccessIdentityFor(bucket) {
|
|
216
|
+
const bucketStack = Stack.of(bucket);
|
|
217
|
+
const existing = this.s3AccessIdentities.get(bucketStack);
|
|
218
|
+
if (existing !== undefined) {
|
|
219
|
+
return existing;
|
|
220
|
+
}
|
|
221
|
+
const sameStack = bucketStack === Stack.of(this);
|
|
222
|
+
const identity = sameStack
|
|
223
|
+
? new OriginAccessIdentity(this, "S3OriginAccessIdentity", {
|
|
224
|
+
comment: `Identity for ${this.node.path}`
|
|
225
|
+
})
|
|
226
|
+
: new OriginAccessIdentity(bucketStack, `${Names.uniqueId(this)}S3OriginAccessIdentity`, { comment: `Identity for ${this.node.path}` });
|
|
227
|
+
this.s3AccessIdentities.set(bucketStack, identity);
|
|
228
|
+
return identity;
|
|
229
|
+
}
|
|
230
|
+
/** OAC twin of `s3OriginAccessIdentityFor` — an OAC is a signing config,
|
|
231
|
+
* not a bucket grant (bucket policies key on the distribution ARN), so a
|
|
232
|
+
* single distribution-scoped one serves every OAC origin. */
|
|
233
|
+
s3OriginAccessControl() {
|
|
234
|
+
this.s3AccessControl ??= new S3OriginAccessControl(this, "S3OriginAccessControl");
|
|
235
|
+
return this.s3AccessControl;
|
|
236
|
+
}
|
|
237
|
+
buildOrigin(config) {
|
|
148
238
|
switch (config.type) {
|
|
149
239
|
case "s3":
|
|
150
240
|
// OAC (single-stack only) is safe when the bucket and distribution live
|
|
@@ -153,11 +243,13 @@ export class CloudFrontDistribution extends Construct {
|
|
|
153
243
|
// See: https://github.com/aws/aws-cdk/issues/31462
|
|
154
244
|
if (config.originAccess === "oac") {
|
|
155
245
|
return S3BucketOrigin.withOriginAccessControl(config.bucket, {
|
|
156
|
-
originPath: config.originPath
|
|
246
|
+
originPath: config.originPath,
|
|
247
|
+
originAccessControl: this.s3OriginAccessControl()
|
|
157
248
|
});
|
|
158
249
|
}
|
|
159
250
|
return S3BucketOrigin.withOriginAccessIdentity(config.bucket, {
|
|
160
|
-
originPath: config.originPath
|
|
251
|
+
originPath: config.originPath,
|
|
252
|
+
originAccessIdentity: this.s3OriginAccessIdentityFor(config.bucket)
|
|
161
253
|
});
|
|
162
254
|
case "alb":
|
|
163
255
|
return new LoadBalancerV2Origin(config.loadBalancer, {
|
|
@@ -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;
|