@fjall/components-infrastructure 38.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 (46) hide show
  1. package/dist/lib/app.d.ts +24 -12
  2. package/dist/lib/app.js +30 -26
  3. package/dist/lib/config/aws/configRecorder.js +3 -0
  4. package/dist/lib/config/aws/disasterRecovery.js +115 -13
  5. package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
  6. package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
  7. package/dist/lib/patterns/aws/computeEcs.js +17 -1
  8. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
  9. package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
  10. package/dist/lib/patterns/aws/messaging.d.ts +10 -0
  11. package/dist/lib/patterns/aws/messaging.js +10 -6
  12. package/dist/lib/patterns/aws/storage.d.ts +41 -0
  13. package/dist/lib/patterns/aws/storage.js +23 -8
  14. package/dist/lib/resources/aws/backup/backupVault.d.ts +10 -0
  15. package/dist/lib/resources/aws/backup/backupVault.js +12 -0
  16. package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
  17. package/dist/lib/resources/aws/compute/ecs.js +27 -21
  18. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
  19. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
  20. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
  21. package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
  22. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
  23. package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
  24. package/dist/lib/resources/aws/database/dynamodb.js +2 -1
  25. package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
  26. package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
  27. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +1 -1
  28. package/dist/lib/resources/aws/logging/accessLogBucket.js +1 -1
  29. package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
  30. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +2 -0
  31. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +2 -1
  32. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
  33. package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
  34. package/dist/lib/resources/aws/messaging/sqs.js +36 -2
  35. package/dist/lib/resources/aws/networking/vpc.js +12 -6
  36. package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
  37. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
  38. package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
  39. package/dist/lib/resources/aws/storage/s3.js +89 -3
  40. package/dist/lib/utils/backupTierMapping.d.ts +23 -2
  41. package/dist/lib/utils/backupTierMapping.js +25 -0
  42. package/dist/lib/utils/exportNaming.d.ts +30 -27
  43. package/dist/lib/utils/exportNaming.js +32 -28
  44. package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
  45. package/dist/lib/utils/standardTagsAspect.js +8 -34
  46. 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
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,7 +316,7 @@ export class App extends CdkApp {
312
316
  * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
313
317
  */
314
318
  getUsEast1CertificateStack() {
315
- return this.getUsEast1Stack("UsEast1Certificates", "certificate");
319
+ return this.getUsEast1Stack(APPLICATION_STACKS.US_EAST_1_CERTIFICATES, "certificate");
316
320
  }
317
321
  /**
318
322
  * Retrieve the app-owned us-east-1 CloudFront logging stack — named
@@ -332,7 +336,7 @@ export class App extends CdkApp {
332
336
  * asset hash) through this stack's references.
333
337
  */
334
338
  getUsEast1CdnLoggingStack() {
335
- return this.getUsEast1Stack("UsEast1CdnLogging", "CloudFront logging");
339
+ return this.getUsEast1Stack(APPLICATION_STACKS.US_EAST_1_CDN_LOGGING, "CloudFront logging");
336
340
  }
337
341
  /**
338
342
  * The two us-east-1 stacks share a shape: no stack dependencies of their
@@ -343,8 +347,8 @@ export class App extends CdkApp {
343
347
  * emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
344
348
  * template test.
345
349
  */
346
- getUsEast1Stack(suffix, purpose) {
347
- const key = `${this.stackPrefix}${suffix}`;
350
+ getUsEast1Stack(stack, purpose) {
351
+ const key = getApplicationStackName(this.name, stack);
348
352
  if (!this.stacks[key]) {
349
353
  const account = process.env.CDK_DEFAULT_ACCOUNT;
350
354
  if (!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,11 +12,12 @@ import { BACKUP_VAULT_NAME, isReownedBackupVault } from "@fjall/util";
12
12
  // Backup retention constants (in days)
13
13
  const RETENTION_PERIODS = {
14
14
  STANDARD: 90,
15
- RESILIENT: 365,
16
- 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,
17
19
  ENTERPRISE_COMPLIANCE: 2555, // 7 years
18
- COLD_STORAGE_MINIMUM: 90, // AWS minimum
19
- COLD_STORAGE_ONE_YEAR: 365
20
+ COLD_STORAGE_MINIMUM: 90 // AWS's minimum before a cold point may be deleted
20
21
  };
21
22
  // Backup window constants
22
23
  const BACKUP_WINDOWS = {
@@ -125,6 +126,51 @@ export class DisasterRecovery extends Construct {
125
126
  resources: ["*"]
126
127
  });
127
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
+ }
128
174
  }
129
175
  }
130
176
  // Construct cross-region replication vault ARN (same account, DR region)
@@ -197,12 +243,12 @@ export class DisasterRecovery extends Construct {
197
243
  ];
198
244
  }
199
245
  createResilientBackupRules(planName, replicationVaultArn) {
200
- 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);
201
247
  return [
202
248
  new BackupPlanRule({
203
249
  ruleName: `${planName}Daily`,
204
250
  scheduleExpression: cronSchedule({ hour: "2", minute: "0" }), // 2 AM daily
205
- deleteAfter: Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS),
251
+ deleteAfter: Duration.days(RETENTION_PERIODS.CONTINUOUS_MAX),
206
252
  enableContinuousBackup: true,
207
253
  startWindow: BACKUP_WINDOWS.START,
208
254
  completionWindow: BACKUP_WINDOWS.COMPLETION,
@@ -211,25 +257,81 @@ export class DisasterRecovery extends Construct {
211
257
  ];
212
258
  }
213
259
  createEnterpriseBackupRules(planName, replicationVaultArn, disasterRecoveryVaultArn) {
214
- const continuousCopyActions = this.createCopyActions(`${planName}Continuous`, replicationVaultArn, disasterRecoveryVaultArn, Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS), undefined);
215
- 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
+ }
216
311
  return [
217
312
  // Continuous backup (35-day max PITR window)
218
313
  new BackupPlanRule({
219
314
  ruleName: `${planName}Continuous`,
220
315
  scheduleExpression: cronSchedule({ hour: "2", minute: "0" }), // Daily for continuous backup
221
- deleteAfter: Duration.days(RETENTION_PERIODS.ENTERPRISE_CONTINUOUS),
316
+ deleteAfter: Duration.days(RETENTION_PERIODS.CONTINUOUS_MAX),
222
317
  enableContinuousBackup: true,
223
318
  startWindow: BACKUP_WINDOWS.START,
224
319
  completionWindow: BACKUP_WINDOWS.COMPLETION,
225
320
  copyActions: continuousCopyActions
226
321
  }),
227
- // 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`.
228
330
  new BackupPlanRule({
229
- ruleName: `${planName}HourlyCompliance`,
230
- scheduleExpression: cronSchedule({ minute: "0" }), // Every hour
331
+ ruleName: `${planName}MonthlyCompliance`,
332
+ scheduleExpression: cronSchedule({ day: "1", hour: "2", minute: "0" }),
231
333
  deleteAfter: Duration.days(RETENTION_PERIODS.ENTERPRISE_COMPLIANCE),
232
- moveToColdStorageAfter: Duration.days(RETENTION_PERIODS.COLD_STORAGE_ONE_YEAR),
334
+ moveToColdStorageAfter: Duration.days(RETENTION_PERIODS.COLD_STORAGE_MINIMUM),
233
335
  startWindow: BACKUP_WINDOWS.START,
234
336
  completionWindow: BACKUP_WINDOWS.COMPLETION_SHORT,
235
337
  copyActions: complianceCopyActions
@@ -95,8 +95,13 @@ export class Buildkite extends Construct {
95
95
  appBackupTier
96
96
  });
97
97
  // Secrets (SSH deploy keys, git credentials) must survive stack teardown
98
- // and accidental overwrite.
99
- const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`, { versioned: true, removalPolicy: RemovalPolicy.RETAIN });
98
+ // and accidental overwrite — which is exactly what a backup plan is for,
99
+ // so the bucket takes the app's tier like the artefact bucket above.
100
+ const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`, {
101
+ versioned: true,
102
+ removalPolicy: RemovalPolicy.RETAIN,
103
+ appBackupTier
104
+ });
100
105
  if (fjallApiKeyParameterName !== undefined ||
101
106
  pipelineSecretsPrefix !== undefined) {
102
107
  this.addAgentSecretsEnvHook(id, managedSecretsBucket, {
@@ -367,6 +367,9 @@ export class ClickHouseDatabase extends Construct {
367
367
  const backupBucket = props.backupBucket ??
368
368
  new S3Bucket(this, "ClickHouseBackupBucket", {
369
369
  versioned: true,
370
+ ...(props.appBackupTier !== undefined && {
371
+ appBackupTier: props.appBackupTier
372
+ }),
370
373
  lifecycleRules: [
371
374
  {
372
375
  enabled: true,
@@ -525,8 +528,7 @@ export class ClickHouseDatabase extends Construct {
525
528
  `${sidecarTlsPreamble}${optimisePreamble}\n${optimiseScript}`
526
529
  ],
527
530
  secrets: maintenanceSecrets,
528
- logGroup: optimiseTaskLogGroup,
529
- securityGroups: [securityGroup]
531
+ logGroup: optimiseTaskLogGroup
530
532
  });
531
533
  maintenanceLogGroups.push({
532
534
  job: "optimise",
@@ -555,8 +557,7 @@ export class ClickHouseDatabase extends Construct {
555
557
  })}`
556
558
  ],
557
559
  secrets: maintenanceSecrets,
558
- logGroup: backupTaskLogGroup,
559
- securityGroups: [securityGroup]
560
+ logGroup: backupTaskLogGroup
560
561
  });
561
562
  maintenanceLogGroups.push({
562
563
  job: "backup",
@@ -590,6 +591,9 @@ export class ClickHouseDatabase extends Construct {
590
591
  ...(props.appBackupTier !== undefined && {
591
592
  appBackupTier: props.appBackupTier
592
593
  }),
594
+ // ClickHouse is infrastructure: the deploy engine must never roll it
595
+ // out, restart it or rebind it as one of the app's own services.
596
+ deployableServices: false,
593
597
  cluster: {
594
598
  loadBalancer: false,
595
599
  securityGroup,
@@ -1,4 +1,4 @@
1
- import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
1
+ import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, NetworkMode, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
2
2
  import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
3
3
  import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
4
  import { Annotations, Aws, Stack, Token } from "aws-cdk-lib";
@@ -269,6 +269,21 @@ export function validateEcsProps(props) {
269
269
  "does not own. Either omit cluster.securityGroup (a Fjall-owned SG will be created) or set directAccess: false.");
270
270
  }
271
271
  }
272
+ for (const entry of props.cluster?.scheduledTasks ?? []) {
273
+ if (entry.networkMode === NetworkMode.AWS_VPC) {
274
+ throw new Error(`Scheduled task '${entry.name}': networkMode AWS_VPC is not supported for scheduled tasks — ` +
275
+ "a scheduled task's connections are wired to the cluster's EC2 capacity security groups " +
276
+ "(EcsCluster.getEc2CapacityConnectable), which an awsvpc task's own ENI does not wear, so every " +
277
+ "connection grant would be unreachable at runtime with nothing failing at synth. Remove networkMode " +
278
+ "to run on the instance ENI, or set NetworkMode.HOST. Govern the task's reachability with the " +
279
+ "cluster-level securityGroup on EcsClusterConfig.");
280
+ }
281
+ if (entry.logGroup !== undefined && entry.logRetention !== undefined) {
282
+ throw new Error(`Scheduled task '${entry.name}': "logGroup" and "logRetention" are mutually exclusive on ` +
283
+ "EcsScheduledTaskConfig — the supplied logGroup owns its own retention, so logRetention " +
284
+ `(${entry.logRetention}) would be silently discarded. Supply one or the other, not both.`);
285
+ }
286
+ }
272
287
  }
273
288
  const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
274
289
  /**
@@ -976,6 +991,7 @@ export class EcsCompute extends Construct {
976
991
  applicationId: props.applicationId ??
977
992
  (App.hasInstance() ? App.getInstance().getName() : undefined),
978
993
  ...(props.taskStopWatchdog === false && { taskStopWatchdog: false }),
994
+ ...(props.deployableServices === false && { deployableServices: false }),
979
995
  ...(props.containerInsights !== undefined && {
980
996
  containerInsights: props.containerInsights
981
997
  }),
@@ -1,6 +1,6 @@
1
1
  import { type Repository } from "aws-cdk-lib/aws-ecr";
2
2
  import { type RepositoryImage, type ContainerImage, type PortMapping, type Secret as EcsSecret, type NetworkMode } from "aws-cdk-lib/aws-ecs";
3
- import { type IPeer, type ISecurityGroup, type IVpc, type Port, type SubnetSelection } from "aws-cdk-lib/aws-ec2";
3
+ import { type IPeer, type ISecurityGroup, type IVpc, type Port } from "aws-cdk-lib/aws-ec2";
4
4
  import { type ILogGroup, type RetentionDays } from "aws-cdk-lib/aws-logs";
5
5
  import { type PolicyDocument, type IManagedPolicy, type PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import type { ITopic } from "aws-cdk-lib/aws-sns";
@@ -554,7 +554,8 @@ export interface EcsScheduledTaskConfig {
554
554
  * Connections to data resources — the same connector interface services
555
555
  * use. Produces IAM grants on the task role, security-group wiring (via the
556
556
  * cluster's EC2 capacity security group — scheduled tasks run on the
557
- * cluster's instances), and schema-version gate env injection when a
557
+ * cluster's instances, which is why `networkMode: AWS_VPC` is refused
558
+ * here), and schema-version gate env injection when a
558
559
  * connected database declares `migrations:`. Scheduled tasks never
559
560
  * materialise a `fjall-schema-gate` container; only the env is injected —
560
561
  * the task's own entrypoint decides whether to gate on it.
@@ -576,8 +577,18 @@ export interface EcsScheduledTaskConfig {
576
577
  * removal default (D17).
577
578
  */
578
579
  logRetention?: RetentionDays;
579
- securityGroups?: ISecurityGroup[];
580
- subnetSelection?: SubnetSelection;
580
+ /**
581
+ * Task network mode. Omit for the `Ec2TaskDefinition` default (BRIDGE, as
582
+ * measured on aws-cdk-lib 2.265.0 — the caret range can move it); `HOST` is
583
+ * permitted.
584
+ *
585
+ * `AWS_VPC` is REFUSED at synth. A scheduled task's `connections` are wired
586
+ * onto the cluster's EC2 capacity security groups
587
+ * (`EcsCluster.getEc2CapacityConnectable`), which an awsvpc task's own ENI
588
+ * does not wear — every grant would land and every network path would not,
589
+ * with nothing failing at synth. Govern reachability with the cluster-level
590
+ * `securityGroup` on `EcsClusterConfig` instead.
591
+ */
581
592
  networkMode?: NetworkMode;
582
593
  /** Host-bind volumes mounted into the task's container. */
583
594
  volumes?: ContainerVolume[];
@@ -641,6 +652,10 @@ export interface EcsClusterConfig {
641
652
  * Scheduled tasks materialised into `Ec2TaskDefinition`s and registered via
642
653
  * `app.addSchedule(...)` against the existing `EcsScheduleTarget` shape
643
654
  * ({ ecs, serviceName, taskCount }). See AC34.
655
+ *
656
+ * These run on the cluster's own EC2 instances and share their network
657
+ * interface, so `networkMode: AWS_VPC` is refused per entry — see
658
+ * {@link EcsScheduledTaskConfig.networkMode}.
644
659
  */
645
660
  scheduledTasks?: EcsScheduledTaskConfig[];
646
661
  }
@@ -1081,6 +1096,17 @@ export interface EcsComputeProps {
1081
1096
  * opt-out for cost-sensitive clusters.
1082
1097
  */
1083
1098
  taskStopWatchdog?: false;
1099
+ /**
1100
+ * Deployable-service exports (default on): one `DeployableService` output
1101
+ * per service plus the cluster's `DeployableCluster` and `ClusterArn`
1102
+ * outputs, named by `@fjall/util`'s `stackExportNames` builders. The
1103
+ * deploy engine's `EcsServiceResolver` reads the app's Compute stack
1104
+ * outputs to find the ECS services that `fjall services`, `fjall rollout`,
1105
+ * `fjall restart`, a code-only deploy and a rollback act on. `false` opts
1106
+ * out a cluster hosting an infrastructure service, so the resolver never
1107
+ * binds it as an app service.
1108
+ */
1109
+ deployableServices?: false;
1084
1110
  /**
1085
1111
  * CloudWatch Container Insights (default off). Enabling it bills
1086
1112
  * ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
@@ -19,6 +19,7 @@ const COMPUTE_PROP_APPLICABILITY = {
19
19
  alertsTopic: ["ecs"],
20
20
  applicationId: ["ecs"],
21
21
  taskStopWatchdog: ["ecs"],
22
+ deployableServices: ["ecs"],
22
23
  containerInsights: ["ecs"],
23
24
  deployment: ["lambda"],
24
25
  functionUrl: ["lambda"],
@@ -28,6 +28,16 @@ export interface IQueueProps {
28
28
  maxReceiveCount?: number;
29
29
  };
30
30
  encryption?: "SSE_SQS" | "SSE_KMS" | "NONE";
31
+ /**
32
+ * ARN of a customer-managed KMS key to encrypt the queue (and its
33
+ * auto-created DLQ) with. Requires `encryption: "SSE_KMS"` — the resource
34
+ * layer refuses the pair rather than ignoring the key. Absent with
35
+ * `"SSE_KMS"`, the queue takes the stack's shared key.
36
+ *
37
+ * An ARN rather than an `IKey` because this is the plan vocabulary the
38
+ * scaffold and the codemod write, the same as `S3Props.kmsKeyArn`; a key
39
+ * you hold in `infrastructure.ts` passes as `key.keyArn`.
40
+ */
31
41
  kmsKeyArn?: string;
32
42
  contentBasedDeduplication?: boolean;
33
43
  fifoThroughputLimit?: "perQueue" | "perMessageGroupId";