@fjall/components-infrastructure 37.0.0 → 39.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.
Files changed (72) hide show
  1. package/dist/lib/app.d.ts +52 -12
  2. package/dist/lib/app.js +60 -24
  3. package/dist/lib/config/aws/configRecorder.js +3 -0
  4. package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
  5. package/dist/lib/config/aws/disasterRecovery.js +167 -45
  6. package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
  7. package/dist/lib/patterns/aws/cdn.d.ts +19 -11
  8. package/dist/lib/patterns/aws/cdn.js +53 -25
  9. package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
  10. package/dist/lib/patterns/aws/computeEcs.js +17 -1
  11. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
  12. package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
  13. package/dist/lib/patterns/aws/domain.js +3 -3
  14. package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
  15. package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
  16. package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
  17. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
  18. package/dist/lib/patterns/aws/messaging.d.ts +10 -0
  19. package/dist/lib/patterns/aws/messaging.js +10 -6
  20. package/dist/lib/patterns/aws/payload.d.ts +5 -0
  21. package/dist/lib/patterns/aws/payload.js +23 -11
  22. package/dist/lib/patterns/aws/storage.d.ts +41 -0
  23. package/dist/lib/patterns/aws/storage.js +23 -8
  24. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
  25. package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
  26. package/dist/lib/resources/aws/backup/backupVault.d.ts +27 -2
  27. package/dist/lib/resources/aws/backup/backupVault.js +49 -6
  28. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
  29. package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
  30. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
  31. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
  32. package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
  33. package/dist/lib/resources/aws/cdn/index.js +1 -0
  34. package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
  35. package/dist/lib/resources/aws/compute/ecs.js +27 -21
  36. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
  37. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
  38. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
  39. package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
  40. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
  41. package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
  42. package/dist/lib/resources/aws/database/dynamodb.js +2 -1
  43. package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
  44. package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
  45. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
  46. package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
  47. package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
  48. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +16 -0
  49. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +91 -0
  50. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
  51. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
  52. package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
  53. package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
  54. package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
  55. package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
  56. package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
  57. package/dist/lib/resources/aws/messaging/sqs.js +40 -2
  58. package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
  59. package/dist/lib/resources/aws/networking/vpc.js +12 -6
  60. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
  61. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
  62. package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
  63. package/dist/lib/resources/aws/storage/s3.js +89 -3
  64. package/dist/lib/utils/backupTierMapping.d.ts +23 -2
  65. package/dist/lib/utils/backupTierMapping.js +25 -0
  66. package/dist/lib/utils/cdkContext.d.ts +14 -0
  67. package/dist/lib/utils/cdkContext.js +38 -1
  68. package/dist/lib/utils/exportNaming.d.ts +30 -27
  69. package/dist/lib/utils/exportNaming.js +32 -28
  70. package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
  71. package/dist/lib/utils/standardTagsAspect.js +8 -34
  72. package/package.json +4 -4
package/dist/lib/app.d.ts CHANGED
@@ -54,10 +54,15 @@ export interface IAppOptions {
54
54
  };
55
55
  /**
56
56
  * Backup configuration for automatic AWS Backup enrolment.
57
- * - Object with tier: Tags all resources with `fjall:disasterRecovery:tier`
58
- * and sets the app backup tier that resource defaults derive from — S3
59
- * buckets version by default at `resilient`/`enterprise`; a per-bucket
60
- * `backupVaultTier` overrides the tier for that bucket.
57
+ * - Object with tier: stamps `fjall:disasterRecovery:tier` on the resource
58
+ * types AWS Backup can protect — DynamoDB tables, RDS clusters and
59
+ * instances, the EBS volumes Fjall keeps state on, and versioned S3
60
+ * buckets — and sets the app backup tier that resource defaults derive
61
+ * from. The backup selection built from that tag carries no resource-type
62
+ * filter, so the tag IS the selection: it is applied by the constructs
63
+ * that own those resources, not across the app. S3 buckets version by
64
+ * default at `resilient`/`enterprise`; a per-bucket `backupVaultTier`
65
+ * overrides the tier for that bucket.
61
66
  * - false: Explicitly disabled (no backup tag, no tier-derived defaults)
62
67
  */
