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'." } }