@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
@@ -0,0 +1,90 @@
1
+ import { type IDistribution } from "aws-cdk-lib/aws-cloudfront";
2
+ import { CfnDelivery, CfnDeliveryDestination, CfnDeliverySource } from "aws-cdk-lib/aws-logs";
3
+ import { type IBucket } from "aws-cdk-lib/aws-s3";
4
+ import { Construct } from "constructs";
5
+ /**
6
+ * Output formats CloudWatch Logs writes a CloudFront access-log delivery in.
7
+ * `json` is the default; `parquet` adds CloudWatch's conversion charge on top
8
+ * of delivery. Fixed at creation — a change replaces the destination.
9
+ */
10
+ export declare const CLOUDFRONT_LOG_FORMATS: readonly ["json", "parquet", "plain", "w3c", "raw"];
11
+ export type CloudFrontLogFormat = (typeof CLOUDFRONT_LOG_FORMATS)[number];
12
+ export declare const DEFAULT_CLOUDFRONT_LOG_FORMAT: CloudFrontLogFormat;
13
+ /**
14
+ * CloudWatch Logs mints every CloudFront delivery source, destination and
15
+ * delivery in us-east-1 whatever region the bucket is in — the delivery ARNs
16
+ * read `arn:aws:logs:us-east-1:…` and the API refuses other regions.
17
+ */
18
+ export declare const CLOUDFRONT_LOG_DELIVERY_REGION = "us-east-1";
19
+ /** The service principal that writes vended logs into the bucket. */
20
+ export declare const CLOUDFRONT_LOG_DELIVERY_PRINCIPAL = "delivery.logs.amazonaws.com";
21
+ /** Objects land at `<prefix>/<yyyy>/<MM>/<dd>/<HH>/…`. Changeable in place. */
22
+ export declare const CLOUDFRONT_LOG_S3_SUFFIX_PATH = "{yyyy}/{MM}/{dd}/{HH}";
23
+ /**
24
+ * Every delivery source and destination is named under this prefix —
25
+ * `fjall-cf-<distributionId>` for the source, `fjall-cf-<distributionId>-<digest>`
26
+ * for the destination. The deploy-test residue scan attributes delivery
27
+ * resources by the prefix and the digest width and restates both; a change
28
+ * here is a change to what the scan can see.
29
+ */
30
+ export declare const CLOUDFRONT_LOG_DELIVERY_NAME_PREFIX = "fjall-cf-";
31
+ /** Hex characters of the sha256 the destination name carries. */
32
+ export declare const CLOUDFRONT_LOG_DELIVERY_DIGEST_LENGTH = 8;
33
+ export interface CloudFrontLogDeliveryProps {
34
+ /**
35
+ * The distribution whose access logs are delivered. CloudWatch Logs allows
36
+ * one delivery source per distribution account-wide, so the source is
37
+ * named by the distribution id and nothing else.
38
+ */
39
+ readonly distribution: IDistribution;
40
+ /** The bucket the logs land in; same account as the distribution. */
41
+ readonly bucket: IBucket;
42
+ /** Key prefix the objects land under — see `normaliseCloudFrontLogPrefix`. */
43
+ readonly prefix: string;
44
+ readonly format: CloudFrontLogFormat;
45
+ }
46
+ /**
47
+ * The prefix is part of the destination ARN (`arn:aws:s3:::<bucket>/<prefix>`),
48
+ * a create-only property, and it keys the destination's name, so it must be
49
+ * a literal at synth. Leading and trailing slashes are dropped (CloudWatch
50
+ * joins the suffix path with its own separator); an empty prefix is refused
51
+ * rather than silently selecting the bucket-root `AWSLogs/…` layout.
52
+ */
53
+ export declare function normaliseCloudFrontLogPrefix(context: string, prefix: string): string;
54
+ /**
55
+ * CloudFront standard logging (v2): the CloudWatch Logs delivery source
56
+ * (the distribution), delivery destination (the bucket and prefix) and the
57
+ * delivery joining them, plus the bucket-policy statement the delivery
58
+ * principal writes through. The names — `fjall-cf-<distributionId>` and
59
+ * `fjall-cf-<distributionId>-<digest>` — carry no construct path, but
60
+ * CloudFormation replaces on the LOGICAL id and creates the replacement
61
+ * before deleting the original, and CloudWatch refuses a second source for
62
+ * the same distribution. So the three logical ids are load-bearing once a
63
+ * delivery is live: a re-layout of this construct or its owner strands the
64
+ * update in rollback, and is a breaking change with a migration (delete
65
+ * the live delivery first) rather than a refactor. The delivery test pins
66
+ * the ids in both layouts.
67
+ *
68
+ * The scope MUST be a us-east-1 stack (asserted at synth). When the
69
+ * distribution and bucket live in another region's stack, their ARNs reach
70
+ * this stack through CDK cross-region references; both are stable under an
71
+ * unchanged logical id, which the export writer requires.
72
+ */
73
+ export declare class CloudFrontLogDelivery extends Construct {
74
+ readonly sourceName: string;
75
+ readonly destinationName: string;
76
+ readonly source: CfnDeliverySource;
77
+ readonly destination: CfnDeliveryDestination;
78
+ readonly delivery: CfnDelivery;
79
+ constructor(scope: Construct, id: string, props: CloudFrontLogDeliveryProps);
80
+ /**
81
+ * The bucket-policy statement the delivery principal writes through, and
82
+ * the key-policy statement when the bucket is KMS-encrypted. Both carry the
83
+ * confused-deputy conditions; the bucket statement additionally requires
84
+ * `bucket-owner-full-control`, the one ACL a BucketOwnerEnforced bucket
85
+ * accepts. Imported buckets and keys are not managed here — a synth
86
+ * warning names the statement the owner must add, because a missing grant
87
+ * fails delivery silently rather than at deploy.
88
+ */
89
+ private grantDeliveryWrite;
90
+ }
@@ -0,0 +1,213 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Annotations, Stack, Token } from "aws-cdk-lib";
3
+ import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
4
+ import { CfnDelivery, CfnDeliveryDestination, CfnDeliverySource } from "aws-cdk-lib/aws-logs";
5
+ import { Construct } from "constructs";
6
+ /**
7
+ * Output formats CloudWatch Logs writes a CloudFront access-log delivery in.
8
+ * `json` is the default; `parquet` adds CloudWatch's conversion charge on top
9
+ * of delivery. Fixed at creation — a change replaces the destination.
10
+ */
11
+ export const CLOUDFRONT_LOG_FORMATS = [
12
+ "json",
13
+ "parquet",
14
+ "plain",
15
+ "w3c",
16
+ "raw"
17
+ ];
18
+ export const DEFAULT_CLOUDFRONT_LOG_FORMAT = "json";
19
+ /**
20
+ * CloudWatch Logs mints every CloudFront delivery source, destination and
21
+ * delivery in us-east-1 whatever region the bucket is in — the delivery ARNs
22
+ * read `arn:aws:logs:us-east-1:…` and the API refuses other regions.
23
+ */
24
+ export const CLOUDFRONT_LOG_DELIVERY_REGION = "us-east-1";
25
+ /** The service principal that writes vended logs into the bucket. */
26
+ export const CLOUDFRONT_LOG_DELIVERY_PRINCIPAL = "delivery.logs.amazonaws.com";
27
+ /** Objects land at `<prefix>/<yyyy>/<MM>/<dd>/<HH>/…`. Changeable in place. */
28
+ export const CLOUDFRONT_LOG_S3_SUFFIX_PATH = "{yyyy}/{MM}/{dd}/{HH}";
29
+ /**
30
+ * Every delivery source and destination is named under this prefix —
31
+ * `fjall-cf-<distributionId>` for the source, `fjall-cf-<distributionId>-<digest>`
32
+ * for the destination. The deploy-test residue scan attributes delivery
33
+ * resources by the prefix and the digest width and restates both; a change
34
+ * here is a change to what the scan can see.
35
+ */
36
+ export const CLOUDFRONT_LOG_DELIVERY_NAME_PREFIX = "fjall-cf-";
37
+ /** Hex characters of the sha256 the destination name carries. */
38
+ export const CLOUDFRONT_LOG_DELIVERY_DIGEST_LENGTH = 8;
39
+ /**
40
+ * The prefix is part of the destination ARN (`arn:aws:s3:::<bucket>/<prefix>`),
41
+ * a create-only property, and it keys the destination's name, so it must be
42
+ * a literal at synth. Leading and trailing slashes are dropped (CloudWatch
43
+ * joins the suffix path with its own separator); an empty prefix is refused
44
+ * rather than silently selecting the bucket-root `AWSLogs/…` layout.
45
+ */
46
+ export function normaliseCloudFrontLogPrefix(context, prefix) {
47
+ if (Token.isUnresolved(prefix)) {
48
+ throw new Error(`${context}: the log prefix must be a literal string at synth — it ` +
49
+ "keys the delivery destination's name — but a token was given.");
50
+ }
51
+ const normalised = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
52
+ if (normalised === "") {
53
+ throw new Error(`${context}: the log prefix is empty after dropping slashes. Give a ` +
54
+ "key prefix such as 'cloudfront/<id>', or omit it for the default.");
55
+ }
56
+ return normalised;
57
+ }
58
+ function assertDeliveryRegion(context, stack) {
59
+ const region = stack.region;
60
+ if (Token.isUnresolved(region)) {
61
+ throw new Error(`${context}: the delivery stack's region is unresolved at synth (a ` +
62
+ `region token). CloudWatch Logs accepts these resources in ` +
63
+ `${CLOUDFRONT_LOG_DELIVERY_REGION} only, so the stack must be ` +
64
+ "env-pinned — deploy through the Fjall CLI, or export " +
65
+ "CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION before synthesising.");
66
+ }
67
+ if (region !== CLOUDFRONT_LOG_DELIVERY_REGION) {
68
+ throw new Error(`${context}: CloudWatch Logs accepts CloudFront delivery resources in ` +
69
+ `${CLOUDFRONT_LOG_DELIVERY_REGION} only, but the scope's stack ` +
70
+ `'${stack.stackName}' is in ${region}. Create the delivery in the ` +
71
+ "app's us-east-1 logging stack (App.getUsEast1CdnLoggingStack()) — " +
72
+ "the Cdn pattern places it there itself.");
73
+ }
74
+ }
75
+ /**
76
+ * Everything create-only on the destination — the bucket, the prefix and
77
+ * the format — folds into its name, so a change replaces the destination
78
+ * under a NEW name: CloudFormation creates the replacement before deleting
79
+ * the original, and an identical name would collide. An imported bucket
80
+ * contributes its literal name; a bucket built here contributes its
81
+ * construct path (its physical name is a token), which is stable under an
82
+ * unchanged logical id — the one residual is a same-id bucket replacement,
83
+ * which keeps the name. Nothing tokenised is hashed: token spellings vary
84
+ * between synths.
85
+ */
86
+ function destinationDigest(bucket, prefix, format) {
87
+ const bucketKey = Token.isUnresolved(bucket.bucketName)
88
+ ? bucket.node.path
89
+ : bucket.bucketName;
90
+ return createHash("sha256")
91
+ .update(`${bucketKey}|${prefix}|${format}`)
92
+ .digest("hex")
93
+ .slice(0, CLOUDFRONT_LOG_DELIVERY_DIGEST_LENGTH);
94
+ }
95
+ /**
96
+ * CloudFront standard logging (v2): the CloudWatch Logs delivery source
97
+ * (the distribution), delivery destination (the bucket and prefix) and the
98
+ * delivery joining them, plus the bucket-policy statement the delivery
99
+ * principal writes through. The names — `fjall-cf-<distributionId>` and
100
+ * `fjall-cf-<distributionId>-<digest>` — carry no construct path, but
101
+ * CloudFormation replaces on the LOGICAL id and creates the replacement
102
+ * before deleting the original, and CloudWatch refuses a second source for
103
+ * the same distribution. So the three logical ids are load-bearing once a
104
+ * delivery is live: a re-layout of this construct or its owner strands the
105
+ * update in rollback, and is a breaking change with a migration (delete
106
+ * the live delivery first) rather than a refactor. The delivery test pins
107
+ * the ids in both layouts.
108
+ *
109
+ * The scope MUST be a us-east-1 stack (asserted at synth). When the
110
+ * distribution and bucket live in another region's stack, their ARNs reach
111
+ * this stack through CDK cross-region references; both are stable under an
112
+ * unchanged logical id, which the export writer requires.
113
+ */
114
+ export class CloudFrontLogDelivery extends Construct {
115
+ sourceName;
116
+ destinationName;
117
+ source;
118
+ destination;
119
+ delivery;
120
+ constructor(scope, id, props) {
121
+ super(scope, id);
122
+ const context = `CloudFront log delivery '${id}'`;
123
+ const stack = Stack.of(this);
124
+ assertDeliveryRegion(context, stack);
125
+ const { distribution, bucket, format } = props;
126
+ const prefix = normaliseCloudFrontLogPrefix(context, props.prefix);
127
+ this.sourceName = `${CLOUDFRONT_LOG_DELIVERY_NAME_PREFIX}${distribution.distributionId}`;
128
+ this.destinationName = `${this.sourceName}-${destinationDigest(bucket, prefix, format)}`;
129
+ this.source = new CfnDeliverySource(this, "Source", {
130
+ name: this.sourceName,
131
+ resourceArn: distribution.distributionArn,
132
+ logType: "ACCESS_LOGS"
133
+ });
134
+ this.destination = new CfnDeliveryDestination(this, "Destination", {
135
+ name: this.destinationName,
136
+ deliveryDestinationType: "S3",
137
+ destinationResourceArn: `${bucket.bucketArn}/${prefix}`,
138
+ outputFormat: format
139
+ });
140
+ this.delivery = new CfnDelivery(this, "Delivery", {
141
+ deliverySourceName: this.sourceName,
142
+ deliveryDestinationArn: this.destination.attrArn,
143
+ s3SuffixPath: CLOUDFRONT_LOG_S3_SUFFIX_PATH,
144
+ s3EnableHiveCompatiblePath: false
145
+ });
146
+ // `deliverySourceName` is a plain string: CloudFormation sees no edge to
147
+ // the source and would happily create the delivery first.
148
+ this.delivery.addDependency(this.source);
149
+ this.grantDeliveryWrite(context, bucket, prefix, stack);
150
+ }
151
+ /**
152
+ * The bucket-policy statement the delivery principal writes through, and
153
+ * the key-policy statement when the bucket is KMS-encrypted. Both carry the
154
+ * confused-deputy conditions; the bucket statement additionally requires
155
+ * `bucket-owner-full-control`, the one ACL a BucketOwnerEnforced bucket
156
+ * accepts. Imported buckets and keys are not managed here — a synth
157
+ * warning names the statement the owner must add, because a missing grant
158
+ * fails delivery silently rather than at deploy.
159
+ */
160
+ grantDeliveryWrite(context, bucket, prefix, deliveryStack) {
161
+ const bucketStack = Stack.of(bucket);
162
+ const principal = new ServicePrincipal(CLOUDFRONT_LOG_DELIVERY_PRINCIPAL);
163
+ const sourceArnPattern = `arn:${bucketStack.partition}:logs:${CLOUDFRONT_LOG_DELIVERY_REGION}:` +
164
+ `${bucketStack.account}:delivery-source:*`;
165
+ // The partition is a token in every stack; the account may be too.
166
+ const sourceArnForHumans = `arn:<partition>:logs:${CLOUDFRONT_LOG_DELIVERY_REGION}:` +
167
+ "<account-id>:delivery-source:*";
168
+ const deputyConditions = {
169
+ StringEquals: { "aws:SourceAccount": bucketStack.account },
170
+ ArnLike: { "aws:SourceArn": sourceArnPattern }
171
+ };
172
+ const bucketGrant = bucket.addToResourcePolicy(new PolicyStatement({
173
+ effect: Effect.ALLOW,
174
+ principals: [principal],
175
+ actions: ["s3:PutObject"],
176
+ resources: [bucket.arnForObjects(`${prefix}/*`)],
177
+ conditions: {
178
+ StringEquals: {
179
+ "s3:x-amz-acl": "bucket-owner-full-control",
180
+ ...deputyConditions.StringEquals
181
+ },
182
+ ArnLike: deputyConditions.ArnLike
183
+ }
184
+ }));
185
+ if (!bucketGrant.statementAdded) {
186
+ Annotations.of(this).addWarningV2("fjall:cloudfront:log-delivery-bucket-policy", `${context}: bucket '${bucket.node.path}' is imported, so its policy ` +
187
+ "is not managed here. Allow s3:PutObject on " +
188
+ `'${prefix}/*' to ${CLOUDFRONT_LOG_DELIVERY_PRINCIPAL} with ` +
189
+ "s3:x-amz-acl=bucket-owner-full-control, aws:SourceAccount and " +
190
+ `aws:SourceArn=${sourceArnForHumans} conditions yourself — ` +
191
+ "without it delivery fails silently.");
192
+ }
193
+ else if (bucket.policy !== undefined && bucketStack === deliveryStack) {
194
+ this.delivery.node.addDependency(bucket.policy);
195
+ }
196
+ if (bucket.encryptionKey === undefined)
197
+ return;
198
+ const keyGrant = bucket.encryptionKey.addToResourcePolicy(new PolicyStatement({
199
+ effect: Effect.ALLOW,
200
+ principals: [principal],
201
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
202
+ resources: ["*"],
203
+ conditions: deputyConditions
204
+ }), true);
205
+ if (!keyGrant.statementAdded) {
206
+ Annotations.of(this).addWarningV2("fjall:cloudfront:log-delivery-key-policy", `${context}: the bucket's KMS key '${bucket.encryptionKey.node.path}' ` +
207
+ "is imported, so its key policy is not managed here. Allow " +
208
+ `kms:GenerateDataKey* and kms:Decrypt to ${CLOUDFRONT_LOG_DELIVERY_PRINCIPAL} ` +
209
+ `with aws:SourceAccount and aws:SourceArn=${sourceArnForHumans} ` +
210
+ "conditions yourself — without it delivery fails silently.");
211
+ }
212
+ }
213
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./cloudFront.js";
2
+ export * from "./cloudFrontLogDelivery.js";
2
3
  export * from "./responseHeadersPolicy.js";
@@ -1,2 +1,3 @@
1
1
  export * from "./cloudFront.js";
2
+ export * from "./cloudFrontLogDelivery.js";
2
3
  export * from "./responseHeadersPolicy.js";
@@ -152,11 +152,14 @@ export default class EcsCluster extends Construct implements IConnectable {
152
152
  private addServiceToCluster;
153
153
  private setupConnections;
154
154
  /**
155
- * Create DeployableService outputs for deployment automation.
156
- * Each service gets a DeployableService output so the deployment service
157
- * can find and deploy all services in the cluster.
155
+ * The outputs the deploy engine's `EcsServiceResolver` reads to find this
156
+ * cluster's services: the cluster's `DeployableCluster` and `ClusterArn`,
157
+ * then one `DeployableService` per service. Export names come from
158
+ * `@fjall/util`'s `stackExportNames` builders — the same functions the
159
+ * resolver rebuilds them with — and the output keys keep the PascalCase
160
+ * form a logical id needs.
158
161
  */
159
- private addDeployableServiceOutputs;
162
+ private addDeployableOutputs;
160
163
  /** Checks if any service in the cluster uses a Fargate capacity provider. */
161
164
  private anyServiceUsesFargate;
162
165
  /** Checks if any service in the cluster uses an EC2 capacity provider. */
@@ -8,7 +8,7 @@ import { tierDefault } from "../../../utils/backupTierMapping.js";
8
8
  import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
9
9
  import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
10
10
  import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
11
- import { stackScopedExportName } from "../../../utils/exportNaming.js";
11
+ import { clusterArnExportName, deployableClusterExportName, deployableServiceExportName } from "@fjall/util";
12
12
  import { toPascalCase } from "../../../utils/capitaliseString.js";
13
13
  import { createEcsServiceAlarms, createEcsTaskStopWatchdog, createLogPatternAlarms } from "../monitoring/index.js";
14
14
  // Extracted modules
@@ -177,7 +177,9 @@ export default class EcsCluster extends Construct {
177
177
  applicationId: props.applicationId
178
178
  });
179
179
  }