63
68
  backup?: {
@@ -148,14 +153,16 @@ export declare class App extends CdkApp {
148
153
  */
149
154
  getStack(key: string, dependencies?: AwsStack | AwsStack[], props?: AwsStackProps): AwsStack;
150
155
  /**
151
- * Retrieve default compute stack - named as `${this.name}Compute`
156
+ * Retrieve default compute stack, named by
157
+ * `getApplicationStackName(name, "Compute")`.
152
158
  *
153
159
  * Only depends on Network. Database dependency is added automatically
154
160
  * by CDK when compute resources reference database resources.
155
161
  */
156
162
  getDefaultComputeStack(): AwsStack;
157
163
  /**
158
- * Retrieve default network stack - named as `${this.name}Network`
164
+ * Retrieve default network stack, named by
165
+ * `getApplicationStackName(name, "Network")`.
159
166
  */
160
167
  getDefaultNetworkStack(): AwsStack;
161
168
  /**
@@ -171,15 +178,18 @@ export declare class App extends CdkApp {
171
178
  */
172
179
  private networkDependency;
173
180
  /**
174
- * Retrieve default database stack - named as `${this.name}Database`
181
+ * Retrieve default database stack, named by
182
+ * `getApplicationStackName(name, "Database")`.
175
183
  */
176
184
  getDefaultDatabaseStack(): AwsStack;
177
185
  /**
178
- * Retrieve default storage stack - named as `${this.name}Storage`
186
+ * Retrieve default storage stack, named by
187
+ * `getApplicationStackName(name, "Storage")`.
179
188
  */
180
189
  getDefaultStorageStack(): AwsStack;
181
190
  /**
182
- * Retrieve default CDN stack - named as `${this.name}Cdn`
191
+ * Retrieve default CDN stack, named by
192
+ * `getApplicationStackName(name, "Cdn")`.
183
193
  *
184
194
  * Depends on Network when networking is enabled. Compute/Storage dependencies
185
195
  * are added automatically by CDK when CDN resources reference ALB or S3 bucket
@@ -189,7 +199,8 @@ export declare class App extends CdkApp {
189
199
  */
190
200
  getDefaultCdnStack(): AwsStack;
191
201
  /**
192
- * Retrieve default messaging stack - named as `${this.name}Messaging`
202
+ * Retrieve default messaging stack, named by
203
+ * `getApplicationStackName(name, "Messaging")`.
193
204
  *
194
205
  * Used for SQS queues, SNS topics, and EventBridge event buses.
195
206
  * Depends on Network only. These are regional services that don't
@@ -197,8 +208,9 @@ export declare class App extends CdkApp {
197
208
  */
198
209
  getDefaultMessagingStack(): AwsStack;
199
210
  /**
200
- * Retrieve the app-owned us-east-1 certificate stack — named
201
- * `${this.name}UsEast1Certificates` (domain gold-plating D3, BYO path).
211
+ * Retrieve the app-owned us-east-1 certificate stack, named by
212
+ * `getApplicationStackName(name, "UsEast1Certificates")` (domain
213
+ * gold-plating D3, BYO path).
202
214
  *
203
215
  * CloudFront accepts viewer certificates from us-east-1 only, so
204
216
  * CloudFront-consuming patterns (staticsite, payload) whose app deploys to
@@ -211,6 +223,34 @@ export declare class App extends CdkApp {
211
223
  * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
212
224
  */
213
225
  getUsEast1CertificateStack(): AwsStack;
226
+ /**
227
+ * Retrieve the app-owned us-east-1 CloudFront logging stack — named
228
+ * `${this.name}UsEast1CdnLogging`.
229
+ *
230
+ * CloudWatch Logs accepts CloudFront standard-logging (v2) delivery
231
+ * sources, destinations and deliveries in us-east-1 only, so a logging
232
+ * CDN whose app deploys to another region creates them here; the
233
+ * distribution ARN and the log bucket ARN reach this stack through CDK
234
+ * cross-region references, which makes it a CONSUMER of the Cdn stack —
235
+ * it deploys after the Cdn stack and is destroyed before it, the mirror
236
+ * of the certificate stack. A CDN in a us-east-1 app never creates it.
237
+ *
238
+ * Everything crossing the boundary is a value the export writer requires
239
+ * stable under an unchanged logical id: the distribution ARN and the
240
+ * bucket ARN both are. Never route a per-synth value (a timestamp, an
241
+ * asset hash) through this stack's references.
242
+ */
243
+ getUsEast1CdnLoggingStack(): AwsStack;
244
+ /**
245
+ * The two us-east-1 stacks share a shape: no stack dependencies of their
246
+ * own (the CDK cross-region reference adds the consumer→producer edge),
247
+ * env-pinned to the deploying account (cross-region references resolve
248
+ * only between env-pinned stacks) and `crossRegionReferences: true` — the
249
+ * flag must be on BOTH the producing and the consuming stack, and the
250
+ * emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
251
+ * template test.
252
+ */
253
+ private getUsEast1Stack;
214
254
  /**
215
255
  * Get a VPC by name. If no name is provided, returns the default VPC.
216
256
  *
package/dist/lib/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { App as CdkApp, Aspects, Tags } from "aws-cdk-lib";
2
- import { maskSensitiveOutput } from "@fjall/util";
2
+ import { APPLICATION_STACKS, defaultVpcConstructId, getApplicationStackName, maskSensitiveOutput } from "@fjall/util";
3
3
  import { RDS_PREVENT_RENDERING_DEPRECATED_CREDENTIALS } from "aws-cdk-lib/cx-api";
4
4
  import { Vpc } from "./resources/aws/networking/vpc.js";
5
5
  import { createBastion } from "./patterns/aws/bastionFactory.js";
@@ -16,7 +16,6 @@ import { DeployTimeWriterMarkerAspect } from "./utils/deployTimeWriterMarkerAspe
16
16
  import { emitAppLevelAlbAliasExports } from "./utils/albAliasTargetRegistry.js";
17
17
  import { emitAppLevelCdnAliasExports } from "./utils/cdnAliasTargetRegistry.js";
18
18
  import { ensureFunctionLogGroups } from "./resources/aws/logging/logGroupHygiene.js";
19
- import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./utils/backupTierMapping.js";
20
19
  import { randomBytes } from "crypto";
21
20
  import { getConfig } from "./utils/getConfig.js";
22
21
  import AuditRoleFactory from "./resources/aws/audit/auditRole.js";
@@ -90,12 +89,12 @@ export class App extends CdkApp {
90
89
  applyBackupTier(tier) {
91
90
  if (this.backupTierObserved) {
92
91
  throw new Error(`Backup tier "${tier}" applied after a resource already resolved the app's tier: ` +
93
- `that resource would carry the tier's disaster-recovery tag without its versioning default. ` +
92
+ `that resource is already built at the tier it read — its versioning default, ` +
93
+ `its backup enrolment and every other tier-owned default are synthesised. ` +
94
94
  `Pass \`backup\` on the first App.getApp("${this.name}") call, before addStorage(), ` +
95
95
  `addBuildkite() or a Payload pattern.`);
96
96
  }
97
97
  this.backupTier = tier;
98
- this.globalTags[BACKUP_TIER_TAG_KEY] = BACKUP_TIER_TAG_MAP[tier];
99
98
  }
100
99
  initialiseTunnel(config) {
101
100
  if (this.networkDisabled || !this.vpc) {
@@ -113,7 +112,7 @@ export class App extends CdkApp {
113
112
  }
114
113
  else {
115
114
  const networkConfig = { ...config, vpcName: config.vpcName ?? this.name };
116
- const network = NetworkFactory.build(`${this.stackPrefix}Vpc`, networkConfig)(this, networkStack.getStack());
115
+ const network = NetworkFactory.build(defaultVpcConstructId(this.name), networkConfig)(this, networkStack.getStack());
117
116
  this.vpc = network.getVpc();
118
117
  }
119
118
  }
@@ -146,7 +145,7 @@ export class App extends CdkApp {
146
145
  // Warn if network already initialised - changing name after network exists creates a mismatch
147
146
  if (App.instance.vpc) {
148
147
  FjallLogger.warn(`App name changed from "${App.instance.name}" to "${name}" after network was initialised. ` +
149
- `VPC will have name "${App.instance.name}Vpc" but app name is "${name}". ` +
148
+ `VPC construct id will be "${defaultVpcConstructId(App.instance.name)}" but app name is "${name}". ` +
150
149
  `Ensure App.getApp() is called with the final name before initialising network.`);
151
150
  }
152
151
  App.instance.name = name;
@@ -230,19 +229,21 @@ export class App extends CdkApp {
230
229
  return this.stacks[key];
231
230
  }
232
231
  /**
233
- * Retrieve default compute stack - named as `${this.name}Compute`
232
+ * Retrieve default compute stack, named by
233
+ * `getApplicationStackName(name, "Compute")`.
234
234
  *
235
235
  * Only depends on Network. Database dependency is added automatically
236
236
  * by CDK when compute resources reference database resources.
237
237
  */
238
238
  getDefaultComputeStack() {
239
- return this.getStack(`${this.stackPrefix}Compute`, this.networkDependency());
239
+ return this.getStack(getApplicationStackName(this.name, APPLICATION_STACKS.COMPUTE), this.networkDependency());
240
240
  }
241
241
  /**
242
- * Retrieve default network stack - named as `${this.name}Network`
242
+ * Retrieve default network stack, named by
243
+ * `getApplicationStackName(name, "Network")`.
243
244
  */
244
245
  getDefaultNetworkStack() {
245
- return this.getStack(`${this.stackPrefix}Network`);
246
+ return this.getStack(getApplicationStackName(this.name, APPLICATION_STACKS.NETWORK));
246
247
  }
247
248
  /**
248
249
  * The Network stack as a dependency, or `undefined` when the app is
@@ -259,19 +260,22 @@ export class App extends CdkApp {
259
260
  return this.networkDisabled ? undefined : this.getDefaultNetworkStack();
260
261
  }
261
262
  /**
262
- * Retrieve default database stack - named as `${this.name}Database`
263
+ * Retrieve default database stack, named by
264
+ * `getApplicationStackName(name, "Database")`.
263
265
  */
264
266
  getDefaultDatabaseStack() {
265
- return this.getStack(`${this.stackPrefix}Database`, this.networkDependency());
267
+ return this.getStack(getApplicationStackName(this.name, APPLICATION_STACKS.DATABASE), this.networkDependency());
266
268
  }
267
269
  /**
268
- * Retrieve default storage stack - named as `${this.name}Storage`
270
+ * Retrieve default storage stack, named by
271
+ * `getApplicationStackName(name, "Storage")`.
269
272
  */
270
273
  getDefaultStorageStack() {
271
- return this.getStack(`${this.stackPrefix}Storage`, this.networkDependency());
274
+ return this.getStack(getApplicationStackName(this.name, APPLICATION_STACKS.STORAGE), this.networkDependency());
272
275
  }
273
276
  /**
274
- * Retrieve default CDN stack - named as `${this.name}Cdn`
277
+ * Retrieve default CDN stack, named by
278
+ * `getApplicationStackName(name, "Cdn")`.
275
279
  *
276
280
  * Depends on Network when networking is enabled. Compute/Storage dependencies
277
281
  * are added automatically by CDK when CDN resources reference ALB or S3 bucket
@@ -283,23 +287,23 @@ export class App extends CdkApp {
283
287
  // crossRegionReferences is enabled at creation so the Cdn stack can
284
288
  // consume a us-east-1 certificate (domain gold-plating D3, BYO path).
285
289
  // The flag is inert unless a cross-region reference is synthesised.
286
- return this.getStack(`${this.stackPrefix}Cdn`, this.networkDependency(), {
287
- crossRegionReferences: true
288
- });
290
+ return this.getStack(getApplicationStackName(this.name, APPLICATION_STACKS.CDN), this.networkDependency(), { crossRegionReferences: true });
289
291
  }
290
292
  /**
291
- * Retrieve default messaging stack - named as `${this.name}Messaging`
293
+ * Retrieve default messaging stack, named by
294
+ * `getApplicationStackName(name, "Messaging")`.
292
295
  *
293
296
  * Used for SQS queues, SNS topics, and EventBridge event buses.
294
297
  * Depends on Network only. These are regional services that don't
295
298
  * require VPC, but we maintain consistent stack dependency patterns.
296
299
  */
297
300
  getDefaultMessagingStack() {
298
- return this.getStack(`${this.stackPrefix}Messaging`, this.networkDependency());
301
+ return this.getStack(getApplicationStackName(this.name, APPLICATION_STACKS.MESSAGING), this.networkDependency());
299
302
  }
300
303
  /**
301
- * Retrieve the app-owned us-east-1 certificate stack — named
302
- * `${this.name}UsEast1Certificates` (domain gold-plating D3, BYO path).
304
+ * Retrieve the app-owned us-east-1 certificate stack, named by
305
+ * `getApplicationStackName(name, "UsEast1Certificates")` (domain
306
+ * gold-plating D3, BYO path).
303
307
  *
304
308
  * CloudFront accepts viewer certificates from us-east-1 only, so
305
309
  * CloudFront-consuming patterns (staticsite, payload) whose app deploys to
@@ -312,11 +316,43 @@ export class App extends CdkApp {
312
316
  * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
313
317
  */
314
318
  getUsEast1CertificateStack() {
315
- const key = `${this.stackPrefix}UsEast1Certificates`;
319
+ return this.getUsEast1Stack(APPLICATION_STACKS.US_EAST_1_CERTIFICATES, "certificate");
320
+ }
321
+ /**
322
+ * Retrieve the app-owned us-east-1 CloudFront logging stack — named
323
+ * `${this.name}UsEast1CdnLogging`.
324
+ *
325
+ * CloudWatch Logs accepts CloudFront standard-logging (v2) delivery
326
+ * sources, destinations and deliveries in us-east-1 only, so a logging
327
+ * CDN whose app deploys to another region creates them here; the
328
+ * distribution ARN and the log bucket ARN reach this stack through CDK
329
+ * cross-region references, which makes it a CONSUMER of the Cdn stack —
330
+ * it deploys after the Cdn stack and is destroyed before it, the mirror
331
+ * of the certificate stack. A CDN in a us-east-1 app never creates it.
332
+ *
333
+ * Everything crossing the boundary is a value the export writer requires
334
+ * stable under an unchanged logical id: the distribution ARN and the
335
+ * bucket ARN both are. Never route a per-synth value (a timestamp, an
336
+ * asset hash) through this stack's references.
337
+ */
338
+ getUsEast1CdnLoggingStack() {
339
+ return this.getUsEast1Stack(APPLICATION_STACKS.US_EAST_1_CDN_LOGGING, "CloudFront logging");
340
+ }
341
+ /**
342
+ * The two us-east-1 stacks share a shape: no stack dependencies of their
343
+ * own (the CDK cross-region reference adds the consumer→producer edge),
344
+ * env-pinned to the deploying account (cross-region references resolve
345
+ * only between env-pinned stacks) and `crossRegionReferences: true` — the
346
+ * flag must be on BOTH the producing and the consuming stack, and the
347
+ * emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
348
+ * template test.
349
+ */
350
+ getUsEast1Stack(stack, purpose) {
351
+ const key = getApplicationStackName(this.name, stack);
316
352
  if (!this.stacks[key]) {
317
353
  const account = process.env.CDK_DEFAULT_ACCOUNT;
318
354
  if (!account) {
319
- throw new Error(`App '${this.name}': the us-east-1 certificate stack needs a ` +
355
+ throw new Error(`App '${this.name}': the us-east-1 ${purpose} stack needs a ` +
320
356
  "concrete account (got CDK_DEFAULT_ACCOUNT undefined). " +
321
357
  "Cross-region references resolve only between env-pinned stacks " +
322
358
  "— deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT " +
@@ -25,6 +25,9 @@ export class ConfigRecorder extends Construct {
25
25
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
26
26
  versioned: true,
27
27
  removalPolicy: RemovalPolicy.RETAIN,
28
+ // AWS Config delivers configuration snapshots here on its own schedule
29
+ // and the 90-day rule below is the retention; nothing restores from it.
30
+ backupExemption: "log-delivery",
28
31
  lifecycleRules: [
29
32
  {
30
33
  expiration: Duration.days(90),
@@ -12,8 +12,7 @@ export interface DisasterRecoveryProps {
12
12
  customBackupPlans?: CustomBackupPlanConfig[];
13
13
  }
14
14
  export declare class DisasterRecovery extends Construct {
15
- readonly backupVault?: BackupVault;
16
- private readonly vaultRef;
15
+ readonly backupVault: BackupVault;
17
16
  readonly backupPlans: {
18
17
  standard: BackupPlan;
19
18
  resilient: BackupPlan;
@@ -1,20 +1,23 @@
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 { BACKUP_VAULT_NAME } from "@fjall/util";
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,
13
- RESILIENT: 365,
14
- ENTERPRISE_CONTINUOUS: 35, // AWS max for continuous backup
15
+ // AWS caps a continuous-backup window at 35 days, so the resilient and the
16
+ // enterprise continuous rules both sit at the cap: it is AWS's limit, not a
17
+ // tier's choice, which is why one constant serves both.
18
+ CONTINUOUS_MAX: 35,
15
19
  ENTERPRISE_COMPLIANCE: 2555, // 7 years
16
- COLD_STORAGE_MINIMUM: 90, // AWS minimum
17
- COLD_STORAGE_ONE_YEAR: 365
20
+ COLD_STORAGE_MINIMUM: 90 // AWS's minimum before a cold point may be deleted
18
21
  };
19
22
  // Backup window constants
20
23
  const BACKUP_WINDOWS = {
@@ -28,11 +31,13 @@ const VAULT_LOCK = {
28
31
  MAX_RETENTION: Duration.days(36500),
29
32
  GRACE_PERIOD: Duration.days(3)
30
33
  };
34
+ function liveLockMode(lock) {
35
+ if (lock === null)
36
+ return "none";
37
+ return lock.lockDate !== undefined ? "compliance" : "governance";
38
+ }
31
39
  export class DisasterRecovery extends Construct {
32
- // Set in create-mode only; in adopt-mode the existing vault is referenced, not created.
33
40
  backupVault;
34
- // Vault the plans write to: created vault (create-mode) or imported handle (adopt-mode).
35
- vaultRef;
36
41
  backupPlans;
37
42
  customBackupPlans;
38
43
  constructor(scope, id, props) {
@@ -41,9 +46,10 @@ export class DisasterRecovery extends Construct {
41
46
  const providerAccounts = config.providerAccounts;
42
47
  const account = providerAccounts.find((pa) => pa.id === props.accountId);
43
48
  const isComplianceAccount = account?.environment === "compliance";
44
- // Set by the deploy orchestration after probing DescribeBackupVault: adopt the
45
- // retained fixed-name vault by reference rather than re-CREATE it (which collides).
46
- const adoptExistingVault = this.node.tryGetContext("fjallAdoptBackupVault") === "true";
49
+ // Injected by the deploy engine after probing the live vault and the account
50
+ // stack; unset (a bare synth) is the create template.
51
+ const verdict = readBackupVaultVerdict(this.node);
52
+ const reOwned = isReownedBackupVault(verdict);
47
53
  const disasterRecoveryRegion = config.disasterRecoveryRegion;
48
54
  // Look up compliance account for cross-account replication
49
55
  // Skip if the compliance account (prevent self-replication)
@@ -58,9 +64,11 @@ export class DisasterRecovery extends Construct {
58
64
  // permanently-immutable compliance lock. See
59
65
  // decisions/2026-06-03-backup-vault-lock-mode-by-intent.md.
60
66
  const vaultLock = account?.vaultLock ?? (isComplianceAccount ? "governance" : "none");
61
- // Adopt-mode references an already-locked vault and applies no lock config,
62
- // so it is exempt from the acknowledgement gate.
63
- if (!adoptExistingVault &&
67
+ // Fjall never issues a lock call against a vault it did not create: a
68
+ // re-owned vault declares no lock and the intent only warns when it
69
+ // differs, so the acknowledgement gate guards only a lock this template
70
+ // creates.
71
+ if (!reOwned &&
64
72
  vaultLock === "compliance" &&
65
73
  account?.acknowledgeImmutableVaultLock !== true) {
66
74
  throw new Error(`Account "${account?.name ?? props.accountId}" requests vaultLock: ` +
@@ -69,7 +77,7 @@ export class DisasterRecovery extends Construct {
69
77
  `compliance lock cannot be removed by anyone, including AWS or the ` +
70
78
  `root user. Set the acknowledgement explicitly to proceed.`);
71
79
  }
72
- const lockConfiguration = vaultLock === "compliance"
80
+ const intendedLockConfiguration = vaultLock === "compliance"
73
81
  ? {
74
82
  minRetention: VAULT_LOCK.MIN_RETENTION,
75
83
  maxRetention: VAULT_LOCK.MAX_RETENTION,
@@ -81,20 +89,33 @@ export class DisasterRecovery extends Construct {
81
89
  maxRetention: VAULT_LOCK.MAX_RETENTION
82
90
  }
83
91
  : undefined;
84
- if (adoptExistingVault) {
85
- this.vaultRef = Vault.fromBackupVaultName(this, "BackupVault", BACKUP_VAULT_NAME);
86
- }
87
- else {
88
- this.backupVault = new BackupVault(this, "BackupVault", {
89
- vaultName: BACKUP_VAULT_NAME,
90
- removalPolicy: RemovalPolicy.RETAIN,
91
- lockConfiguration: lockConfiguration
92
- });
93
- this.vaultRef = this.backupVault.vault;
92
+ const lockConfiguration = reOwned ? undefined : intendedLockConfiguration;
93
+ const liveLocked = verdict.state !== "absent" && verdict.lock !== null;
94
+ if (verdict.state !== "absent" && reOwned) {
95
+ const liveMode = liveLockMode(verdict.lock);
96
+ if (liveMode !== vaultLock) {
97
+ Annotations.of(this).addWarningV2("fjall:backupVault:lockIntentNotApplied", `Backup vault "${BACKUP_VAULT_NAME}" is re-owned: Fjall does not ` +
98
+ `manage the lock of a vault it did not create. Its live lock ` +
99
+ `(${liveMode}) stays as it is and the configured vaultLock ` +
100
+ `(${vaultLock}) is not applied — align the configured vaultLock ` +
101
+ `with the live lock, or change the live lock with the AWS Backup ` +
102
+ `API where it is still changeable.`);
103
+ }
94
104
  }
95
- // Cross-account access policy — create-mode only. addToAccessPolicy is unavailable
96
- // on an imported vault, and a live (locked) vault retains the grant written at creation.
97
- if (this.backupVault && isComplianceAccount) {
105
+ this.backupVault = new BackupVault(this, "BackupVault", {
106
+ vaultName: BACKUP_VAULT_NAME,
107
+ removalPolicy: RemovalPolicy.RETAIN,
108
+ lockConfiguration,
109
+ ...(verdict.state !== "absent" &&
110
+ reOwned && {
111
+ encryptionKey: Key.fromKeyArn(this, "BackupVaultLiveKey", verdict.keyArn)
112
+ }),
113
+ importIdentityOnly: verdict.state === "unowned"
114
+ });
115
+ const vault = this.backupVault.vault;
116
+ // Cross-account copy grant. Declared in every mode; the wrapper strips it
117
+ // from the import pass, and the converge deploy applies it.
118
+ if (isComplianceAccount) {
98
119
  const allAccounts = providerAccounts;
99
120
  const productionAccounts = allAccounts.filter((acc) => acc.environment === "production");
100
121
  if (productionAccounts.length > 0) {
@@ -104,7 +125,52 @@ export class DisasterRecovery extends Construct {
104
125
  actions: ["backup:CopyIntoBackupVault"],
105
126
  resources: ["*"]
106
127
  });
107
- this.backupVault.vault.addToAccessPolicy(copyStatement);
128
+ vault.addToAccessPolicy(copyStatement);
129
+ // The access policy admits the copy; the KEY has to admit it too. A
130
+ // copy job into an encrypted vault in another account is executed by
131
+ // the SOURCE account, so without these the job fails at the
132
+ // destination with an access-denied nothing in the source account
133
+ // surfaces. The enterprise tier's seven-year record is this copy, so
134
+ // the grant is load-bearing, not hardening.
135
+ const sourcePrincipals = productionAccounts.map((acc) => new AccountPrincipal(acc.id));
136
+ if (this.backupVault.ownsEncryptionKey) {
137
+ this.backupVault.encryptionKey.addToResourcePolicy(new PolicyStatement({
138
+ sid: "FjallAllowCrossAccountBackupCopyKeyUse",
139
+ effect: Effect.ALLOW,
140
+ principals: sourcePrincipals,
141
+ actions: [
142
+ "kms:Decrypt",
143
+ "kms:DescribeKey",
144
+ "kms:Encrypt",
145
+ "kms:GenerateDataKey",
146
+ "kms:GenerateDataKeyWithoutPlaintext",
147
+ "kms:ReEncryptFrom",
148
+ "kms:ReEncryptTo"
149
+ ],
150
+ resources: ["*"]
151
+ }));
152
+ this.backupVault.encryptionKey.addToResourcePolicy(new PolicyStatement({
153
+ sid: "FjallAllowCrossAccountBackupCopyGrant",
154
+ effect: Effect.ALLOW,
155
+ principals: sourcePrincipals,
156
+ actions: ["kms:CreateGrant"],
157
+ resources: ["*"],
158
+ conditions: {
159
+ Bool: { "kms:GrantIsForAWSResource": "true" }
160
+ }
161
+ }));
162
+ }
163
+ else {
164
+ Annotations.of(this).addWarningV2("fjall:backupVault:crossAccountKeyGrantNotApplied", `Fjall does not manage the encryption key of backup vault ` +
165
+ `"${BACKUP_VAULT_NAME}" — the vault was re-owned, so the key is ` +
166
+ `yours — and cannot grant the source accounts use of it. ` +
167
+ `Cross-account copies into this vault fail at the copy job ` +
168
+ `until the key policy admits them: add ` +
169
+ `kms:Decrypt, kms:Encrypt, kms:GenerateDataKey*, kms:ReEncrypt* ` +
170
+ `and a kms:CreateGrant statement conditioned on ` +
171
+ `kms:GrantIsForAWSResource for the production accounts, from a ` +
172
+ `principal in the compliance account.`);
173
+ }
108
174
  }
109
175
  }
110
176
  // Construct cross-region replication vault ARN (same account, DR region)
@@ -118,19 +184,19 @@ export class DisasterRecovery extends Construct {
118
184
  planName: "standard",
119
185
  rules: this.createStandardBackupRules("standard"),
120
186
  tagValue: "default",
121
- backupVault: this.vaultRef
187
+ backupVault: vault
122
188
  }),
123
189
  resilient: new BackupPlan(this, "ResilientBackupPlan", {
124
190
  planName: "resilient",
125
191
  rules: this.createResilientBackupRules("resilient", replicationVaultArn),
126
192
  tagValue: "resilient",
127
- backupVault: this.vaultRef
193
+ backupVault: vault
128
194
  }),
129
195
  enterprise: new BackupPlan(this, "EnterpriseBackupPlan", {
130
196
  planName: "enterprise",
131
197
  rules: this.createEnterpriseBackupRules("enterprise", replicationVaultArn, disasterRecoveryVaultArn),
132
198
  tagValue: "enterprise",
133
- backupVault: this.vaultRef
199
+ backupVault: vault
134
200
  })
135
201
  };
136
202
  // Create custom backup plans if provided
@@ -140,7 +206,7 @@ export class DisasterRecovery extends Construct {
140
206
  planName: config.planName,
141
207
  rules: config.rules,
142
208
  tagValue: config.tagValue,
143
- backupVault: this.vaultRef
209
+ backupVault: vault
144
210
  });
145
211
  });
146
212
  }
@@ -157,7 +223,7 @@ export class DisasterRecovery extends Construct {
157
223
  exportName: `${id}ReplicationRegion`
158
224
  });
159
225
  }
160
- if (vaultLock !== "none") {
226
+ if (reOwned ? liveLocked : lockConfiguration !== undefined) {
161
227
  new CfnOutput(this, "VaultLockEnabled", {
162
228
  key: "VaultLockEnabled",
163
229
  value: "true",
@@ -177,12 +243,12 @@ export class DisasterRecovery extends Construct {
177
243
  ];
178
244
  }
179
245
  createResilientBackupRules(planName, replicationVaultArn) {
180
- const copyActions = this.createCopyActions(planName, replicationVaultArn, undefined, Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS), undefined);
246
+ const copyActions = this.createCopyActions(planName, replicationVaultArn, undefined, Duration.days(RETENTION_PERIODS.CONTINUOUS_MAX), undefined);
181
247
  return [
182
248
  new BackupPlanRule({
183
249
  ruleName: `${planName}Daily`,
184
250
  scheduleExpression: cronSchedule({ hour: "2", minute: "0" }), // 2 AM daily
185
- deleteAfter: Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS),
251
+ deleteAfter: Duration.days(RETENTION_PERIODS.CONTINUOUS_MAX),
186
252
  enableContinuousBackup: true,
187
253
  startWindow: BACKUP_WINDOWS.START,
188
254
  completionWindow: BACKUP_WINDOWS.COMPLETION,
@@ -191,25 +257,81 @@ export class DisasterRecovery extends Construct {
191
257
  ];
192
258
  }
193
259
  createEnterpriseBackupRules(planName, replicationVaultArn, disasterRecoveryVaultArn) {
194
- const continuousCopyActions = this.createCopyActions(`${planName}Continuous`, replicationVaultArn, disasterRecoveryVaultArn, Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS), undefined);
195
- const complianceCopyActions = this.createCopyActions(`${planName}Compliance`, replicationVaultArn, disasterRecoveryVaultArn, Duration.days(RETENTION_PERIODS.ENTERPRISE_COMPLIANCE), Duration.days(RETENTION_PERIODS.COLD_STORAGE_ONE_YEAR));
260
+ // One destination per rule, by role. The continuous window is the
261
+ // regional-outage answer and goes to the DR region in this account; the
262
+ // seven-year record goes to the compliance account and nowhere else.
263
+ // Sending both rules to both vaults doubled the copy volume to buy a
264
+ // second copy of the same thing.
265
+ //
266
+ // Each rule falls back to the other's destination when its own role has
267
+ // none, because the two ARNs go absent for independent reasons: the
268
+ // cross-account one whenever the organisation has no compliance account,
269
+ // and inside the compliance account itself where the self-replication
270
+ // guard clears it; the DR-region one whenever the configured
271
+ // disasterRecoveryRegion IS this account's region. A rule with no
272
+ // destination keeps every recovery point it takes in one vault in one
273
+ // region, which is the failure the tier exists to answer, so one copy in
274
+ // the less appropriate place beats none. Still one destination per rule:
275
+ // each fallback is passed only when that rule's own destination is absent.
276
+ const continuousCopyActions = this.createCopyActions(`${planName}Continuous`, replicationVaultArn, replicationVaultArn === undefined ? disasterRecoveryVaultArn : undefined, Duration.days(RETENTION_PERIODS.CONTINUOUS_MAX), undefined);
277
+ const complianceCopyActions = this.createCopyActions(`${planName}Compliance`, disasterRecoveryVaultArn === undefined ? replicationVaultArn : undefined, disasterRecoveryVaultArn, Duration.days(RETENTION_PERIODS.ENTERPRISE_COMPLIANCE), Duration.days(RETENTION_PERIODS.COLD_STORAGE_MINIMUM));
278
+ // Both candidate ARNs name `disasterRecoveryRegion`, so an absent
279
+ // `replicationVaultArn` means that region IS this account's own and
280
+ // nothing either rule can reach crosses a region — whatever destination
281
+ // the fallbacks above found. The account stack builds this construct only
282
+ // when a DR region is configured, so this is a misconfiguration rather
283
+ // than an opt-out, and the tier's whole promise rests on the copy.
284
+ //
285
+ // Warn on the plan, not on one rule. Asking only whether the compliance
286
+ // record had a destination was silent for the configuration where a
287
+ // compliance account exists and the DR region is the account's own: the
288
+ // record copies cross-account, the continuous window copies nowhere, and
289
+ // the condition that fires the warning never holds.
290
+ if (replicationVaultArn === undefined) {
291
+ if (continuousCopyActions === undefined) {
292
+ Annotations.of(this).addWarningV2("fjall:backupPlan:recoveryPointsNotCopied", `Neither enterprise rule has a copy destination, so every recovery ` +
293
+ `point the plan takes — the ` +
294
+ `${RETENTION_PERIODS.CONTINUOUS_MAX}-day continuous window and ` +
295
+ `the ${RETENTION_PERIODS.ENTERPRISE_COMPLIANCE}-day compliance ` +
296
+ `record alike — lives in one vault in one region. The configured ` +
297
+ `disasterRecoveryRegion is this account's own region and the ` +
298
+ `organisation has no compliance account — set a ` +
299
+ `disasterRecoveryRegion that differs from the account's region, ` +
300
+ `or add a compliance account for the plan to copy to.`);
301
+ }
302
+ else {
303
+ Annotations.of(this).addWarningV2("fjall:backupPlan:noCrossRegionCopy", `The enterprise plan copies to the compliance account, but nothing ` +
304
+ `it copies crosses a region: the configured ` +
305
+ `disasterRecoveryRegion is this account's own region, so both ` +
306
+ `rules' recovery points stay where the originals are. Losing the ` +
307
+ `region is the failure this tier exists to answer — set a ` +
308
+ `disasterRecoveryRegion that differs from the account's region.`);
309
+ }
310
+ }
196
311
  return [
197
312
  // Continuous backup (35-day max PITR window)
198
313
  new BackupPlanRule({
199
314
  ruleName: `${planName}Continuous`,
200
315
  scheduleExpression: cronSchedule({ hour: "2", minute: "0" }), // Daily for continuous backup
201
- deleteAfter: Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS),
316
+ deleteAfter: Duration.days(RETENTION_PERIODS.CONTINUOUS_MAX),
202
317
  enableContinuousBackup: true,
203
318
  startWindow: BACKUP_WINDOWS.START,
204
319
  completionWindow: BACKUP_WINDOWS.COMPLETION,
205
320
  copyActions: continuousCopyActions
206
321
  }),
207
- // Hourly snapshots for long-term compliance (7 years)
322
+ // Monthly snapshot for the long-term compliance record (7 years),
323
+ // cold from day 90. Monthly, not hourly: inside the 35-day window the
324
+ // continuous rule above already gives second-level recovery, so an
325
+ // hourly snapshot bought granularity nobody asks for beyond 35 days at
326
+ // 720× the job count — and 24 RDS recovery points a day would exhaust
327
+ // the account's manual-snapshot headroom in under four days. Cold
328
+ // storage cannot ride the continuous rule: aws-cdk-lib refuses
329
+ // `moveToColdStorageAfter` with `enableContinuousBackup`.
208
330
  new BackupPlanRule({
209
- ruleName: `${planName}HourlyCompliance`,
210
- scheduleExpression: cronSchedule({ minute: "0" }), // Every hour
331
+ ruleName: `${planName}MonthlyCompliance`,
332
+ scheduleExpression: cronSchedule({ day: "1", hour: "2", minute: "0" }),
211
333
  deleteAfter: Duration.days(RETENTION_PERIODS.ENTERPRISE_COMPLIANCE),
212
- moveToColdStorageAfter: Duration.days(RETENTION_PERIODS.COLD_STORAGE_ONE_YEAR),
334
+ moveToColdStorageAfter: Duration.days(RETENTION_PERIODS.COLD_STORAGE_MINIMUM),
213
335
  startWindow: BACKUP_WINDOWS.START,
214
336
  completionWindow: BACKUP_WINDOWS.COMPLETION_SHORT,
215
337
  copyActions: complianceCopyActions