Skip to content

Commit dfa0b22

Browse files
authored
Merge pull request #435 from getlift/storage-lifecycle
Add support for S3 lifecycle rules on the `storage` construct
2 parents 75f1962 + 03a1932 commit dfa0b22

File tree

4 files changed

+120
-14
lines changed

4 files changed

+120
-14
lines changed

docs/storage.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,28 @@ constructs:
9191
encryption: kms
9292
```
9393

94+
### Lifecycle rules
95+
96+
You can configure custom [S3 lifecycle rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) to automatically delete or transition objects:
97+
98+
```yaml
99+
constructs:
100+
avatars:
101+
type: storage
102+
lifecycleRules:
103+
- prefix: tmp/
104+
expirationInDays: 1
105+
- prefix: cache/
106+
expirationInDays: 7
107+
```
108+
109+
The configuration maps directly to [CloudFormation LifecycleConfiguration Rules](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket-lifecycleconfig-rule.html) with the following conveniences:
110+
111+
- `Status: Enabled` is added by default (can be overridden)
112+
- Property names can be written in camelCase (e.g. `expirationInDays`) or PascalCase (e.g. `ExpirationInDays`)
113+
114+
These rules are added to the default lifecycle rules that Lift adds (intelligent tiering and old version cleanup).
115+
94116
## Extensions
95117

96118
You can specify an `extensions` property on the storage construct to extend the underlying CloudFormation resources. In the exemple below, the S3 Bucket CloudFormation resource generated by the `avatars` storage construct will be extended with the new `AccessControl: PublicRead` CloudFormation property.

src/constructs/aws/Storage.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CfnBucket } from "aws-cdk-lib/aws-s3";
2-
import { BlockPublicAccess, Bucket, BucketEncryption, StorageClass } from "aws-cdk-lib/aws-s3";
2+
import { BlockPublicAccess, Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
33
import type { Construct as CdkConstruct } from "constructs";
4-
import { CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
4+
import { CfnOutput, Fn, Stack } from "aws-cdk-lib";
55
import type { CfnResource } from "aws-cdk-lib";
66
import type { FromSchema } from "json-schema-to-ts";
77
import type { AwsProvider } from "@lift/providers";
@@ -16,15 +16,42 @@ const STORAGE_DEFINITION = {
1616
encryption: {
1717
anyOf: [{ const: "s3" }, { const: "kms" }],
1818
},
19+
lifecycleRules: {
20+
type: "array",
21+
items: { type: "object" },
22+
},
1923
},
2024
additionalProperties: false,
2125
} as const;
2226
const STORAGE_DEFAULTS: Required<FromSchema<typeof STORAGE_DEFINITION>> = {
2327
type: "storage",
2428
archive: 45,
2529
encryption: "s3",
30+
lifecycleRules: [],
2631
};
2732

33+
function capitalizeFirstLetter(str: string): string {
34+
return str.charAt(0).toUpperCase() + str.slice(1);
35+
}
36+
37+
function capitalizeKeys(obj: Record<string, unknown>): Record<string, unknown> {
38+
const result: Record<string, unknown> = {};
39+
for (const [key, value] of Object.entries(obj)) {
40+
const capitalizedKey = capitalizeFirstLetter(key);
41+
if (Array.isArray(value)) {
42+
result[capitalizedKey] = value.map((item: unknown) =>
43+
typeof item === "object" && item !== null ? capitalizeKeys(item as Record<string, unknown>) : item
44+
);
45+
} else if (typeof value === "object" && value !== null) {
46+
result[capitalizedKey] = capitalizeKeys(value as Record<string, unknown>);
47+
} else {
48+
result[capitalizedKey] = value;
49+
}
50+
}
51+
52+
return result;
53+
}
54+
2855
type Configuration = FromSchema<typeof STORAGE_DEFINITION>;
2956

3057
export class Storage extends AwsConstruct {
@@ -50,19 +77,40 @@ export class Storage extends AwsConstruct {
5077
versioned: true,
5178
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
5279
enforceSSL: true,
53-
lifecycleRules: [
54-
{
55-
transitions: [
56-
{
57-
storageClass: StorageClass.INTELLIGENT_TIERING,
58-
transitionAfter: Duration.days(0),
59-
},
60-
],
61-
},
62-
{
63-
noncurrentVersionExpiration: Duration.days(30),
80+
});
81+
82+
// Default lifecycle rules (always applied)
83+
const defaultRules = [
84+
{
85+
Status: "Enabled",
86+
Transitions: [
87+
{
88+
StorageClass: "INTELLIGENT_TIERING",
89+
TransitionInDays: 0,
90+
},
91+
],
92+
},
93+
{
94+
Status: "Enabled",
95+
NoncurrentVersionExpiration: {
96+
NoncurrentDays: 30,
6497
},
65-
],
98+
},
99+
];
100+
101+
// Transform user rules: capitalize keys and add Status: Enabled by default
102+
const userRules = resolvedConfiguration.lifecycleRules.map((rule) => {
103+
const capitalizedRule = capitalizeKeys(rule as Record<string, unknown>);
104+
if (!("Status" in capitalizedRule)) {
105+
capitalizedRule.Status = "Enabled";
106+
}
107+
108+
return capitalizedRule;
109+
});
110+
111+
const cfnBucket = this.bucket.node.defaultChild as CfnBucket;
112+
cfnBucket.addPropertyOverride("LifecycleConfiguration", {
113+
Rules: [...defaultRules, ...userRules],
66114
});
67115

68116
this.bucketNameOutput = new CfnOutput(this, "BucketName", {

test/fixtures/storage/serverless.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,11 @@ constructs:
3232
- HEAD
3333
- PUT
3434
- POST
35+
withLifecycleRules:
36+
type: storage
37+
lifecycleRules:
38+
- prefix: tmp/
39+
expirationInDays: 1
40+
- Prefix: cache/
41+
ExpirationInDays: 7
42+
Status: Disabled

test/unit/storage.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,32 @@ describe("storage", () => {
7676
},
7777
});
7878
});
79+
80+
it("supports custom lifecycleRules with auto-capitalization and default Status", () => {
81+
const lifecycleConfig = cfTemplate.Resources[computeLogicalId("withLifecycleRules", "Bucket")].Properties
82+
.LifecycleConfiguration as { Rules: unknown[] };
83+
expect(lifecycleConfig.Rules).toEqual([
84+
// Default rules
85+
{
86+
Status: "Enabled",
87+
Transitions: [{ StorageClass: "INTELLIGENT_TIERING", TransitionInDays: 0 }],
88+
},
89+
{
90+
Status: "Enabled",
91+
NoncurrentVersionExpiration: { NoncurrentDays: 30 },
92+
},
93+
// User rules (lowercase keys capitalized, Status: Enabled added by default)
94+
{
95+
Prefix: "tmp/",
96+
ExpirationInDays: 1,
97+
Status: "Enabled",
98+
},
99+
// User rule with already-capitalized keys and custom Status
100+
{
101+
Prefix: "cache/",
102+
ExpirationInDays: 7,
103+
Status: "Disabled",
104+
},
105+
]);
106+
});
79107
});

0 commit comments

Comments
 (0)