@fjall/components-infrastructure 36.0.0 → 38.0.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/app.d.ts +28 -0
- package/dist/lib/app.js +34 -2
- package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
- package/dist/lib/config/aws/disasterRecovery.js +52 -32
- package/dist/lib/patterns/aws/cdn.d.ts +19 -11
- package/dist/lib/patterns/aws/cdn.js +53 -25
- package/dist/lib/patterns/aws/domain.js +3 -3
- package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
- package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
- package/dist/lib/patterns/aws/payload.d.ts +5 -0
- package/dist/lib/patterns/aws/payload.js +23 -11
- package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
- package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
- package/dist/lib/resources/aws/backup/backupVault.d.ts +17 -2
- package/dist/lib/resources/aws/backup/backupVault.js +37 -6
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
- package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
- package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
- package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
- package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
- package/dist/lib/resources/aws/cdn/index.js +1 -0
- package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
- package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +14 -0
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +90 -0
- package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
- package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
- package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
- package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
- package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
- package/dist/lib/resources/aws/messaging/sqs.js +4 -0
- package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
- package/dist/lib/resources/aws/secrets/kms.d.ts +8 -0
- package/dist/lib/resources/aws/secrets/kms.js +12 -2
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +6 -4
- package/dist/lib/utils/backupTierMapping.d.ts +11 -16
- package/dist/lib/utils/backupTierMapping.js +2 -15
- package/dist/lib/utils/cdkContext.d.ts +14 -0
- package/dist/lib/utils/cdkContext.js +38 -1
- package/dist/lib/utils/resourceNaming.d.ts +22 -0
- package/dist/lib/utils/resourceNaming.js +32 -0
- package/package.json +3 -3
package/dist/lib/app.d.ts
CHANGED
|
@@ -211,6 +211,34 @@ export declare class App extends CdkApp {
|
|
|
211
211
|
* Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
|
|
212
212
|
*/
|
|
213
213
|
getUsEast1CertificateStack(): AwsStack;
|
|
214
|
+
/**
|
|
215
|
+
* Retrieve the app-owned us-east-1 CloudFront logging stack — named
|
|
216
|
+
* `${this.name}UsEast1CdnLogging`.
|
|
217
|
+
*
|
|
218
|
+
* CloudWatch Logs accepts CloudFront standard-logging (v2) delivery
|
|
219
|
+
* sources, destinations and deliveries in us-east-1 only, so a logging
|
|
220
|
+
* CDN whose app deploys to another region creates them here; the
|
|
221
|
+
* distribution ARN and the log bucket ARN reach this stack through CDK
|
|
222
|
+
* cross-region references, which makes it a CONSUMER of the Cdn stack —
|
|
223
|
+
* it deploys after the Cdn stack and is destroyed before it, the mirror
|
|
224
|
+
* of the certificate stack. A CDN in a us-east-1 app never creates it.
|
|
225
|
+
*
|
|
226
|
+
* Everything crossing the boundary is a value the export writer requires
|
|
227
|
+
* stable under an unchanged logical id: the distribution ARN and the
|
|
228
|
+
* bucket ARN both are. Never route a per-synth value (a timestamp, an
|
|
229
|
+
* asset hash) through this stack's references.
|
|
230
|
+
*/
|
|
231
|
+
getUsEast1CdnLoggingStack(): AwsStack;
|
|
232
|
+
/**
|
|
233
|
+
* The two us-east-1 stacks share a shape: no stack dependencies of their
|
|
234
|
+
* own (the CDK cross-region reference adds the consumer→producer edge),
|
|
235
|
+
* env-pinned to the deploying account (cross-region references resolve
|
|
236
|
+
* only between env-pinned stacks) and `crossRegionReferences: true` — the
|
|
237
|
+
* flag must be on BOTH the producing and the consuming stack, and the
|
|
238
|
+
* emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
|
|
239
|
+
* template test.
|
|
240
|
+
*/
|
|
241
|
+
private getUsEast1Stack;
|
|
214
242
|
/**
|
|
215
243
|
* Get a VPC by name. If no name is provided, returns the default VPC.
|
|
216
244
|
*
|
package/dist/lib/app.js
CHANGED
|
@@ -312,11 +312,43 @@ export class App extends CdkApp {
|
|
|
312
312
|
* Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
|
|
313
313
|
*/
|
|
314
314
|
getUsEast1CertificateStack() {
|
|
315
|
-
|
|
315
|
+
return this.getUsEast1Stack("UsEast1Certificates", "certificate");
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Retrieve the app-owned us-east-1 CloudFront logging stack — named
|
|
319
|
+
* `${this.name}UsEast1CdnLogging`.
|
|
320
|
+
*
|
|
321
|
+
* CloudWatch Logs accepts CloudFront standard-logging (v2) delivery
|
|
322
|
+
* sources, destinations and deliveries in us-east-1 only, so a logging
|
|
323
|
+
* CDN whose app deploys to another region creates them here; the
|
|
324
|
+
* distribution ARN and the log bucket ARN reach this stack through CDK
|
|
325
|
+
* cross-region references, which makes it a CONSUMER of the Cdn stack —
|
|
326
|
+
* it deploys after the Cdn stack and is destroyed before it, the mirror
|
|
327
|
+
* of the certificate stack. A CDN in a us-east-1 app never creates it.
|
|
328
|
+
*
|
|
329
|
+
* Everything crossing the boundary is a value the export writer requires
|
|
330
|
+
* stable under an unchanged logical id: the distribution ARN and the
|
|
331
|
+
* bucket ARN both are. Never route a per-synth value (a timestamp, an
|
|
332
|
+
* asset hash) through this stack's references.
|
|
333
|
+
*/
|
|
334
|
+
getUsEast1CdnLoggingStack() {
|
|
335
|
+
return this.getUsEast1Stack("UsEast1CdnLogging", "CloudFront logging");
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The two us-east-1 stacks share a shape: no stack dependencies of their
|
|
339
|
+
* own (the CDK cross-region reference adds the consumer→producer edge),
|
|
340
|
+
* env-pinned to the deploying account (cross-region references resolve
|
|
341
|
+
* only between env-pinned stacks) and `crossRegionReferences: true` — the
|
|
342
|
+
* flag must be on BOTH the producing and the consuming stack, and the
|
|
343
|
+
* emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
|
|
344
|
+
* template test.
|
|
345
|
+
*/
|
|
346
|
+
getUsEast1Stack(suffix, purpose) {
|
|
347
|
+
const key = `${this.stackPrefix}${suffix}`;
|
|
316
348
|
if (!this.stacks[key]) {
|
|
317
349
|
const account = process.env.CDK_DEFAULT_ACCOUNT;
|
|
318
350
|
if (!account) {
|
|
319
|
-
throw new Error(`App '${this.name}': the us-east-1
|
|
351
|
+
throw new Error(`App '${this.name}': the us-east-1 ${purpose} stack needs a ` +
|
|
320
352
|
"concrete account (got CDK_DEFAULT_ACCOUNT undefined). " +
|
|
321
353
|
"Cross-region references resolve only between env-pinned stacks " +
|
|
322
354
|
"— deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT " +
|
|
@@ -12,8 +12,7 @@ export interface DisasterRecoveryProps {
|
|
|
12
12
|
customBackupPlans?: CustomBackupPlanConfig[];
|
|
13
13
|
}
|
|
14
14
|
export declare class DisasterRecovery extends Construct {
|
|
15
|
-
readonly backupVault
|
|
16
|
-
private readonly vaultRef;
|
|
15
|
+
readonly backupVault: BackupVault;
|
|
17
16
|
readonly backupPlans: {
|
|
18
17
|
standard: BackupPlan;
|
|
19
18
|
resilient: BackupPlan;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
1
|
+
import { Annotations, CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
2
2
|
import { Construct } from "constructs";
|
|
3
3
|
import { BackupVault, BackupPlan } from "../../resources/aws/backup/index.js";
|
|
4
4
|
import { BackupPlanRule, BackupVault as Vault } from "aws-cdk-lib/aws-backup";
|
|
5
|
-
import { cronSchedule } from "../../resources/aws/messaging/index.js";
|
|
6
5
|
import { AccountPrincipal, Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import { Key } from "aws-cdk-lib/aws-kms";
|
|
7
|
+
import { cronSchedule } from "../../resources/aws/messaging/index.js";
|
|
7
8
|
import { getConfig } from "../../utils/getConfig.js";
|
|
8
9
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
9
|
-
import {
|
|
10
|
+
import { readBackupVaultVerdict } from "../../utils/cdkContext.js";
|
|
11
|
+
import { BACKUP_VAULT_NAME, isReownedBackupVault } from "@fjall/util";
|
|
10
12
|
// Backup retention constants (in days)
|
|
11
13
|
const RETENTION_PERIODS = {
|
|
12
14
|
STANDARD: 90,
|
|
@@ -28,11 +30,13 @@ const VAULT_LOCK = {
|
|
|
28
30
|
MAX_RETENTION: Duration.days(36500),
|
|
29
31
|
GRACE_PERIOD: Duration.days(3)
|
|
30
32
|
};
|
|
33
|
+
function liveLockMode(lock) {
|
|
34
|
+
if (lock === null)
|
|
35
|
+
return "none";
|
|
36
|
+
return lock.lockDate !== undefined ? "compliance" : "governance";
|
|
37
|
+
}
|
|
31
38
|
export class DisasterRecovery extends Construct {
|
|
32
|
-
// Set in create-mode only; in adopt-mode the existing vault is referenced, not created.
|
|
33
39
|
backupVault;
|
|
34
|
-
// Vault the plans write to: created vault (create-mode) or imported handle (adopt-mode).
|
|
35
|
-
vaultRef;
|
|
36
40
|
backupPlans;
|
|
37
41
|
customBackupPlans;
|
|
38
42
|
constructor(scope, id, props) {
|
|
@@ -41,9 +45,10 @@ export class DisasterRecovery extends Construct {
|
|
|
41
45
|
const providerAccounts = config.providerAccounts;
|
|
42
46
|
const account = providerAccounts.find((pa) => pa.id === props.accountId);
|
|
43
47
|
const isComplianceAccount = account?.environment === "compliance";
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
const
|
|
48
|
+
// Injected by the deploy engine after probing the live vault and the account
|
|
49
|
+
// stack; unset (a bare synth) is the create template.
|
|
50
|
+
const verdict = readBackupVaultVerdict(this.node);
|
|
51
|
+
const reOwned = isReownedBackupVault(verdict);
|
|
47
52
|
const disasterRecoveryRegion = config.disasterRecoveryRegion;
|
|
48
53
|
// Look up compliance account for cross-account replication
|
|
49
54
|
// Skip if the compliance account (prevent self-replication)
|
|
@@ -58,9 +63,11 @@ export class DisasterRecovery extends Construct {
|
|
|
58
63
|
// permanently-immutable compliance lock. See
|
|
59
64
|
// decisions/2026-06-03-backup-vault-lock-mode-by-intent.md.
|
|
60
65
|
const vaultLock = account?.vaultLock ?? (isComplianceAccount ? "governance" : "none");
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
66
|
+
// Fjall never issues a lock call against a vault it did not create: a
|
|
67
|
+
// re-owned vault declares no lock and the intent only warns when it
|
|
68
|
+
// differs, so the acknowledgement gate guards only a lock this template
|
|
69
|
+
// creates.
|
|
70
|
+
if (!reOwned &&
|
|
64
71
|
vaultLock === "compliance" &&
|
|
65
72
|
account?.acknowledgeImmutableVaultLock !== true) {
|
|
66
73
|
throw new Error(`Account "${account?.name ?? props.accountId}" requests vaultLock: ` +
|
|
@@ -69,7 +76,7 @@ export class DisasterRecovery extends Construct {
|
|
|
69
76
|
`compliance lock cannot be removed by anyone, including AWS or the ` +
|
|
70
77
|
`root user. Set the acknowledgement explicitly to proceed.`);
|
|
71
78
|
}
|
|
72
|
-
const
|
|
79
|
+
const intendedLockConfiguration = vaultLock === "compliance"
|
|
73
80
|
? {
|
|
74
81
|
minRetention: VAULT_LOCK.MIN_RETENTION,
|
|
75
82
|
maxRetention: VAULT_LOCK.MAX_RETENTION,
|
|
@@ -81,20 +88,33 @@ export class DisasterRecovery extends Construct {
|
|
|
81
88
|
maxRetention: VAULT_LOCK.MAX_RETENTION
|
|
82
89
|
}
|
|
83
90
|
: undefined;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
const lockConfiguration = reOwned ? undefined : intendedLockConfiguration;
|
|
92
|
+
const liveLocked = verdict.state !== "absent" && verdict.lock !== null;
|
|
93
|
+
if (verdict.state !== "absent" && reOwned) {
|
|
94
|
+
const liveMode = liveLockMode(verdict.lock);
|
|
95
|
+
if (liveMode !== vaultLock) {
|
|
96
|
+
Annotations.of(this).addWarningV2("fjall:backupVault:lockIntentNotApplied", `Backup vault "${BACKUP_VAULT_NAME}" is re-owned: Fjall does not ` +
|
|
97
|
+
`manage the lock of a vault it did not create. Its live lock ` +
|
|
98
|
+
`(${liveMode}) stays as it is and the configured vaultLock ` +
|
|
99
|
+
`(${vaultLock}) is not applied — align the configured vaultLock ` +
|
|
100
|
+
`with the live lock, or change the live lock with the AWS Backup ` +
|
|
101
|
+
`API where it is still changeable.`);
|
|
102
|
+
}
|
|
94
103
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
this.backupVault = new BackupVault(this, "BackupVault", {
|
|
105
|
+
vaultName: BACKUP_VAULT_NAME,
|
|
106
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
107
|
+
lockConfiguration,
|
|
108
|
+
...(verdict.state !== "absent" &&
|
|
109
|
+
reOwned && {
|
|
110
|
+
encryptionKey: Key.fromKeyArn(this, "BackupVaultLiveKey", verdict.keyArn)
|
|
111
|
+
}),
|
|
112
|
+
importIdentityOnly: verdict.state === "unowned"
|
|
113
|
+
});
|
|
114
|
+
const vault = this.backupVault.vault;
|
|
115
|
+
// Cross-account copy grant. Declared in every mode; the wrapper strips it
|
|
116
|
+
// from the import pass, and the converge deploy applies it.
|
|
117
|
+
if (isComplianceAccount) {
|
|
98
118
|
const allAccounts = providerAccounts;
|
|
99
119
|
const productionAccounts = allAccounts.filter((acc) => acc.environment === "production");
|
|
100
120
|
if (productionAccounts.length > 0) {
|
|
@@ -104,7 +124,7 @@ export class DisasterRecovery extends Construct {
|
|
|
104
124
|
actions: ["backup:CopyIntoBackupVault"],
|
|
105
125
|
resources: ["*"]
|
|
106
126
|
});
|
|
107
|
-
|
|
127
|
+
vault.addToAccessPolicy(copyStatement);
|
|
108
128
|
}
|
|
109
129
|
}
|
|
110
130
|
// Construct cross-region replication vault ARN (same account, DR region)
|
|
@@ -118,19 +138,19 @@ export class DisasterRecovery extends Construct {
|
|
|
118
138
|
planName: "standard",
|
|
119
139
|
rules: this.createStandardBackupRules("standard"),
|
|
120
140
|
tagValue: "default",
|
|
121
|
-
backupVault:
|
|
141
|
+
backupVault: vault
|
|
122
142
|
}),
|
|
123
143
|
resilient: new BackupPlan(this, "ResilientBackupPlan", {
|
|
124
144
|
planName: "resilient",
|
|
125
145
|
rules: this.createResilientBackupRules("resilient", replicationVaultArn),
|
|
126
146
|
tagValue: "resilient",
|
|
127
|
-
backupVault:
|
|
147
|
+
backupVault: vault
|
|
128
148
|
}),
|
|
129
149
|
enterprise: new BackupPlan(this, "EnterpriseBackupPlan", {
|
|
130
150
|
planName: "enterprise",
|
|
131
151
|
rules: this.createEnterpriseBackupRules("enterprise", replicationVaultArn, disasterRecoveryVaultArn),
|
|
132
152
|
tagValue: "enterprise",
|
|
133
|
-
backupVault:
|
|
153
|
+
backupVault: vault
|
|
134
154
|
})
|
|
135
155
|
};
|
|
136
156
|
// Create custom backup plans if provided
|
|
@@ -140,7 +160,7 @@ export class DisasterRecovery extends Construct {
|
|
|
140
160
|
planName: config.planName,
|
|
141
161
|
rules: config.rules,
|
|
142
162
|
tagValue: config.tagValue,
|
|
143
|
-
backupVault:
|
|
163
|
+
backupVault: vault
|
|
144
164
|
});
|
|
145
165
|
});
|
|
146
166
|
}
|
|
@@ -157,7 +177,7 @@ export class DisasterRecovery extends Construct {
|
|
|
157
177
|
exportName: `${id}ReplicationRegion`
|
|
158
178
|
});
|
|
159
179
|
}
|
|
160
|
-
if (
|
|
180
|
+
if (reOwned ? liveLocked : lockConfiguration !== undefined) {
|
|
161
181
|
new CfnOutput(this, "VaultLockEnabled", {
|
|
162
182
|
key: "VaultLockEnabled",
|
|
163
183
|
value: "true",
|
|
@@ -4,7 +4,7 @@ import { type IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalanc
|
|
|
4
4
|
import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
5
5
|
import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
6
6
|
import type App from "../../app.js";
|
|
7
|
-
import { CloudFrontDistribution, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
|
|
7
|
+
import { CloudFrontDistribution, type CloudFrontLogFormat, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
|
|
8
8
|
import { type ICdn } from "./interfaces/cdn.js";
|
|
9
9
|
import { type StaticSiteRouting, type CloudFrontPriceClass } from "@fjall/util";
|
|
10
10
|
import { type BackupTier } from "../../utils/backupTierMapping.js";
|
|
@@ -83,17 +83,17 @@ interface BaseCdnProps {
|
|
|
83
83
|
certificateArn?: string;
|
|
84
84
|
comment?: string;
|
|
85
85
|
/**
|
|
86
|
-
* Standard (
|
|
87
|
-
* `cloudFrontLogging` column — enterprise
|
|
88
|
-
* lifecycle-expired access-log bucket under
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
86
|
+
* Standard logging (v2): access logs delivered through CloudWatch Logs.
|
|
87
|
+
* Default: the app backup tier's `cloudFrontLogging` column — enterprise
|
|
88
|
+
* logs to the stack's shared, lifecycle-expired access-log bucket under
|
|
89
|
+
* `cloudfront/<id>`, as JSON. An object takes over at any tier (its
|
|
90
|
+
* `bucket`, `prefix` and `format` each default as above); `false` opts
|
|
91
|
+
* out at any tier. The delivery resources live in the CDN stack when the
|
|
92
|
+
* app deploys to us-east-1 and in the app's `UsEast1CdnLogging` stack
|
|
93
|
+
* otherwise (CloudWatch Logs accepts them there only), so a logging CDN
|
|
94
|
+
* outside us-east-1 needs the `App` — the factory path fills it.
|
|
94
95
|
*/
|
|
95
|
-
|
|
96
|
-
logBucket?: IBucket;
|
|
96
|
+
logging?: CdnLoggingConfig | false;
|
|
97
97
|
priceClass?: CloudFrontPriceClass;
|
|
98
98
|
forwardHostHeader?: boolean;
|
|
99
99
|
accessGate?: false | AccessGateConfig;
|
|
@@ -104,6 +104,14 @@ interface BaseCdnProps {
|
|
|
104
104
|
*/
|
|
105
105
|
appBackupTier?: BackupTier;
|
|
106
106
|
}
|
|
107
|
+
export interface CdnLoggingConfig {
|
|
108
|
+
/** Bucket the logs land in; defaults to the stack's access-log bucket. */
|
|
109
|
+
bucket?: IBucket;
|
|
110
|
+
/** Key prefix (no leading or trailing slash); defaults to `cloudfront/<id>`. */
|
|
111
|
+
prefix?: string;
|
|
112
|
+
/** CloudWatch Logs output format; defaults to `json`. */
|
|
113
|
+
format?: CloudFrontLogFormat;
|
|
114
|
+
}
|
|
107
115
|
/**
|
|
108
116
|
* S3 origin CDN props - use with S3 bucket
|
|
109
117
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Fn, Token } from "aws-cdk-lib";
|
|
1
|
+
import { Fn, Stack, Token } from "aws-cdk-lib";
|
|
2
2
|
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
3
3
|
import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
4
|
-
import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
|
|
4
|
+
import { assertNoLegacyLoggingProps, CLOUDFRONT_LOG_DELIVERY_REGION, CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
|
|
5
5
|
import { AliasRecord } from "../../resources/aws/networking/index.js";
|
|
6
6
|
import { tierDefault } from "../../utils/backupTierMapping.js";
|
|
7
7
|
import { stackAccessLogBucket } from "../../resources/aws/logging/index.js";
|
|
@@ -12,31 +12,54 @@ import { isStorage } from "./storage.js";
|
|
|
12
12
|
import { CdnEcsOriginResolver, cdnOriginRefusal, internalAlbMessage, normaliseDnsName } from "./cdnAppOrigin.js";
|
|
13
13
|
import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* to the stack's shared access-log bucket under its own prefix.
|
|
15
|
+
* `false` is off at any tier; an object is on at any tier with its own
|
|
16
|
+
* defaults filled; `undefined` lets the tier decide, and a tier-enabled CDN
|
|
17
|
+
* logs to the stack's shared access-log bucket under its own prefix.
|
|
18
18
|
*/
|
|
19
19
|
function resolveCdnLogging(scope, id, props) {
|
|
20
|
-
if (props.
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
if (!tierDefault(props.appBackupTier, "cloudFrontLogging")) {
|
|
28
|
-
return {
|
|
29
|
-
enableLogging: undefined,
|
|
30
|
-
logBucket: undefined,
|
|
31
|
-
logFilePrefix: undefined
|
|
32
|
-
};
|
|
20
|
+
if (props.logging === false)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (props.logging === undefined &&
|
|
23
|
+
!tierDefault(props.appBackupTier, "cloudFrontLogging")) {
|
|
24
|
+
return undefined;
|
|
33
25
|
}
|
|
26
|
+
const config = props.logging ?? {};
|
|
34
27
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
bucket: config.bucket ?? stackAccessLogBucket(scope),
|
|
29
|
+
...(config.prefix !== undefined && { prefix: config.prefix }),
|
|
30
|
+
...(config.format !== undefined && { format: config.format }),
|
|
31
|
+
...resolveLogDeliveryScope(scope, id, props.app)
|
|
38
32
|
};
|
|
39
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* CloudWatch Logs accepts CloudFront delivery resources in us-east-1 only.
|
|
36
|
+
* A CDN stack already there hosts them itself; any other region hands them
|
|
37
|
+
* to the app's `UsEast1CdnLogging` stack, whose distribution and bucket
|
|
38
|
+
* ARNs arrive through CDK cross-region references — which need both stacks
|
|
39
|
+
* env-pinned, so a token region is refused here with the cure rather than
|
|
40
|
+
* by CloudFormation with a region error after the CDN stack has deployed.
|
|
41
|
+
*/
|
|
42
|
+
function resolveLogDeliveryScope(scope, id, app) {
|
|
43
|
+
const region = Stack.of(scope).region;
|
|
44
|
+
if (Token.isUnresolved(region)) {
|
|
45
|
+
throw new Error(`CDN '${id}': standard logging (v2) needs a concrete region at synth ` +
|
|
46
|
+
"— the delivery resources go to us-east-1 and reach the " +
|
|
47
|
+
"distribution through cross-region references, which resolve only " +
|
|
48
|
+
"between env-pinned stacks — but this stack's region is a token. " +
|
|
49
|
+
"Deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT and " +
|
|
50
|
+
"CDK_DEFAULT_REGION before synthesising; or set logging: false.");
|
|
51
|
+
}
|
|
52
|
+
if (region === CLOUDFRONT_LOG_DELIVERY_REGION)
|
|
53
|
+
return {};
|
|
54
|
+
if (app === undefined) {
|
|
55
|
+
throw new Error(`CDN '${id}': standard logging (v2) from region ${region} places its ` +
|
|
56
|
+
"delivery resources in the app's us-east-1 logging stack, but 'app' " +
|
|
57
|
+
"is unset. Create the CDN through the factory path — " +
|
|
58
|
+
"app.addCdn(CdnFactory.build(...)) — which fills it, pass 'app' " +
|
|
59
|
+
"explicitly, or set logging: false.");
|
|
60
|
+
}
|
|
61
|
+
return { deliveryScope: app.getUsEast1CdnLoggingStack().getStack() };
|
|
62
|
+
}
|
|
40
63
|
/**
|
|
41
64
|
* The distribution's own alias names, normalised — consumed by the
|
|
42
65
|
* origin-loop guard and by the ECS origin resolver (auto-resolution excludes
|
|
@@ -59,7 +82,10 @@ function collectAliasNames(props) {
|
|
|
59
82
|
* Validates CDN props — synth-time hard errors for shapes CloudFormation
|
|
60
83
|
* would only reject at deploy.
|
|
61
84
|
*/
|
|
62
|
-
function validateCdnProps(props) {
|
|
85
|
+
function validateCdnProps(id, props) {
|
|
86
|
+
// The pattern rebuilds the distribution props field by field, so the
|
|
87
|
+
// resource-layer refusal never sees a legacy key given here.
|
|
88
|
+
assertNoLegacyLoggingProps(`CDN '${id}'`, props, "logging: { bucket?, prefix?, format? } | false");
|
|
63
89
|
const hasCertificate = !!(props.certificate || props.certificateArn);
|
|
64
90
|
// domainConfig is the managed-domain authority for the viewer config —
|
|
65
91
|
// mixing it with the literal surface would give the alias list and the
|
|
@@ -251,7 +277,7 @@ const ROUTING_CDN_BEHAVIOUR = {
|
|
|
251
277
|
};
|
|
252
278
|
export class Cdn extends CloudFrontDistribution {
|
|
253
279
|
constructor(scope, id, props) {
|
|
254
|
-
validateCdnProps(props);
|
|
280
|
+
validateCdnProps(id, props);
|
|
255
281
|
// Zone + certificate resolve BEFORE super() — the viewer certificate is
|
|
256
282
|
// part of the distribution's props — so their constructs land on the CDN
|
|
257
283
|
// stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
|
|
@@ -476,6 +502,7 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
476
502
|
originRequestPolicy: props.originRequestPolicy
|
|
477
503
|
}
|
|
478
504
|
: {};
|
|
505
|
+
const logging = resolveCdnLogging(scope, id, props);
|
|
479
506
|
return {
|
|
480
507
|
distributionProps: {
|
|
481
508
|
appName,
|
|
@@ -486,7 +513,7 @@ export class Cdn extends CloudFrontDistribution {
|
|
|
486
513
|
domainNames,
|
|
487
514
|
certificate,
|
|
488
515
|
comment: props.comment,
|
|
489
|
-
...
|
|
516
|
+
...(logging !== undefined && { logging }),
|
|
490
517
|
priceClass: props.priceClass,
|
|
491
518
|
forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
|
|
492
519
|
accessGate: props.accessGate,
|
|
@@ -699,7 +726,8 @@ export class CdnFactory {
|
|
|
699
726
|
// The App is the only place the app name is known here; without it the
|
|
700
727
|
// export falls back to the construct id and the Domain import misses.
|
|
701
728
|
// The App instance itself rides along for domainConfig resolution
|
|
702
|
-
// (certificate placement needs app.getUsEast1CertificateStack())
|
|
729
|
+
// (certificate placement needs app.getUsEast1CertificateStack()) and
|
|
730
|
+
// for log-delivery placement (app.getUsEast1CdnLoggingStack()).
|
|
703
731
|
return new Cdn(scope, id, {
|
|
704
732
|
...props,
|
|
705
733
|
appName: props.appName ?? app.getName(),
|
|
@@ -99,9 +99,9 @@ export class Domain extends Construct {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* Re-publish the domain's effective zone name under a predictable
|
|
103
|
-
* key
|
|
104
|
-
*
|
|
102
|
+
* Re-publish the domain's effective zone name under a predictable output
|
|
103
|
+
* key (mirrored byte-for-byte by the eject template) so a stack reader can
|
|
104
|
+
* name the zone without knowing the registrar discriminant. The `Zone` and
|
|
105
105
|
* `HostedZoneId` outputs on the inner `HostedZone` construct cover the
|
|
106
106
|
* Route53-backed paths; this output covers external-records mode where no
|
|
107
107
|
* hosted zone exists.
|
|
@@ -7,12 +7,15 @@ export interface ExternalRecordsPatternResult {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Composition for `registrar: "external-records"`. No Route53 hosted zone is
|
|
10
|
-
* created
|
|
11
|
-
* validation
|
|
12
|
-
* records
|
|
13
|
-
*
|
|
10
|
+
* created. Certificates use ACM DNS validation with no zone to write to, so
|
|
11
|
+
* their validation CNAMEs must be created at the external registrar — and
|
|
12
|
+
* those records do not exist at synth: ACM chooses them when CloudFormation
|
|
13
|
+
* creates the certificate, and `AWS::CertificateManager::Certificate`
|
|
14
|
+
* returns only its ARN. They are therefore never `ManualRecord` entries:
|
|
15
|
+
* they are read from ACM (`DescribeCertificate`) once the certificate is
|
|
16
|
+
* pending, while CloudFormation waits for the records to resolve.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* User-declared records become `ManualRecord` entries, each with a
|
|
19
|
+
* `CfnOutput` triple keyed by its position in `records`.
|
|
17
20
|
*/
|
|
18
21
|
export declare function composeExternalRecords(scope: Construct, props: ExternalRecordsProps): ExternalRecordsPatternResult;
|
|
@@ -6,58 +6,35 @@ import { resolveRecordFqdn } from "../../utils/domainTypes.js";
|
|
|
6
6
|
const MANUAL_RECORD_TTL_SECONDS = 300;
|
|
7
7
|
/**
|
|
8
8
|
* Composition for `registrar: "external-records"`. No Route53 hosted zone is
|
|
9
|
-
* created
|
|
10
|
-
* validation
|
|
11
|
-
* records
|
|
12
|
-
*
|
|
9
|
+
* created. Certificates use ACM DNS validation with no zone to write to, so
|
|
10
|
+
* their validation CNAMEs must be created at the external registrar — and
|
|
11
|
+
* those records do not exist at synth: ACM chooses them when CloudFormation
|
|
12
|
+
* creates the certificate, and `AWS::CertificateManager::Certificate`
|
|
13
|
+
* returns only its ARN. They are therefore never `ManualRecord` entries:
|
|
14
|
+
* they are read from ACM (`DescribeCertificate`) once the certificate is
|
|
15
|
+
* pending, while CloudFormation waits for the records to resolve.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* User-declared records become `ManualRecord` entries, each with a
|
|
18
|
+
* `CfnOutput` triple keyed by its position in `records`.
|
|
16
19
|
*/
|
|
17
20
|
export function composeExternalRecords(scope, props) {
|
|
18
21
|
const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
|
|
19
22
|
const certificates = new Map();
|
|
20
23
|
const manualRecords = [];
|
|
21
|
-
// 1. Certificates — ACM DNS validation emits CNAMEs the user must paste at
|
|
22
|
-
// their registrar. Capture the validation records as ManualRecord
|
|
23
|
-
// entries (cert-validation first per ordering invariant).
|
|
24
24
|
const normalisedCerts = (props.certificates ?? []).map(normaliseCertificate);
|
|
25
25
|
normalisedCerts.forEach((cert, index) => {
|
|
26
26
|
const safeCertName = toPascalCase(cert.domainName.split(".").join(""));
|
|
27
27
|
const acm = new AcmCertificate(scope, `${safeZone}${safeCertName}Cert${index}`, {
|
|
28
28
|
domainName: cert.domainName,
|
|
29
29
|
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
30
|
-
// No hosted zone to validate against — ACM emits tokens for the user
|
|
31
|
-
// to resolve via `describeStackResources` or deploy output.
|
|
32
30
|
validation: CertificateValidation.fromDns(),
|
|
33
31
|
transparencyLoggingEnabled: cert.transparencyLogging ?? true
|
|
34
32
|
});
|
|
35
33
|
certificates.set(cert.domainName, acm);
|
|
36
|
-
const certNames = [
|
|
37
|
-
cert.domainName,
|
|
38
|
-
...(cert.subjectAlternativeNames ?? [])
|
|
39
|
-
];
|
|
40
|
-
certNames.forEach((name, nameIndex) => {
|
|
41
|
-
// `domainValidationOption` returns a token CNAME name/value pair. Using
|
|
42
|
-
// `attrDomainValidationOptions` returns an array token; per-name lookup
|
|
43
|
-
// uses index. CDK exposes the first validation option via
|
|
44
|
-
// `certificate.certificateArn` only — we fall back to emitting a
|
|
45
|
-
// predictable CfnOutput pointing to the attribute for Phase 2.
|
|
46
|
-
const record = {
|
|
47
|
-
name: `_acme-challenge.${name}`,
|
|
48
|
-
type: "CNAME",
|
|
49
|
-
value: `<ACM-validation-token-for-${name}>`,
|
|
50
|
-
ttl: MANUAL_RECORD_TTL_SECONDS,
|
|
51
|
-
reason: "certificate-validation"
|
|
52
|
-
};
|
|
53
|
-
manualRecords.push(record);
|
|
54
|
-
emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, record, `CertValidation${safeCertName}${nameIndex}`);
|
|
55
|
-
});
|
|
56
34
|
});
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// entries.
|
|
35
|
+
// Typed targets resolve to token strings via `resolveTargetToDnsName`.
|
|
36
|
+
// Apex ALIAS was already rejected by `validateDomainProps`, so all records
|
|
37
|
+
// here are safe to emit as manual entries.
|
|
61
38
|
const records = props.records ?? [];
|
|
62
39
|
records.forEach((record, index) => {
|
|
63
40
|
const fqdn = resolveRecordFqdn(record.name, props.zoneName);
|
|
@@ -67,11 +44,10 @@ export function composeExternalRecords(scope, props) {
|
|
|
67
44
|
name: fqdn,
|
|
68
45
|
type: "CNAME",
|
|
69
46
|
value,
|
|
70
|
-
ttl: MANUAL_RECORD_TTL_SECONDS
|
|
71
|
-
reason: "user-declared"
|
|
47
|
+
ttl: MANUAL_RECORD_TTL_SECONDS
|
|
72
48
|
};
|
|
73
49
|
manualRecords.push(manual);
|
|
74
|
-
emitManualRecordOutputs(scope, safeZone,
|
|
50
|
+
emitManualRecordOutputs(scope, safeZone, index, manual);
|
|
75
51
|
return;
|
|
76
52
|
}
|
|
77
53
|
const rawValue = record.value;
|
|
@@ -80,11 +56,10 @@ export function composeExternalRecords(scope, props) {
|
|
|
80
56
|
name: fqdn,
|
|
81
57
|
type: narrowManualType(record.type),
|
|
82
58
|
value: joined,
|
|
83
|
-
ttl: record.ttl ?? MANUAL_RECORD_TTL_SECONDS
|
|
84
|
-
reason: "user-declared"
|
|
59
|
+
ttl: record.ttl ?? MANUAL_RECORD_TTL_SECONDS
|
|
85
60
|
};
|
|
86
61
|
manualRecords.push(manual);
|
|
87
|
-
emitManualRecordOutputs(scope, safeZone,
|
|
62
|
+
emitManualRecordOutputs(scope, safeZone, index, manual);
|
|
88
63
|
});
|
|
89
64
|
return { certificates, manualRecords };
|
|
90
65
|
}
|
|
@@ -115,17 +90,18 @@ function narrowManualType(type) {
|
|
|
115
90
|
return "TXT";
|
|
116
91
|
}
|
|
117
92
|
}
|
|
118
|
-
function emitManualRecordOutputs(scope, safeZone, index, record
|
|
119
|
-
|
|
93
|
+
function emitManualRecordOutputs(scope, safeZone, index, record) {
|
|
94
|
+
const idPrefix = `${safeZone}ManualRecordUserRecord${index}`;
|
|
95
|
+
new CfnOutput(scope, `${idPrefix}Name`, {
|
|
120
96
|
key: `${safeZone}ManualRecord${index}Name`,
|
|
121
97
|
value: record.name,
|
|
122
|
-
description:
|
|
98
|
+
description: "External-records manual DNS entry (user-declared)"
|
|
123
99
|
});
|
|
124
|
-
new CfnOutput(scope, `${
|
|
100
|
+
new CfnOutput(scope, `${idPrefix}Type`, {
|
|
125
101
|
key: `${safeZone}ManualRecord${index}Type`,
|
|
126
102
|
value: record.type
|
|
127
103
|
});
|
|
128
|
-
new CfnOutput(scope, `${
|
|
104
|
+
new CfnOutput(scope, `${idPrefix}Value`, {
|
|
129
105
|
key: `${safeZone}ManualRecord${index}Value`,
|
|
130
106
|
value: record.value
|
|
131
107
|
});
|
|
@@ -158,15 +158,18 @@ export interface ExternalRecordsProps extends DomainCommonProps {
|
|
|
158
158
|
}
|
|
159
159
|
export type DomainProps = Route53ApexProps | ExternalDelegatedProps | ExternalRecordsProps;
|
|
160
160
|
/**
|
|
161
|
-
*
|
|
162
|
-
* to
|
|
163
|
-
*
|
|
164
|
-
*
|
|
161
|
+
* A user-declared record that `registrar: "external-records"` mode reports
|
|
162
|
+
* for the user to create at their external registrar, one per `records`
|
|
163
|
+
* entry, in declaration order. For typed targets the `value` is a
|
|
164
|
+
* CloudFormation token (`Fn::ImportValue`) at synth time; the concrete host
|
|
165
|
+
* is the deployed stack's `ManualRecord<index>Value` output.
|
|
166
|
+
*
|
|
167
|
+
* Certificate-validation records are never manual records: ACM chooses them
|
|
168
|
+
* at deploy time, so no synth-time value exists (see `composeExternalRecords`).
|
|
165
169
|
*/
|
|
166
170
|
export interface ManualRecord {
|
|
167
171
|
readonly name: string;
|
|
168
172
|
readonly type: "A" | "AAAA" | "CNAME" | "TXT";
|
|
169
173
|
readonly value: string;
|
|
170
174
|
readonly ttl: number;
|
|
171
|
-
readonly reason: "certificate-validation" | "user-declared";
|
|
172
175
|
}
|