diff --git a/README.md b/README.md
index a9534b0..a1c1dee 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,126 @@
# terraform-aws-s3
-Terraform module to create an S3 bucket
+Terraform module to create an S3 bucket with flexible lifecycle configuration.
+
+## Features
+
+- ✅ **Dynamic Lifecycle Rules** - Flexible configuration supporting any S3 lifecycle pattern
+- ✅ **Multiple Rule Types** - Expiration, transitions, cleanup, version management
+- ✅ **Flexible Filtering** - Prefix and tag-based filtering
+- ✅ **Cost Optimization** - Storage class transitions for cost savings
+- ✅ **Version Management** - Noncurrent version expiration support
+- ✅ **Security** - Public access blocking and encryption
+- ✅ **Logging** - Optional S3 access logging
+
+## Lifecycle Rules Examples
+
+The module supports dynamic lifecycle rules through the `lifecycle_rules` variable. Here are some common patterns:
+
+### Basic Examples
+
+**Simple Expiration:**
+
+```hcl
+lifecycle_rules = [
+ {
+ id = "expire-after-30-days"
+ status = "Enabled"
+ expiration = {
+ days = 30
+ }
+ }
+]
+```
+
+**Cleanup Incomplete Uploads:**
+
+```hcl
+lifecycle_rules = [
+ {
+ id = "cleanup-incomplete-uploads"
+ status = "Enabled"
+ abort_incomplete_multipart_upload = {
+ days_after_initiation = 7
+ }
+ }
+]
+```
+
+**Log Retention with Prefix Filter:**
+
+```hcl
+lifecycle_rules = [
+ {
+ id = "log-retention"
+ status = "Enabled"
+ filter = {
+ prefix = "logs/"
+ }
+ expiration = {
+ days = 90
+ }
+ noncurrent_version_expiration = {
+ noncurrent_days = 30
+ }
+ }
+]
+```
+
+**Storage Class Transitions:**
+
+```hcl
+lifecycle_rules = [
+ {
+ id = "cost-optimization"
+ status = "Enabled"
+ filter = {
+ prefix = "data/"
+ }
+ transitions = [
+ {
+ days = 30
+ storage_class = "STANDARD_IA"
+ },
+ {
+ days = 90
+ storage_class = "GLACIER"
+ },
+ {
+ days = 365
+ storage_class = "DEEP_ARCHIVE"
+ }
+ ]
+ }
+]
+```
+
+**Tag-based Filtering:**
+
+```hcl
+lifecycle_rules = [
+ {
+ id = "production-data-retention"
+ status = "Enabled"
+ filter = {
+ tag = {
+ key = "Environment"
+ value = "production"
+ }
+ }
+ expiration = {
+ days = 2555 # 7 years
+ }
+ }
+]
+```
+
+### Supported Rule Types
+
+- `expiration` - Delete objects after specified days/date
+- `noncurrent_version_expiration` - Delete noncurrent versions
+- `abort_incomplete_multipart_upload` - Clean up failed uploads
+- `transitions` - Move objects to different storage classes
+- `filter` - Apply rules to specific objects (prefix/tag)
## Requirements
@@ -48,10 +168,7 @@ No modules.
| [enable\_versioning](#input\_enable\_versioning) | Enable versioning for the bucket | `bool` | `true` | no |
| [force\_destroy](#input\_force\_destroy) | Whether to allow deletion of non-empty bucket | `bool` | `false` | no |
| [ignore\_public\_acls](#input\_ignore\_public\_acls) | Whether to ignore public ACLs for this bucket. | `bool` | `true` | no |
-| [lifecycle\_abort\_incomplete\_multipart\_upload\_days](#input\_lifecycle\_abort\_incomplete\_multipart\_upload\_days) | Number of days after which incomplete multipart uploads are aborted. This helps reduce storage costs by cleaning up failed uploads. | `number` | `1` | no |
-| [lifecycle\_enabled](#input\_lifecycle\_enabled) | Enable lifecycle configuration for the S3 bucket | `bool` | `false` | no |
-| [lifecycle\_rule\_id](#input\_lifecycle\_rule\_id) | ID for the lifecycle rule. Must be unique within the bucket. | `string` | `"cleanup-incomplete-uploads"` | no |
-| [lifecycle\_rule\_status](#input\_lifecycle\_rule\_status) | Status of the lifecycle rule. Set to 'Disabled' to temporarily disable the rule without deleting it. | `string` | `"Disabled"` | no |
+| [lifecycle\_rules](#input\_lifecycle\_rules) | List of lifecycle rules for the S3 bucket. Each rule is a map that will be passed directly to the aws_s3_bucket_lifecycle_configuration resource. | `any` | `[]` | no |
| [logging\_enabled](#input\_logging\_enabled) | Enable logging for the S3 bucket | `bool` | `false` | no |
| [logging\_encryption\_algorithm](#input\_logging\_encryption\_algorithm) | The encryption algorithm used for S3 logging. Valid values: 'AES256', 'aws:kms'. | `string` | `"AES256"` | no |
| [logging\_encryption\_enabled](#input\_logging\_encryption\_enabled) | Enable encryption for S3 logging. | `bool` | `true` | no |
diff --git a/examples/lifecycle/README.md b/examples/lifecycle/README.md
index 4b07e54..c587eb3 100644
--- a/examples/lifecycle/README.md
+++ b/examples/lifecycle/README.md
@@ -1,12 +1,14 @@
# S3 Bucket with Lifecycle Configuration
-This Terraform module provisions an **AWS S3 bucket** with **lifecycle configuration** for automatic cleanup of incomplete multipart uploads.
+This Terraform module provisions an **AWS S3 bucket** with **flexible lifecycle configuration** supporting multiple rules for data management.
## Features
-- ✅ **Creates an S3 bucket** with lifecycle policies
+- ✅ **Creates an S3 bucket** with dynamic lifecycle policies
+- ✅ **Multiple lifecycle rules** support (expiration, transitions, cleanup)
- ✅ **Automatic cleanup** of incomplete multipart uploads
-- ✅ **Configurable retention** period for cleanup
+- ✅ **Configurable retention** periods for different object types
+- ✅ **Storage class transitions** for cost optimization
- ✅ **Versioning enabled** for data protection
- ✅ **Server-side encryption** with AES256
@@ -31,10 +33,28 @@ module "s3_bucket" {
force_destroy = true
# Lifecycle Configuration
- lifecycle_enabled = true
- lifecycle_rule_id = "cleanup-incomplete-uploads"
- lifecycle_rule_status = "Enabled"
- lifecycle_abort_incomplete_multipart_upload_days = 3
+ lifecycle_rules = [
+ {
+ id = "cleanup-incomplete-uploads"
+ status = "Enabled"
+ abort_incomplete_multipart_upload = {
+ days_after_initiation = 3
+ }
+ },
+ {
+ id = "expire-old-logs"
+ status = "Enabled"
+ filter = {
+ prefix = "logs/"
+ }
+ expiration = {
+ days = 90
+ }
+ noncurrent_version_expiration = {
+ noncurrent_days = 30
+ }
+ }
+ ]
# Additional Configuration
enable_versioning = true
diff --git a/examples/lifecycle/main.tf b/examples/lifecycle/main.tf
index d20d543..0982be1 100644
--- a/examples/lifecycle/main.tf
+++ b/examples/lifecycle/main.tf
@@ -26,11 +26,166 @@ module "s3_bucket" {
bucket_suffix = random_string.suffix.result
force_destroy = true # Allow deletion for demo purposes
- # Lifecycle Configuration
- lifecycle_enabled = true
- lifecycle_rule_id = "cleanup-incomplete-uploads"
- lifecycle_rule_status = "Enabled"
- lifecycle_abort_incomplete_multipart_upload_days = 3
+ # Lifecycle Configuration - Comprehensive test patterns
+ lifecycle_rules = [
+ # 1. Basic cleanup rule
+ {
+ id = "cleanup-incomplete-uploads"
+ status = "Enabled"
+ abort_incomplete_multipart_upload = {
+ days_after_initiation = 3
+ }
+ },
+
+ # 2. Log retention with prefix filter
+ {
+ id = "expire-old-logs"
+ status = "Enabled"
+ filter = {
+ prefix = "logs/"
+ }
+ expiration = {
+ days = 90
+ }
+ noncurrent_version_expiration = {
+ noncurrent_days = 30
+ }
+ },
+
+ # 3. Storage class transitions with tag filter
+ {
+ id = "transition-to-ia"
+ status = "Enabled"
+ filter = {
+ prefix = "data/"
+ tag = {
+ key = "Environment"
+ value = "production"
+ }
+ }
+ transitions = [
+ {
+ days = 30
+ storage_class = "STANDARD_IA"
+ },
+ {
+ days = 90
+ storage_class = "GLACIER"
+ }
+ ]
+ },
+
+ # 4. Date-based expiration
+ {
+ id = "expire-by-date"
+ status = "Enabled"
+ filter = {
+ prefix = "temp/"
+ }
+ expiration = {
+ date = "2024-12-31T00:00:00Z"
+ }
+ },
+
+ # 5. Tag-based filtering (single tag only)
+ {
+ id = "archive-sensitive-data"
+ status = "Enabled"
+ filter = {
+ tag = {
+ key = "Environment"
+ value = "production"
+ }
+ }
+ transitions = [
+ {
+ days = 30
+ storage_class = "STANDARD_IA"
+ },
+ {
+ days = 90
+ storage_class = "GLACIER"
+ },
+ {
+ days = 365
+ storage_class = "DEEP_ARCHIVE"
+ }
+ ]
+ },
+
+ # 6. Noncurrent version management only
+ {
+ id = "manage-versions"
+ status = "Enabled"
+ filter = {
+ prefix = "backups/"
+ }
+ noncurrent_version_expiration = {
+ noncurrent_days = 7
+ }
+ },
+
+ # 7. Mixed actions with both prefix and tag
+ {
+ id = "comprehensive-rule"
+ status = "Enabled"
+ filter = {
+ prefix = "uploads/"
+ tag = {
+ key = "Project"
+ value = "webapp"
+ }
+ }
+ expiration = {
+ days = 180
+ }
+ noncurrent_version_expiration = {
+ noncurrent_days = 14
+ }
+ transitions = [
+ {
+ days = 30
+ storage_class = "STANDARD_IA"
+ }
+ ]
+ },
+
+ # 8. Disabled rule (for testing)
+ {
+ id = "disabled-rule"
+ status = "Disabled"
+ filter = {
+ prefix = "test/"
+ }
+ expiration = {
+ days = 1
+ }
+ },
+
+ # 9. No filter (entire bucket)
+ {
+ id = "global-cleanup"
+ status = "Enabled"
+ abort_incomplete_multipart_upload = {
+ days_after_initiation = 1
+ }
+ },
+
+ # 10. Complex transition with date
+ {
+ id = "date-based-transition"
+ status = "Enabled"
+ filter = {
+ prefix = "archives/"
+ }
+ transitions = [
+ {
+ date = "2024-06-01T00:00:00Z"
+ storage_class = "GLACIER"
+ }
+ ]
+ }
+ ]
# Additional Configuration
enable_versioning = true
diff --git a/main.tf b/main.tf
index 95ef721..64ff120 100644
--- a/main.tf
+++ b/main.tf
@@ -61,38 +61,68 @@ resource "aws_s3_bucket_versioning" "this" {
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
- count = var.lifecycle_enabled ? 1 : 0
+ count = length(var.lifecycle_rules) > 0 ? 1 : 0
bucket = aws_s3_bucket.this.id
- rule {
- id = var.lifecycle_rule_id
- status = var.lifecycle_rule_status
-
- filter {
- prefix = ""
- }
+ dynamic "rule" {
+ for_each = var.lifecycle_rules
+ content {
+ id = rule.value.id
+ status = rule.value.status
+
+ # Filter block
+ dynamic "filter" {
+ for_each = lookup(rule.value, "filter", null) != null ? [lookup(rule.value, "filter", {})] : []
+ content {
+ prefix = lookup(filter.value, "prefix", null)
+
+ # AWS S3 lifecycle only supports one tag per filter
+ # If multiple tags are provided, use the first one
+ dynamic "tag" {
+ for_each = lookup(filter.value, "tag", null) != null ? [lookup(filter.value, "tag", {})] : []
+ content {
+ key = lookup(tag.value, "key", null)
+ value = lookup(tag.value, "value", null)
+ }
+ }
+ }
+ }
- abort_incomplete_multipart_upload {
- days_after_initiation = var.lifecycle_abort_incomplete_multipart_upload_days
- }
- }
+ # Expiration block
+ dynamic "expiration" {
+ for_each = lookup(rule.value, "expiration", null) != null ? [lookup(rule.value, "expiration", {})] : []
+ content {
+ days = lookup(expiration.value, "days", null)
+ date = lookup(expiration.value, "date", null)
+ }
+ }
- # Validation to ensure lifecycle rule is properly configured
- lifecycle {
- precondition {
- condition = var.lifecycle_enabled == false || (var.lifecycle_rule_status == "Enabled" || var.lifecycle_rule_status == "Disabled")
- error_message = "Lifecycle rule status must be 'Enabled' or 'Disabled' when lifecycle is enabled."
- }
+ # Noncurrent version expiration block
+ dynamic "noncurrent_version_expiration" {
+ for_each = lookup(rule.value, "noncurrent_version_expiration", null) != null ? [lookup(rule.value, "noncurrent_version_expiration", {})] : []
+ content {
+ noncurrent_days = lookup(noncurrent_version_expiration.value, "noncurrent_days", null)
+ }
+ }
- precondition {
- condition = var.lifecycle_enabled == false || var.lifecycle_abort_incomplete_multipart_upload_days > 0
- error_message = "Lifecycle abort incomplete multipart upload days must be greater than 0 when lifecycle is enabled."
- }
+ # Abort incomplete multipart upload block
+ dynamic "abort_incomplete_multipart_upload" {
+ for_each = lookup(rule.value, "abort_incomplete_multipart_upload", null) != null ? [lookup(rule.value, "abort_incomplete_multipart_upload", {})] : []
+ content {
+ days_after_initiation = lookup(abort_incomplete_multipart_upload.value, "days_after_initiation", null)
+ }
+ }
- precondition {
- condition = var.lifecycle_enabled == false || (length(var.lifecycle_rule_id) > 0 && length(var.lifecycle_rule_id) <= 255)
- error_message = "Lifecycle rule ID must be between 1 and 255 characters when lifecycle is enabled."
+ # Transitions block
+ dynamic "transition" {
+ for_each = lookup(rule.value, "transitions", null) != null ? lookup(rule.value, "transitions", []) : []
+ content {
+ days = lookup(transition.value, "days", null)
+ date = lookup(transition.value, "date", null)
+ storage_class = lookup(transition.value, "storage_class", null)
+ }
+ }
}
}
}
diff --git a/outputs.tf b/outputs.tf
index 9561ad1..eca3407 100644
--- a/outputs.tf
+++ b/outputs.tf
@@ -39,7 +39,7 @@ output "bucket_id" {
output "bucket_lifecycle_configuration" {
description = "The bucket's lifecycle configuration"
- value = var.lifecycle_enabled ? aws_s3_bucket_lifecycle_configuration.this[0].rule[0] : null
+ value = length(var.lifecycle_rules) > 0 ? aws_s3_bucket_lifecycle_configuration.this[0].rule : null
}
output "bucket_logging_target" {
diff --git a/tests/s3_bucket.tftest.hcl b/tests/s3_bucket.tftest.hcl
index aa906ec..b9c90dc 100644
--- a/tests/s3_bucket.tftest.hcl
+++ b/tests/s3_bucket.tftest.hcl
@@ -32,7 +32,18 @@ run "test_s3_bucket" {
enable_versioning = true
# Lifecycle Configuration
- lifecycle_enabled = true
+ lifecycle_rules = [
+ {
+ id = "cleanup-incomplete-uploads"
+ status = "Enabled"
+ filter = {
+ prefix = ""
+ }
+ abort_incomplete_multipart_upload = {
+ days_after_initiation = 1
+ }
+ }
+ ]
# Logging Configuration
logging_enabled = true
@@ -112,8 +123,8 @@ run "test_s3_bucket" {
}
assert {
- condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].status == "Disabled"
- error_message = "Main bucket lifecycle rule status is not 'Disabled'."
+ condition = aws_s3_bucket_lifecycle_configuration.this[0].rule[0].status == "Enabled"
+ error_message = "Main bucket lifecycle rule status is not 'Enabled'."
}
assert {
diff --git a/variables.tf b/variables.tf
index 90692b7..ddfb9cd 100644
--- a/variables.tf
+++ b/variables.tf
@@ -166,42 +166,33 @@ variable "logging_s3_prefix" {
# LIFECYCLE CONFIGURATION
############################################
-variable "lifecycle_enabled" {
- description = "Enable lifecycle configuration for the S3 bucket"
- type = bool
- default = false
-}
-
-variable "lifecycle_rule_id" {
- description = "ID for the lifecycle rule. Must be unique within the bucket."
- type = string
- default = "cleanup-incomplete-uploads"
+variable "lifecycle_rules" {
+ description = "List of lifecycle rules for the S3 bucket. Each rule is a map that will be passed directly to the aws_s3_bucket_lifecycle_configuration resource."
+ type = any
+ default = []
validation {
- condition = length(var.lifecycle_rule_id) > 0 && length(var.lifecycle_rule_id) <= 255
- error_message = "Lifecycle rule ID must be between 1 and 255 characters."
+ condition = alltrue([
+ for rule in var.lifecycle_rules :
+ contains(keys(rule), "id") && contains(keys(rule), "status")
+ ])
+ error_message = "Each lifecycle rule must have 'id' and 'status' keys."
}
-}
-
-variable "lifecycle_rule_status" {
- description = "Status of the lifecycle rule. Set to 'Disabled' to temporarily disable the rule without deleting it."
- type = string
- default = "Disabled"
validation {
- condition = contains(["Enabled", "Disabled"], var.lifecycle_rule_status)
- error_message = "Lifecycle rule status must be 'Enabled' or 'Disabled'."
+ condition = alltrue([
+ for rule in var.lifecycle_rules :
+ length(rule.id) > 0 && length(rule.id) <= 255
+ ])
+ error_message = "Each lifecycle rule ID must be between 1 and 255 characters."
}
-}
-
-variable "lifecycle_abort_incomplete_multipart_upload_days" {
- description = "Number of days after which incomplete multipart uploads are aborted. This helps reduce storage costs by cleaning up failed uploads."
- type = number
- default = 1
validation {
- condition = var.lifecycle_abort_incomplete_multipart_upload_days > 0
- error_message = "The abort incomplete multipart upload days must be greater than 0."
+ condition = alltrue([
+ for rule in var.lifecycle_rules :
+ contains(["Enabled", "Disabled"], rule.status)
+ ])
+ error_message = "Each lifecycle rule status must be 'Enabled' or 'Disabled'."
}
}