180
- this.addDeployableServiceOutputs(props);
180
+ if (props.deployableServices !== false) {
181
+ this.addDeployableOutputs(props);
182
+ }
181
183
  this.connections = this.setupConnections(props);
182
184
  Aspects.of(this).add(new CapacityProviderDependencyAspect(this.cluster));
183
185
  }
@@ -412,16 +414,31 @@ export default class EcsCluster extends Construct {
412
414
  });
413
415
  }
414
416
  /**
415
- * Create DeployableService outputs for deployment automation.
416
- * Each service gets a DeployableService output so the deployment service
417
- * can find and deploy all services in the cluster.
417
+ * The outputs the deploy engine's `EcsServiceResolver` reads to find this
418
+ * cluster's services: the cluster's `DeployableCluster` and `ClusterArn`,
419
+ * then one `DeployableService` per service. Export names come from
420
+ * `@fjall/util`'s `stackExportNames` builders — the same functions the
421
+ * resolver rebuilds them with — and the output keys keep the PascalCase
422
+ * form a logical id needs.
418
423
  */
419
- addDeployableServiceOutputs(props) {
424
+ addDeployableOutputs(props) {
425
+ const stackName = Stack.of(this).stackName;
426
+ new CfnOutput(this, `${this.outputName}DeployableCluster`, {
427
+ key: `${this.outputName}DeployableCluster`,
428
+ exportName: deployableClusterExportName(stackName, props.clusterName),
429
+ value: this.cluster.clusterArn
430
+ });
431
+ new CfnOutput(this, `${this.outputName}ClusterArn`, {
432
+ key: `${this.outputName}ClusterArn`,
433
+ exportName: clusterArnExportName(stackName, props.clusterName),
434
+ value: this.cluster.clusterArn,
435
+ description: `ECS Cluster ARN for ${props.clusterName}`
436
+ });
420
437
  for (const [serviceName, serviceData] of this.services) {
421
- const safeServiceName = toPascalCase(serviceName);
422
- new CfnOutput(this, `${this.outputName}${safeServiceName}DeployableService`, {
423
- key: `${this.outputName}${safeServiceName}DeployableService`,
424
- exportName: stackScopedExportName(this, `${props.clusterName}${serviceName}DeployableService`),
438
+ const outputKey = `${this.outputName}${toPascalCase(serviceName)}DeployableService`;
439
+ new CfnOutput(this, outputKey, {
440
+ key: outputKey,
441
+ exportName: deployableServiceExportName(stackName, props.clusterName, serviceName),
425
442
  value: serviceData.service.serviceArn,
426
443
  description: `Deployable ECS Service ARN for ${serviceName} in ${props.clusterName}`
427
444
  });
@@ -467,17 +484,6 @@ export default class EcsCluster extends Construct {
467
484
  }
468
485
  }
469
486
  });
470
- new CfnOutput(this, `${this.outputName}DeployableCluster`, {
471
- key: `${this.outputName}DeployableCluster`,
472
- exportName: stackScopedExportName(this, `${props.clusterName}DeployableCluster`),
473
- value: cluster.clusterArn
474
- });
475
- new CfnOutput(this, `${this.outputName}ClusterArn`, {
476
- key: `${this.outputName}ClusterArn`,
477
- exportName: stackScopedExportName(this, `${props.clusterName}ClusterArn`),
478
- value: cluster.clusterArn,
479
- description: `ECS Cluster ARN for ${props.clusterName}`
480
- });
481
487
  return cluster;
482
488
  }
483
489
  static build(id, props) {
@@ -195,6 +195,9 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
195
195
  ...(ec2Config.persistentDataVolume.alarmTopic === undefined &&
196
196
  ctx.props.alertsTopic !== undefined && {
197
197
  alarmTopic: ctx.props.alertsTopic
198
+ }),
199
+ ...(ctx.props.appBackupTier !== undefined && {
200
+ appBackupTier: ctx.props.appBackupTier
198
201
  })
199
202
  },
200
203
  // Pinned slots reproduce the deployed fjall:OwnerLogicalId verbatim —
@@ -763,6 +763,17 @@ export interface EcsClusterProps {
763
763
  * auditable opt-out for cost-sensitive clusters.
764
764
  */
765
765
  taskStopWatchdog?: false;
766
+ /**
767
+ * Deployable-service exports (default on): one `DeployableService` output
768
+ * per service plus the cluster's `DeployableCluster` and `ClusterArn`
769
+ * outputs, named by `@fjall/util`'s `stackExportNames` builders. The
770
+ * deploy engine's `EcsServiceResolver` reads the app's Compute stack
771
+ * outputs to find the ECS services that `fjall services`, `fjall rollout`,
772
+ * `fjall restart`, a code-only deploy and a rollback act on. `false` opts
773
+ * out a cluster hosting an infrastructure service, so the resolver never
774
+ * binds it as an app service.
775
+ */
776
+ deployableServices?: false;
766
777
  /**
767
778
  * CloudWatch Container Insights. Default: the app backup tier's
768
779
  * `containerInsights` column (on at enterprise, off below). Enabling it
@@ -26,6 +26,14 @@ import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTy
26
26
  * a field on `EcsServiceProps`, so a direct `new EcsCluster(...)` consumer
27
27
  * cannot pass it and there is no resources-layer code path to validate.
28
28
  *
29
+ * Same applies to `cluster.scheduledTasks`: the whole array is a patterns-
30
+ * layer field on `EcsClusterConfig`, materialised into `Ec2TaskDefinition`s
31
+ * and `app.addSchedule(...)` calls by `materialiseScheduledTasks` in
32
+ * `computeEcs.ts`. Its two per-entry refusals (`networkMode: AWS_VPC`, and
33
+ * `logGroup` together with `logRetention`) therefore run in `validateEcsProps`
34
+ * only. `EcsClusterProps` carries no `scheduledTasks` field, so a mirror here
35
+ * would be unreachable.
36
+ *
29
37
  * @param props - The cluster props to validate
30
38
  * @param annotationsScope - When provided (the `EcsCluster` constructor passes
31
39
  * `this`), non-fatal findings from {@link validateEc2TaskMemoryFit} are
@@ -32,6 +32,14 @@ import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
32
32
  * a field on `EcsServiceProps`, so a direct `new EcsCluster(...)` consumer
33
33
  * cannot pass it and there is no resources-layer code path to validate.
34
34
  *
35
+ * Same applies to `cluster.scheduledTasks`: the whole array is a patterns-
36
+ * layer field on `EcsClusterConfig`, materialised into `Ec2TaskDefinition`s
37
+ * and `app.addSchedule(...)` calls by `materialiseScheduledTasks` in
38
+ * `computeEcs.ts`. Its two per-entry refusals (`networkMode: AWS_VPC`, and
39
+ * `logGroup` together with `logRetention`) therefore run in `validateEcsProps`
40
+ * only. `EcsClusterProps` carries no `scheduledTasks` field, so a mirror here
41
+ * would be unreachable.
42
+ *
35
43
  * @param props - The cluster props to validate
36
44
  * @param annotationsScope - When provided (the `EcsCluster` constructor passes
37
45
  * `this`), non-fatal findings from {@link validateEc2TaskMemoryFit} are
@@ -3,6 +3,7 @@ import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
3
3
  import { EbsDeviceVolumeType, Volume } from "aws-cdk-lib/aws-ec2";
4
4
  import type { ITopic } from "aws-cdk-lib/aws-sns";
5
5
  import { Construct } from "constructs";
6
+ import { type BackupTier } from "../../../utils/backupTierMapping.js";
6
7
  import { LambdaFunction } from "./lambda.js";
7
8
  import { SQSQueue } from "../messaging/sqs.js";
8
9
  /**
@@ -53,6 +54,13 @@ export interface PersistentDataVolumeProps {
53
54
  * re-attach queue's DLQ-depth alarm (created only when the topic is set).
54
55
  */
55
56
  alarmTopic?: ITopic;
57
+ /**
58
+ * The application's backup tier. Enrols the volume in the matching AWS
59
+ * Backup plan via the disaster-recovery tier tag. Independent of the
60
+ * volume's `SNAPSHOT` removal policy, which only covers a clean destroy:
61
+ * the plan's recovery points are the copies that reach the DR region.
62
+ */
63
+ appBackupTier?: BackupTier;
56
64
  }
57
65
  /**
58
66
  * Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
@@ -10,6 +10,7 @@ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
10
10
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
11
11
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
12
12
  import { Construct } from "constructs";
13
+ import { tagBackupTier } from "../../../utils/backupTierMapping.js";
13
14
  import { attachInlineAsgLifecycleHook } from "./asgInlineLifecycleHook.js";
14
15
  import { LambdaFunction } from "./lambda.js";
15
16
  import { SQSQueue } from "../messaging/sqs.js";
@@ -104,6 +105,7 @@ export class PersistentDataVolume extends Construct {
104
105
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE, PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE);
105
106
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID, this.ownerLogicalId);
106
107
  Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_STACK_ID, Aws.STACK_ID);
108
+ tagBackupTier(this.volume, props.appBackupTier);
107
109
  this.queue = new SQSQueue(this, `${id}Queue`, {
108
110
  visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
109
111
  deadLetterQueue: { enabled: true, maxReceiveCount: 5 },
@@ -1,6 +1,6 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput } from "aws-cdk-lib";
3
- import { tierDefault } from "../../../utils/backupTierMapping.js";
3
+ import { tagBackupTier, tierDefault } from "../../../utils/backupTierMapping.js";
4
4
  import { toRemovalPolicy } from "../../../utils/removalPolicy.js";
5
5
  import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
6
6
  import { Table, AttributeType, BillingMode, StreamViewType, ProjectionType, TableEncryption } from "aws-cdk-lib/aws-dynamodb";
@@ -98,6 +98,7 @@ export class DynamoDBTable extends Construct {
98
98
  removalPolicy: toRemovalPolicy(props.removalPolicy),
99
99
  timeToLiveAttribute: props.timeToLiveAttribute
100
100
  });
101
+ tagBackupTier(this.table, props.appBackupTier);
101
102
  if (props.globalSecondaryIndexes) {
102
103
  for (const gsi of props.globalSecondaryIndexes) {
103
104
  const gsiProps = {
@@ -6,7 +6,7 @@ import { Construct } from "constructs";
6
6
  import { SecurityGroup } from "../networking/securityGroup.js";
7
7
  import { Secret } from "../secrets/index.js";
8
8
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
9
- import { tierDefault } from "../../../utils/backupTierMapping.js";
9
+ import { tagBackupTier, tierDefault } from "../../../utils/backupTierMapping.js";
10
10
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
11
11
  import { getCdkDatabaseInsightsMode, getDatabaseInsightsRetention } from "./index.js";
12
12
  import { RDS_DEFAULTS, SERVERLESS_V2_AUTO_PAUSE_MIN_SECONDS, SERVERLESS_V2_AUTO_PAUSE_MAX_SECONDS, MIN_AUTO_PAUSE_POSTGRES_VERSION } from "./rdsDefaults.js";
@@ -255,6 +255,11 @@ export class RdsAurora extends Construct {
255
255
  this.databaseCluster = new DatabaseCluster(this, `${id}Database`, clusterProps);
256
256
  }
257
257
  this.cfnCluster = this.databaseCluster.node.defaultChild;
258
+ // The L1, not the L2: AWS Backup protects the Aurora cluster, and a tag on
259
+ // the DatabaseCluster construct would propagate to every member instance,
260
+ // each of which would then be selected as an RDS instance it cannot back
261
+ // up.
262
+ tagBackupTier(this.cfnCluster, props.appBackupTier);
258
263
  // Global secondary clusters can't specify these properties
259
264
  if (props.isGlobalSecondary && this.cfnCluster) {
260
265
  this.cfnCluster.masterUsername = undefined;
@@ -13,7 +13,7 @@ import { RDS_DEFAULTS } from "./rdsDefaults.js";
13
13
  import { DEFAULT_POSTGRES_ENGINE_CONFIG, resolveDatabaseInsights, resolveStorageEncryptionKey, resolvePerformanceInsightsKey, addMultiUserSecretRotation, warnIfSnapshotUsernameAssumed } from "./rdsHelpers.js";
14
14
  import { resolveSecretRotation, isCMKRequested } from "../../../utils/databaseTypes.js";
15
15
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
16
- import { tierDefault } from "../../../utils/backupTierMapping.js";
16
+ import { tagBackupTier, tierDefault } from "../../../utils/backupTierMapping.js";
17
17
  import { addProxyCfnOutput } from "./rdsProxyOutput.js";
18
18
  import { createRdsAlarms } from "../monitoring/index.js";
19
19
  export class RdsInstance extends Construct {
@@ -189,6 +189,11 @@ export class RdsInstance extends Construct {
189
189
  credentials: Credentials.fromSecret(this.databaseCredentials.secret)
190
190
  });
191
191
  }
192
+ // The L1, not the L2: a tag on the DatabaseInstance construct propagates
193
+ // to the subnet group it creates. The read replica added below is
194
+ // deliberately left untagged — AWS Backup would protect it as a second
195
+ // full copy of storage the primary's own recovery points already hold.
196
+ tagBackupTier(this.database.node.defaultChild, props.appBackupTier);
192
197
  }
193
198
  rotateSecret(rotationConfig) {
194
199
  this.masterSecret = addMultiUserSecretRotation({
@@ -9,16 +9,23 @@ export declare const ACCESS_LOG_RETENTION_DAYS = 90;
9
9
  /**
10
10
  * The one bucket an enterprise-tier stack delivers its access logs to —
11
11
  * application load balancer access logs (`alb/<name>/`) and CloudFront
12
- * standard logs (`cloudfront/<id>/`) share it under distinct prefixes.
12
+ * standard logs v2 (`cloudfront/<id>/`) share it under distinct prefixes.
13
13
  *
14
14
  * Its shape is dictated by the two delivery services and deliberately NOT
15
15
  * tier-aware: ELB log delivery requires SSE-S3 (it cannot write to a
16
- * KMS-encrypted bucket), CloudFront standard logging requires ACLs enabled
17
- * (`OBJECT_WRITER` ownership, the CDK's own logging-bucket shape), and
18
- * versioning would only multiply expired log objects — the `access-logs`
19
- * exemption marker tells posture so, landing `S3_VERSIONING_DISABLED`
20
- * `mitigated` rather than `open`. The bucket takes the env-aware removal
21
- * policy like every other bucket: production RETAINs its audit evidence,
22
- * non-production DESTROYs.
16
+ * KMS-encrypted bucket); both deliveries are bucket-policy grants, so
17
+ * ownership is `BUCKET_OWNER_ENFORCED` (ACLs off — the S3 default, and the
18
+ * posture every other Fjall bucket carries); and versioning would only
19
+ * multiply expired log objects — the `access-logs` exemption marker tells
20
+ * posture so, landing `S3_VERSIONING_DISABLED` `mitigated` rather than
21
+ * `open`. The bucket takes the env-aware removal policy like every other
22
+ * bucket: production RETAINs its audit evidence, non-production DESTROYs.
23
+ *
24
+ * Migration from 33.0.0–37.x: CloudFront standard (legacy) logging granted
25
+ * its `awslogsdelivery` canonical user FULL_CONTROL on the bucket ACL and
26
+ * never removes it, so on a bucket legacy logging wrote to the ownership
27
+ * flip fails with `InvalidBucketAclWithObjectOwnership`. Cure once, then
28
+ * redeploy: `aws s3api put-bucket-acl --bucket <name> --acl private`. The
29
+ * objects already delivered stay readable under the old prefix.
23
30
  */
24
31
  export declare function stackAccessLogBucket(scope: Construct): S3Bucket;