@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
@@ -2,6 +2,7 @@ import { Construct } from "constructs";
2
2
  import { CfnOutput, Duration } from "aws-cdk-lib";
3
3
  import { toPascalCase } from "../../../utils/capitaliseString.js";
4
4
  import { Queue, QueueEncryption, FifoThroughputLimit, DeduplicationScope } from "aws-cdk-lib/aws-sqs";
5
+ import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
5
6
  import { toRemovalPolicy } from "./utils.js";
6
7
  import { createSqsDlqAlarms } from "../monitoring/index.js";
7
8
  import { validateQueuePolicyQuota } from "./queuePolicyQuota.js";
@@ -56,6 +57,33 @@ function toEncryption(encryption) {
56
57
  return QueueEncryption.SQS_MANAGED;
57
58
  }
58
59
  }
60
+ /**
61
+ * The queue's encryption mode and key, refusing the two combinations CDK
62
+ * would otherwise resolve quietly.
63
+ *
64
+ * A `masterKey` with no stated encryption means `"SSE_KMS"` — nobody hands a
65
+ * key to an unencrypted queue. A `masterKey` BESIDE `"SSE_SQS"` or `"NONE"`
66
+ * is a contradiction this wrapper used to warn about and then ignore; it
67
+ * throws now, because a queue that discards the key it was handed is
68
+ * indistinguishable at synth from one that honoured it, and the discrepancy
69
+ * only surfaces as an unreadable message months later.
70
+ */
71
+ function resolveQueueEncryption(scope, props) {
72
+ if (props.masterKey !== undefined &&
73
+ props.encryption !== undefined &&
74
+ props.encryption !== "SSE_KMS") {
75
+ throw new Error(`'masterKey' is only meaningful with encryption: "SSE_KMS", and this ` +
76
+ `queue asks for "${props.encryption}". Set encryption: "SSE_KMS" to ` +
77
+ `encrypt with the key, or drop 'masterKey' to keep "${props.encryption}".`);
78
+ }
79
+ if (props.encryption !== "SSE_KMS" && props.masterKey === undefined) {
80
+ return { encryption: toEncryption(props.encryption) };
81
+ }
82
+ return {
83
+ encryption: QueueEncryption.KMS,
84
+ masterKey: props.masterKey ?? StackEncryptionKey.of(scope).forSqsServicePublishers()
85
+ };
86
+ }
59
87
  function toFifoThroughputLimit(limit) {
60
88
  switch (limit) {
61
89
  case "perQueue":
@@ -85,6 +113,10 @@ export class SQSQueue extends Construct {
85
113
  this.id = id;
86
114
  // Sanitise id for CloudFormation output keys (must be alphanumeric)
87
115
  const outputName = toPascalCase(id);
116
+ // Resolved once and shared by the queue and its auto-created DLQ: one
117
+ // key for the pair, not one apiece. A dead-lettered message is the same
118
+ // message, so a second key would buy no isolation and double the charge.
119
+ const { encryption, masterKey } = resolveQueueEncryption(this, props);
88
120
  // SQS queues are transient work mediums: their contents (job messages,
89
121
  // failed-delivery copies) regenerate from a source of truth held elsewhere
90
122
  // (Postgres, the producing schedule/rule), so the wrapper defaults to
@@ -118,7 +150,8 @@ export class SQSQueue extends Construct {
118
150
  this.dlq = new Queue(this, `${id}DeadLetterQueue`, {
119
151
  queueName: dlqName,
120
152
  fifo: isFifo,
121
- encryption: toEncryption(props.encryption),
153
+ encryption,
154
+ encryptionMasterKey: masterKey,
122
155
  enforceSSL: true,
123
156
  retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS),
124
157
  removalPolicy: resolvedRemovalPolicy
@@ -157,7 +190,8 @@ export class SQSQueue extends Construct {
157
190
  ? Duration.seconds(props.deliveryDelay)
158
191
  : undefined,
159
192
  deadLetterQueue,
160
- encryption: toEncryption(props.encryption),
193
+ encryption,
194
+ encryptionMasterKey: masterKey,
161
195
  enforceSSL: true,
162
196
  contentBasedDeduplication: isFifo
163
197
  ? (props.contentBasedDeduplication ?? true)
@@ -1,4 +1,5 @@
1
1
  import { CfnOutput, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
+ import { vpcCidrExportName } from "@fjall/util";
2
3
  import * as ec2 from "aws-cdk-lib/aws-ec2";
3
4
  import * as s3 from "aws-cdk-lib/aws-s3";
4
5
  import { LogGroup } from "../logging/logGroup.js";
@@ -30,14 +31,15 @@ export class Vpc extends ec2.Vpc {
30
31
  });
31
32
  this.hasNatGateways = natGateways !== 0;
32
33
  this.addInterfaceEndpoints(id, props, this.hasNatGateways);
33
- // Export names are unique per account+region, so the id is load-bearing:
34
- // without it a stack holding two VPCs emits the name twice and
35
- // CloudFormation rejects the template. The `-vpc-cidr` suffix must stay
36
- // last — the webapp finds a linked app's CIDR by ListExports + suffix
37
- // filter (webapp app/.server/services/infrastructure/captureVpcCidr.ts).
34
+ // The export name is a contract owned by @fjall/util's stackExportNames
35
+ // module: the fjall.io webapp reads it by DescribeStacks on the app's
36
+ // Network stack and rebuilds this exact name — no ListExports, no suffix
37
+ // filter. The construct id is load-bearing because export names are
38
+ // unique per account+region: a stack holding two VPCs would otherwise
39
+ // emit one name twice and CloudFormation rejects the template.
38
40
  new CfnOutput(this, "VpcCidrBlock", {
39
41
  value: this.vpcCidrBlock,
40
- exportName: `${Stack.of(this).stackName}${id}-vpc-cidr`
42
+ exportName: vpcCidrExportName(Stack.of(this).stackName, id)
41
43
  });
42
44
  }
43
45
  static gatewayEndpoints(props) {
@@ -184,6 +186,10 @@ export class Vpc extends ec2.Vpc {
184
186
  bucketName: ResourceNaming.vpcFlowLogBucketName(id, Stack.of(scope).account),
185
187
  encryption: s3.BucketEncryption.S3_MANAGED,
186
188
  versioned: true,
189
+ // VPC delivers flow-log objects here continuously and the rules below
190
+ // age them out; a backup plan would copy a log stream that already has
191
+ // its own retention, so the bucket is versioned but not a source.
192
+ backupExemption: "log-delivery",
187
193
  // Custom lifecycleRules bypass S3Bucket's automatic
188
194
  // noncurrentVersionExpiration pairing — carried explicitly, or
189
195
  // noncurrent versions accumulate forever. The no-retentionDays branch
@@ -23,11 +23,28 @@ import { CustomerManagedKey } from "./kms.js";
23
23
  * SDK/CDK grants they need ride the resource's own IAM path. Service-side
24
24
  * encryptors need a resource-policy admission first: CloudWatch Logs through
25
25
  * [[forLogGroups]], SNS service publishers through
26
- * [[forSnsServicePublishers]]. Each admission is added once per stack.
26
+ * [[forSnsServicePublishers]], SQS senders through
27
+ * [[forSqsServicePublishers]]. Each admission is added once per stack.
28
+ *
29
+ * A resource may also reach the key WITHOUT the tier, through [[of]]: an SQS
30
+ * queue declaring `encryption: "SSE_KMS"` encrypts with the stack key at
31
+ * every tier, because the alternative CDK offers is an implicit key that
32
+ * RETAINs with no alias and no tags and is therefore invisible to every
33
+ * teardown sweep. Queues are not a tier-owned resource — `SSE_SQS` is free
34
+ * encryption at rest and is what the tier column's absence of SQS means —
35
+ * so the opt-in is the caller's, not the tier's.
36
+ *
37
+ * PROBE SCOPE = DELETE SCOPE. The deploy-test teardown releases a retained
38
+ * stack key only after re-reading every resource class that can name it
39
+ * (`probeStackKeyReferrers`, deploy-tests/lib/kmsKeys.mts). A construct that
40
+ * starts encrypting a new kind of resource with this key extends that walk
41
+ * in the same change, or the teardown schedules deletion of a key something
42
+ * still needs.
27
43
  */
28
44
  export declare class StackEncryptionKey extends CustomerManagedKey {
29
45
  private logsAdmitted;
30
46
  private snsPublishersAdmitted;
47
+ private sqsPublishersAdmitted;
31
48
  private constructor();
32
49
  /** The stack's shared key, created on first use. */
33
50
  static of(scope: Construct): StackEncryptionKey;
@@ -57,4 +74,12 @@ export declare class StackEncryptionKey extends CustomerManagedKey {
57
74
  * immutable.
58
75
  */
59
76
  forSnsServicePublishers(): IKey;
77
+ /**
78
+ * The key, with the SQS-sending AWS services admitted to it
79
+ * ([[SQS_SERVICE_PUBLISHERS]]). Without the admission their sends to an
80
+ * encrypted queue fail silently: SQS drops the message rather than
81
+ * returning an error to the service, so the loss surfaces as a consumer
82
+ * that never runs.
83
+ */
84
+ forSqsServicePublishers(): IKey;
60
85
  }
@@ -20,6 +20,25 @@ const SNS_SERVICE_PUBLISHERS = [
20
20
  "cloudwatch.amazonaws.com",
21
21
  "s3.amazonaws.com"
22
22
  ];
23
+ /**
24
+ * AWS services that send to an application's SQS queues on the app's behalf
25
+ * and therefore encrypt the message through the queue's key: EventBridge
26
+ * rule targets and rule dead-letter queues, S3 event notifications, and SNS
27
+ * topic subscriptions. Each needs `kms:Decrypt` + `kms:GenerateDataKey*` on
28
+ * the key's RESOURCE policy for the same reason the SNS set does — an
29
+ * identity policy cannot admit a service principal — and cannot be narrowed
30
+ * by `aws:SourceAccount` / `aws:SourceArn` for the same reason either: a
31
+ * send the key refuses is DROPPED, not rejected, so a condition that fails
32
+ * to match loses messages silently.
33
+ *
34
+ * CloudWatch is deliberately absent, and the difference from the SNS set is
35
+ * the point: an alarm action can target a topic but never a queue.
36
+ */
37
+ const SQS_SERVICE_PUBLISHERS = [
38
+ "events.amazonaws.com",
39
+ "s3.amazonaws.com",
40
+ "sns.amazonaws.com"
41
+ ];
23
42
  /**
24
43
  * The one customer-managed KMS key an enterprise-tier stack encrypts its
25
44
  * tier-owned resources with: DynamoDB tables, SNS topics, Lambda environment
@@ -41,11 +60,28 @@ const SNS_SERVICE_PUBLISHERS = [
41
60
  * SDK/CDK grants they need ride the resource's own IAM path. Service-side
42
61
  * encryptors need a resource-policy admission first: CloudWatch Logs through
43
62
  * [[forLogGroups]], SNS service publishers through
44
- * [[forSnsServicePublishers]]. Each admission is added once per stack.
63
+ * [[forSnsServicePublishers]], SQS senders through
64
+ * [[forSqsServicePublishers]]. Each admission is added once per stack.
65
+ *
66
+ * A resource may also reach the key WITHOUT the tier, through [[of]]: an SQS
67
+ * queue declaring `encryption: "SSE_KMS"` encrypts with the stack key at
68
+ * every tier, because the alternative CDK offers is an implicit key that
69
+ * RETAINs with no alias and no tags and is therefore invisible to every
70
+ * teardown sweep. Queues are not a tier-owned resource — `SSE_SQS` is free
71
+ * encryption at rest and is what the tier column's absence of SQS means —
72
+ * so the opt-in is the caller's, not the tier's.
73
+ *
74
+ * PROBE SCOPE = DELETE SCOPE. The deploy-test teardown releases a retained
75
+ * stack key only after re-reading every resource class that can name it
76
+ * (`probeStackKeyReferrers`, deploy-tests/lib/kmsKeys.mts). A construct that
77
+ * starts encrypting a new kind of resource with this key extends that walk
78
+ * in the same change, or the teardown schedules deletion of a key something
79
+ * still needs.
45
80
  */
46
81
  export class StackEncryptionKey extends CustomerManagedKey {
47
82
  logsAdmitted = false;
48
83
  snsPublishersAdmitted = false;
84
+ sqsPublishersAdmitted = false;
49
85
  constructor(stack) {
50
86
  super(stack, STACK_ENCRYPTION_KEY_ID, {
51
87
  description: `Encrypts the enterprise-tier resources of ${stack.stackName} (DynamoDB tables, SNS topics, Lambda environment, ECS Exec sessions, log groups)`,
@@ -129,4 +165,23 @@ export class StackEncryptionKey extends CustomerManagedKey {
129
165
  }
130
166
  return this.key;
131
167
  }
168
+ /**
169
+ * The key, with the SQS-sending AWS services admitted to it
170
+ * ([[SQS_SERVICE_PUBLISHERS]]). Without the admission their sends to an
171
+ * encrypted queue fail silently: SQS drops the message rather than
172
+ * returning an error to the service, so the loss surfaces as a consumer
173
+ * that never runs.
174
+ */
175
+ forSqsServicePublishers() {
176
+ if (!this.sqsPublishersAdmitted) {
177
+ this.key.addToResourcePolicy(new PolicyStatement({
178
+ sid: "AllowSqsServicePublishers",
179
+ principals: SQS_SERVICE_PUBLISHERS.map((service) => new ServicePrincipal(service)),
180
+ actions: ["kms:Decrypt", "kms:GenerateDataKey*"],
181
+ resources: ["*"]
182
+ }));
183
+ this.sqsPublishersAdmitted = true;
184
+ }
185
+ return this.key;
186
+ }
132
187
  }
@@ -1,4 +1,4 @@
1
- import { type S3VersioningExemption } from "@fjall/util/aws";
1
+ import { type S3BackupExemption, type S3VersioningExemption } from "@fjall/util/aws";
2
2
  import { Bucket, type BucketProps } from "aws-cdk-lib/aws-s3";
3
3
  import { type Construct } from "constructs";
4
4
  import { type BackupTier } from "../../../utils/backupTierMapping.js";
@@ -72,6 +72,26 @@ export interface S3BucketProps extends BucketProps {
72
72
  * disposable bucket of their own.
73
73
  */
74
74
  versioningExemption?: S3VersioningExemption;
75
+ /**
76
+ * Why this VERSIONED bucket is not a backup source. Stamped as the
77
+ * `fjall:s3:backup-exempt` tag only when versioning resolves on — an
78
+ * unversioned bucket is already outside every plan and has nothing to
79
+ * exempt.
80
+ *
81
+ * It is the counterpart of `versioningExemption`, which cannot express this
82
+ * case: that tag is stamped only when versioning is OFF. A log-delivery
83
+ * bucket must stay versioned and must stay out of the backup plan, and
84
+ * without a marker posture cannot tell it from a bucket that lost its tier
85
+ * by omission — so it raises a finding whose own auto-remediation cannot
86
+ * clear it.
87
+ *
88
+ * It beats a resolved tier, and the pair is not a contradiction: the tier
89
+ * says what the APP is, this says what THIS BUCKET is — the same
90
+ * narrower-statement-wins shape as an explicit `versioned` beating the
91
+ * tier's versioning column. A construct that owns a log-delivery bucket
92
+ * inside a tiered app passes both, and the bucket stays out of the plan.
93
+ */
94
+ backupExemption?: S3BackupExemption;
75
95
  publicReadAccess?: boolean;
76
96
  websiteHosting?: WebsiteHostingConfig;
77
97
  /**
@@ -1,9 +1,9 @@
1
- import { S3_VERSIONING_EXEMPT_TAG_KEY, SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
1
+ import { S3_BACKUP_EXEMPT_TAG_KEY, S3_VERSIONING_EXEMPT_TAG_KEY, SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
2
2
  import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
3
3
  import { BlockPublicAccess, Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
4
4
  import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
5
5
  import { RegionInfo } from "aws-cdk-lib/region-info";
6
- import { resolveEffectiveBackupTier, shouldAutoVersion } from "../../../utils/backupTierMapping.js";
6
+ import { resolveEffectiveBackupTier, shouldAutoVersion, tagBackupTier } from "../../../utils/backupTierMapping.js";
7
7
  import { toPascalCase } from "../../../utils/capitaliseString.js";
8
8
  import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
9
9
  import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
@@ -26,10 +26,16 @@ export class S3Bucket extends Bucket {
26
26
  /** The tier the bucket runs under: `backupVaultTier`, else `appBackupTier`. */
27
27
  effectiveBackupTier;
28
28
  constructor(scope, id, props = {}) {
29
- const { websiteHosting, backupVaultTier, appBackupTier, resourcePolicyStatements, aliasTargetName, appName, versioningExemption, ...cdkProps } = props;
29
+ const { websiteHosting, backupVaultTier, appBackupTier, resourcePolicyStatements, aliasTargetName, appName, versioningExemption, backupExemption, ...cdkProps } = props;
30
30
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
31
31
  const effectiveBackupTier = resolveEffectiveBackupTier(backupVaultTier, appBackupTier);
32
32
  const versioned = props.versioned ?? shouldAutoVersion(effectiveBackupTier);
33
+ // Read twice — to publish this bucket's object events, and to tag it into
34
+ // the plan. A bucket selected by one and not the other is the silent
35
+ // failure below, so both read one value rather than two copies.
36
+ const isBackupSource = versioned &&
37
+ backupExemption === undefined &&
38
+ effectiveBackupTier !== undefined;
33
39
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
34
40
  // Mirrors the CDK Bucket's own resolution: an encryptionKey with no
35
41
  // explicit encryption infers KMS. DSSE is excluded deliberately — S3
@@ -37,6 +43,21 @@ export class S3Bucket extends Bucket {
37
43
  const kmsEncryption = props.encryption === BucketEncryption.KMS ||
38
44
  props.encryption === BucketEncryption.KMS_MANAGED ||
39
45
  (props.encryption === undefined && props.encryptionKey !== undefined);
46
+ // The one shape CDK answers by minting a key nobody asked for: a
47
+ // `DeletionPolicy: Retain` `AWS::KMS::Key` with no alias and no tags,
48
+ // beside a bucket that is usually DESTROY. Every Fjall teardown sweep
49
+ // finds keys by the alias tag `CustomerManagedKey` stamps, so that key
50
+ // is invisible to all of them and outlives the app forever. The
51
+ // patterns layer refuses it in plan vocabulary (`validateStorageProps`);
52
+ // this is the same refusal for anything constructing the bucket
53
+ // directly.
54
+ if (props.encryption === BucketEncryption.KMS &&
55
+ props.encryptionKey === undefined) {
56
+ throw new Error(`BucketEncryption.KMS needs an 'encryptionKey'. Pass the key to ` +
57
+ `encrypt with, or use BucketEncryption.KMS_MANAGED for AWS's own ` +
58
+ `key or BucketEncryption.S3_MANAGED for SSE-S3 — either of which ` +
59
+ `leaves no key behind when the bucket goes.`);
60
+ }
40
61
  super(scope, id, {
41
62
  ...cdkProps,
42
63
  enforceSSL: true,
@@ -62,6 +83,19 @@ export class S3Bucket extends Bucket {
62
83
  : (props.blockPublicAccess ?? BlockPublicAccess.BLOCK_ALL),
63
84
  ...(props.bucketKeyEnabled === undefined &&
64
85
  kmsEncryption && { bucketKeyEnabled: true }),
86
+ // AWS Backup builds an S3 bucket's point-in-time window from the object
87
+ // events S3 publishes to the default EventBridge bus, and the
88
+ // bucket-level setting that publishes them is off unless asked for.
89
+ // With it off AWS Backup's managed rule stays enabled and receives
90
+ // nothing: the snapshot rules keep succeeding, the plan keeps reporting
91
+ // healthy, and the window stops advancing past the first full backup.
92
+ // AWS documents that as silent — "no notification, alert, or backup job
93
+ // failure is generated by AWS Backup" — and says it cannot detect the
94
+ // setting being turned off, so nothing downstream recovers it either.
95
+ // An explicit prop still wins: turning it off chooses snapshots over a
96
+ // window, and the tier tag the plan selects on is untouched.
97
+ ...(props.eventBridgeEnabled === undefined &&
98
+ isBackupSource && { eventBridgeEnabled: true }),
65
99
  ...(websiteHosting && {
66
100
  websiteIndexDocument: websiteHosting.indexDocument,
67
101
  websiteErrorDocument: websiteHosting.errorDocument ?? "error.html"
@@ -115,6 +149,58 @@ export class S3Bucket extends Bucket {
115
149
  if (versioningExemption !== undefined && !versioned) {
116
150
  Tags.of(this).add(S3_VERSIONING_EXEMPT_TAG_KEY, versioningExemption);
117
151
  }
152
+ // AWS Backup requires versioning on an S3 source: an unversioned bucket
153
+ // carrying the tag fails every job it is selected for, for ever. So the
154
+ // enrolment follows versioning, not the tier — the standard tier does not
155
+ // version buckets and therefore does not enrol them, and its plan covers
156
+ // the database and volume types instead.
157
+ //
158
+ // A versioned bucket is therefore a source unless it says why not. The
159
+ // three arms below are exhaustive over a versioned bucket by design: it
160
+ // is enrolled, or it is exempt and says so in a tag posture can read, or
161
+ // it is neither and that is a mistake we refuse to make silently. The
162
+ // last arm is the one that matters: `tagBackupTier` no-ops on an
163
+ // undefined tier, so before it existed a construct that simply forgot to
164
+ // thread `appBackupTier` dropped its bucket out of every plan with
165
+ // nothing anywhere to say so.
166
+ if (versioned && backupExemption !== undefined) {
167
+ // `appBackupTier` beside an exemption is the ordinary case and says
168
+ // nothing contradictory: the tier says what the app is, the exemption
169
+ // says what this bucket is. `backupVaultTier` is different — it is a
170
+ // statement about THIS bucket, so the two are opposite answers to one
171
+ // question, and the resolution below (the exemption wins) is a guess at
172
+ // which the author meant. Silently guessing is the class this arm exists
173
+ // to end, so the guess is said out loud.
174
+ if (backupVaultTier !== undefined) {
175
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:backupExemptionBeatsBucketTier", `This bucket declares both \`backupVaultTier: ` +
176
+ `"${backupVaultTier}"\` and \`backupExemption: ` +
177
+ `"${backupExemption}"\`, which say opposite things about the ` +
178
+ `same bucket. The exemption wins: the bucket is tagged ` +
179
+ `${S3_BACKUP_EXEMPT_TAG_KEY} and enrolled in no backup plan, and ` +
180
+ `the tier is not applied. Drop whichever is wrong — an ` +
181
+ `\`appBackupTier\` beside an exemption is not this case.`);
182
+ }
183
+ Tags.of(this).add(S3_BACKUP_EXEMPT_TAG_KEY, backupExemption);
184
+ }
185
+ else if (isBackupSource) {
186
+ tagBackupTier(this, effectiveBackupTier);
187
+ }
188
+ else if (versioned) {
189
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:backupTierUnresolved", `This bucket is versioned but carries no backup tier, so it is in no ` +
190
+ `backup plan. Pass \`appBackupTier\` (the StorageFactory and ` +
191
+ `BuildkiteFactory paths fill it from the App) to enrol it, or pass ` +
192
+ `\`backupExemption\` to record that it is not a backup source.`);
193
+ }
194
+ else if (effectiveBackupTier !== undefined &&
195
+ shouldAutoVersion(effectiveBackupTier) &&
196
+ versioningExemption === undefined) {
197
+ // The carve-out is the cure this message itself names: a bucket that
198
+ // already recorded why it runs unversioned has answered the question.
199
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:backupTierNotEnrolled", `versioned: false leaves this bucket out of the ` +
200
+ `${effectiveBackupTier} backup plan — AWS Backup cannot protect an ` +
201
+ `unversioned bucket. Drop the override to restore the tier's ` +
202
+ `versioning, or keep it and record why with versioningExemption.`);
203
+ }
118
204
  if (websiteHosting) {
119
205
  if (appName === undefined) {
120
206
  // Refusing beats falling back to a bucket-only export name: the
@@ -1,3 +1,4 @@
1
+ import { type IConstruct } from "constructs";
1
2
  import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
2
3
  /**
3
4
  * The application backup tier vocabulary, its defaults table, the DR tag key
@@ -7,8 +8,8 @@ import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TA
7
8
  * the CLI's synth expectations and the webapp's posture rules (which read the
8
9
  * tag back from discovered resources), so every system derives from one
9
10
  * declaration. Re-exported here so construct code keeps one import path for
10
- * tier concerns: `App.applyBackupTier()` (app-level tag), `StandardTagsAspect`
11
- * (per-resource tag) and `BackupPlan` (selection key) all read the same pair.
11
+ * tier concerns: `tagBackupTier()` (the per-resource tag) and `BackupPlan`
12
+ * (the selection key) read the same pair.
12
13
  */
13
14
  export type { BackupTier, BackupTierDefaults };
14
15
  export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
@@ -21,3 +22,23 @@ export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TA
21
22
  export declare function resolveEffectiveBackupTier(resourceTier: BackupTier | undefined, appTier: BackupTier | undefined): BackupTier | undefined;
22
23
  /** Tiers whose S3 buckets version by default. */
23
24
  export declare function shouldAutoVersion(tier: BackupTier | undefined): boolean;
25
+ /**
26
+ * Stamp the disaster-recovery tier tag that enrols a resource in the matching
27
+ * AWS Backup plan. `BackupSelection` selects on this tag alone — it carries no
28
+ * resource-type filter — so where this is called IS the selection scope, and
29
+ * only the five backup-eligible resources call it: the RDS primary instance,
30
+ * the Aurora cluster, the DynamoDB table, the ClickHouse data volume and a
31
+ * versioned bucket.
32
+ *
33
+ * Pass the construct that must carry the tag. An L2 whose only taggable member
34
+ * is the resource itself (a bucket, a volume, a table) may be passed directly;
35
+ * where the L2 owns taggable siblings the L1 is required, because a tag on the
36
+ * L2 propagates to every child — an Aurora cluster's member instances and an
37
+ * RDS instance's subnet group are the two that matter, and AWS Backup protects
38
+ * the Aurora cluster, never its members.
39
+ *
40
+ * Called at construction, never from an aspect: `Tags.of(...)` inside an
41
+ * aspect's `visit()` is an aspect adding an aspect, which aws-cdk-lib drops
42
+ * silently under `aspectStabilization: false`.
43
+ */
44
+ export declare function tagBackupTier(resource: IConstruct | undefined, tier: BackupTier | undefined): void;
@@ -1,3 +1,4 @@
1
+ import { Tags } from "aws-cdk-lib";
1
2
  import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault } from "@fjall/util";
2
3
  export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
3
4
  /**
@@ -13,3 +14,27 @@ export function resolveEffectiveBackupTier(resourceTier, appTier) {
13
14
  export function shouldAutoVersion(tier) {
14
15
  return tierDefault(tier, "s3Versioned");
15
16
  }
17
+ /**
18
+ * Stamp the disaster-recovery tier tag that enrols a resource in the matching
19
+ * AWS Backup plan. `BackupSelection` selects on this tag alone — it carries no
20
+ * resource-type filter — so where this is called IS the selection scope, and
21
+ * only the five backup-eligible resources call it: the RDS primary instance,
22
+ * the Aurora cluster, the DynamoDB table, the ClickHouse data volume and a
23
+ * versioned bucket.
24
+ *
25
+ * Pass the construct that must carry the tag. An L2 whose only taggable member
26
+ * is the resource itself (a bucket, a volume, a table) may be passed directly;
27
+ * where the L2 owns taggable siblings the L1 is required, because a tag on the
28
+ * L2 propagates to every child — an Aurora cluster's member instances and an
29
+ * RDS instance's subnet group are the two that matter, and AWS Backup protects
30
+ * the Aurora cluster, never its members.
31
+ *
32
+ * Called at construction, never from an aspect: `Tags.of(...)` inside an
33
+ * aspect's `visit()` is an aspect adding an aspect, which aws-cdk-lib drops
34
+ * silently under `aspectStabilization: false`.
35
+ */
36
+ export function tagBackupTier(resource, tier) {
37
+ if (resource === undefined || tier === undefined)
38
+ return;
39
+ Tags.of(resource).add(BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP[tier]);
40
+ }
@@ -1,35 +1,38 @@
1
1
  import type { Construct } from "constructs";
2
2
  /**
3
- * Qualify a CloudFormation export name with the stack that emits it.
3
+ * Construct-side adapter for the stack-qualified export names whose contract
4
+ * is `@fjall/util`'s `stackExportNames` module.
4
5
  *
5
- * Export names are unique per account per region, so a name built only from a
6
- * construct id or a cluster name is shared by every app that happens to use
7
- * the same id — and the ids inside a pattern are literals the app author never
8
- * chooses. Two apps then collide, and the second one fails at CloudFormation
9
- * execution ("Export with name X is already exported by stack Y") after its
10
- * first resources exist, so it rolls back rather than refusing up front.
6
+ * Export names are unique per account per region, so a name built only from
7
+ * a construct id or a cluster name is shared by every app that happens to use
8
+ * the same id — and the ids inside a pattern are literals the app author
9
+ * never chooses. Two apps then collide, and the second one fails at
10
+ * CloudFormation execution ("Export with name X is already exported by stack
11
+ * Y") after its first resources exist, so it rolls back rather than refusing
12
+ * up front. Stack names are already unique per account+region and Fjall
13
+ * derives them from the app name, so qualifying with the stack makes the
14
+ * export unique AND puts the app name in it.
11
15
  *
12
- * Stack names are already unique per account+region (CloudFormation enforces
13
- * it) and Fjall derives them from the app name, so prefixing with the stack
14
- * name makes the export globally unique AND puts the app name in it.
16
+ * `@fjall/util` owns the composition — `stackScopedExportName` and the
17
+ * builders on top of it (`deployableServiceExportName`,
18
+ * `deployableClusterExportName`, `clusterArnExportName`,
19
+ * `vpcCidrExportName`) — so the runtime consumers that read an export back
20
+ * (deploy-core's `EcsServiceResolver`, the fjall.io webapp's VPC CIDR
21
+ * capture) rebuild the exact name through the same function the construct
22
+ * emitted it with. This helper only supplies the stack: a construct passes
23
+ * itself and the suffix is qualified with the stack it lives in. An export a
24
+ * consumer rebuilds by whole name goes through its specific builder with
25
+ * `Stack.of(this).stackName`, never through this generic form.
15
26
  *
16
- * The suffix must stay last. Consumers match these by suffix and by app-name
17
- * token, never by whole string — `EcsServiceResolver` filters on
18
- * `endsWith("deployableservice")` (deploy-core
19
- * services/infrastructure/EcsServiceResolver.ts) and the webapp finds a linked
20
- * app's CIDR by `ListExports` + a `-vpc-cidr` suffix filter
21
- * (webapp app/.server/services/infrastructure/captureVpcCidr.ts). Prefixing is
22
- * transparent to both; changing the tail is not.
23
- *
24
- * NOT for exports a consumer reconstructs by whole name. Those are contracts
25
- * with their own vocabulary module and must keep their exact spelling:
26
- * `getDomainExportNames` (@fjall/util), `EXPORT_NAMES`
27
- * (patterns/aws/targets/targetResolution.ts), `SHARED_ALARM_TOPIC_EXPORT_NAME`,
28
- * the CloudTrail output keys, and `${toPascalCase(appName)}EcrRepositoryName`
29
- * (read by `resolveEcrRepositoryName` in the CLI). Account-level singletons
30
- * (`AccountId`, `Environment`, `OrganisationId`, `FjallAuditRoleArn`, …) also
31
- * keep their bare names: exactly one is meant to exist, so a collision there
32
- * correctly reports a duplicate deployment.
27
+ * NOT for exports a consumer reconstructs from its own vocabulary module.
28
+ * Those keep their exact spelling: `getDomainExportNames` (@fjall/util),
29
+ * `EXPORT_NAMES` (patterns/aws/targets/targetResolution.ts),
30
+ * `SHARED_ALARM_TOPIC_EXPORT_NAME`, the CloudTrail output keys, and
31
+ * `${toPascalCase(appName)}EcrRepositoryName` (read by
32
+ * `resolveEcrRepositoryName` in the CLI). Account-level singletons
33
+ * (`AccountId`, `Environment`, `OrganisationId`, `FjallAuditRoleArn`, …)
34
+ * also keep their bare names: exactly one is meant to exist, so a collision
35
+ * there correctly reports a duplicate deployment.
33
36
  */
34
37
  export declare function stackScopedExportName(scope: Construct, suffix: string): string;
35
38
  /**
@@ -1,38 +1,42 @@
1
1
  import { Stack } from "aws-cdk-lib";
2
+ import { stackScopedExportName as qualifyWithStack } from "@fjall/util";
2
3
  /**
3
- * Qualify a CloudFormation export name with the stack that emits it.
4
+ * Construct-side adapter for the stack-qualified export names whose contract
5
+ * is `@fjall/util`'s `stackExportNames` module.
4
6
  *
5
- * Export names are unique per account per region, so a name built only from a
6
- * construct id or a cluster name is shared by every app that happens to use
7
- * the same id — and the ids inside a pattern are literals the app author never
8
- * chooses. Two apps then collide, and the second one fails at CloudFormation
9
- * execution ("Export with name X is already exported by stack Y") after its
10
- * first resources exist, so it rolls back rather than refusing up front.
7
+ * Export names are unique per account per region, so a name built only from
8
+ * a construct id or a cluster name is shared by every app that happens to use
9
+ * the same id — and the ids inside a pattern are literals the app author
10
+ * never chooses. Two apps then collide, and the second one fails at
11
+ * CloudFormation execution ("Export with name X is already exported by stack
12
+ * Y") after its first resources exist, so it rolls back rather than refusing
13
+ * up front. Stack names are already unique per account+region and Fjall
14
+ * derives them from the app name, so qualifying with the stack makes the
15
+ * export unique AND puts the app name in it.
11
16
  *
12
- * Stack names are already unique per account+region (CloudFormation enforces
13
- * it) and Fjall derives them from the app name, so prefixing with the stack
14
- * name makes the export globally unique AND puts the app name in it.
17
+ * `@fjall/util` owns the composition — `stackScopedExportName` and the
18
+ * builders on top of it (`deployableServiceExportName`,
19
+ * `deployableClusterExportName`, `clusterArnExportName`,
20
+ * `vpcCidrExportName`) — so the runtime consumers that read an export back
21
+ * (deploy-core's `EcsServiceResolver`, the fjall.io webapp's VPC CIDR
22
+ * capture) rebuild the exact name through the same function the construct
23
+ * emitted it with. This helper only supplies the stack: a construct passes
24
+ * itself and the suffix is qualified with the stack it lives in. An export a
25
+ * consumer rebuilds by whole name goes through its specific builder with
26
+ * `Stack.of(this).stackName`, never through this generic form.
15
27
  *
16
- * The suffix must stay last. Consumers match these by suffix and by app-name
17
- * token, never by whole string — `EcsServiceResolver` filters on
18
- * `endsWith("deployableservice")` (deploy-core
19
- * services/infrastructure/EcsServiceResolver.ts) and the webapp finds a linked
20
- * app's CIDR by `ListExports` + a `-vpc-cidr` suffix filter
21
- * (webapp app/.server/services/infrastructure/captureVpcCidr.ts). Prefixing is
22
- * transparent to both; changing the tail is not.
23
- *
24
- * NOT for exports a consumer reconstructs by whole name. Those are contracts
25
- * with their own vocabulary module and must keep their exact spelling:
26
- * `getDomainExportNames` (@fjall/util), `EXPORT_NAMES`
27
- * (patterns/aws/targets/targetResolution.ts), `SHARED_ALARM_TOPIC_EXPORT_NAME`,
28
- * the CloudTrail output keys, and `${toPascalCase(appName)}EcrRepositoryName`
29
- * (read by `resolveEcrRepositoryName` in the CLI). Account-level singletons
30
- * (`AccountId`, `Environment`, `OrganisationId`, `FjallAuditRoleArn`, …) also
31
- * keep their bare names: exactly one is meant to exist, so a collision there
32
- * correctly reports a duplicate deployment.
28
+ * NOT for exports a consumer reconstructs from its own vocabulary module.
29
+ * Those keep their exact spelling: `getDomainExportNames` (@fjall/util),
30
+ * `EXPORT_NAMES` (patterns/aws/targets/targetResolution.ts),
31
+ * `SHARED_ALARM_TOPIC_EXPORT_NAME`, the CloudTrail output keys, and
32
+ * `${toPascalCase(appName)}EcrRepositoryName` (read by
33
+ * `resolveEcrRepositoryName` in the CLI). Account-level singletons
34
+ * (`AccountId`, `Environment`, `OrganisationId`, `FjallAuditRoleArn`, …)
35
+ * also keep their bare names: exactly one is meant to exist, so a collision
36
+ * there correctly reports a duplicate deployment.
33
37
  */
34
38
  export function stackScopedExportName(scope, suffix) {
35
- return `${Stack.of(scope).stackName}${suffix}`;
39
+ return qualifyWithStack(Stack.of(scope).stackName, suffix);
36
40
  }
37
41
  /**
38
42
  * Export name for the account's IPAM private default scope.