diff --git a/src/garageS3.jl b/src/garageS3.jl
index 9399977..ca264b6 100644
--- a/src/garageS3.jl
+++ b/src/garageS3.jl
@@ -1,6 +1,7 @@
module garageS3
export
+ LifecycleExpiration, build_lifecycle_xml,
GarageStorage, put_file, get_file, list_files, delete_file,
set_lifecycle_expiration, get_lifecycle, delete_lifecycle
@@ -266,11 +267,110 @@ function delete_file(storage::GarageStorage, key::String)
end
+# ===================================================================
+# 3. Bucket Lifecycle Configuration
+# ===================================================================
+""" Lifecycle expiration configuration for a bucket.
+# Fields
+- `prefix::String`: Object key prefix this rule applies to.
+- `days::Int`: Number of days after object creation to expire/delete.
+- `enabled::Bool`: Whether the rule is active (default: `true`).
+- `id::String`: Unique identifier for the rule (default: auto-generated).
+"""
+struct LifecycleExpiration
+ prefix::String
+ days::Int
+ enabled::Bool
+ id::String
+end
+function LifecycleExpiration(prefix::String, days::Int; enabled::Bool=true, id::String="")
+ rule_id = isempty(id) ? "expire-" * replace(prefix, "/" => "_") * "-" * string(days) * "d" : id
+ return LifecycleExpiration(prefix, days, enabled, rule_id)
+end
+""" Build the XML body for a PutBucketLifecycleConfiguration request.
+Supports a single rule with expiration by prefix.
+"""
+function build_lifecycle_xml(rule::LifecycleExpiration)::String
+ status = rule.enabled ? "Enabled" : "Disabled"
+ return """
+
+
+ $(rule.id)
+
+ $(rule.prefix)
+
+ $status
+
+ $(rule.days)
+
+
+"""
+end
+""" Set bucket lifecycle expiration rule.
+# Arguments
+- `storage::GarageStorage`: The storage client instance.
+- `rule::LifecycleExpiration`: The lifecycle expiration rule.
+
+# Returns
+- `Dict{String, Any}`: Parsed response from the server.
+
+# Example
+```julia
+rule = LifecycleExpiration("uploads/", 30)
+set_lifecycle_expiration(storage, rule)
+```
+"""
+function set_lifecycle_expiration(storage::GarageStorage, rule::LifecycleExpiration)
+ xml_body = build_lifecycle_xml(rule)
+ args = Dict{String,Any}("body" => xml_body)
+ return S3.put_bucket_lifecycle_configuration(storage.bucket, args; aws_config=storage.config)
+end
+
+""" Get the bucket lifecycle configuration.
+
+# Arguments
+- `storage::GarageStorage`: The storage client instance.
+
+# Returns
+- `Dict{String, Any}`: Parsed lifecycle configuration, or `nothing` if no lifecycle is configured.
+
+# Example
+```julia
+config = get_lifecycle(storage)
+```
+"""
+function get_lifecycle(storage::GarageStorage)::Union{Dict{String,Any},Nothing}
+ try
+ return S3.get_bucket_lifecycle_configuration(storage.bucket; aws_config=storage.config)
+ catch e
+ if isa(e, AWS.AWSException) && e.code == "NoSuchLifecycleConfiguration"
+ return nothing
+ end
+ rethrow(e)
+ end
+end
+
+""" Delete the bucket lifecycle configuration.
+
+# Arguments
+- `storage::GarageStorage`: The storage client instance.
+
+# Returns
+- `Dict{String, Any}`: Parsed response from the server.
+
+# Example
+```julia
+delete_lifecycle(storage)
+```
+"""
+function delete_lifecycle(storage::GarageStorage)
+ return S3.delete_bucket_lifecycle(storage.bucket; aws_config=storage.config)
+end
end # module GarageS3
diff --git a/test/runtests.jl b/test/runtests.jl
index ea29e64..8ef2cf7 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -343,4 +343,121 @@ using UUIDs: uuid4
delete_file(storage, test_key)
end
+ # --- Lifecycle configuration tests ---
+
+ @testset "LifecycleExpiration construction with default ID" begin
+ rule = LifecycleExpiration("uploads/", 30)
+ @test rule.prefix == "uploads/"
+ @test rule.days == 30
+ @test rule.enabled == true
+ @test rule.id == "expire-uploads_-30d"
+ end
+
+ @testset "LifecycleExpiration construction with custom ID" begin
+ rule = LifecycleExpiration("data/", 7; id="my-rule")
+ @test rule.prefix == "data/"
+ @test rule.days == 7
+ @test rule.enabled == true
+ @test rule.id == "my-rule"
+ end
+
+ @testset "LifecycleExpiration with disabled flag" begin
+ rule = LifecycleExpiration("temp/", 1; enabled=false)
+ @test rule.enabled == false
+ end
+
+ @testset "LifecycleExpiration with nested prefix" begin
+ rule = LifecycleExpiration("deep/nested/", 90)
+ @test rule.prefix == "deep/nested/"
+ end
+
+ @testset "build_lifecycle_xml generates valid XML" begin
+ rule = LifecycleExpiration("test/", 14; id="test-rule")
+ xml = build_lifecycle_xml(rule)
+ @test occursin("", xml)
+ @test occursin("test-rule", xml)
+ @test occursin("test/", xml)
+ @test occursin("Enabled", xml)
+ @test occursin("14", xml)
+ @test occursin("", xml)
+ end
+
+ @testset "build_lifecycle_xml generates disabled rule XML" begin
+ rule = LifecycleExpiration("temp/", 1; enabled=false)
+ xml = build_lifecycle_xml(rule)
+ @test occursin("Disabled", xml)
+ end
+
+ @testset "integration: lifecycle CRUD - set and get" begin
+ storage = GarageStorage(
+ "https://s3-api.yiem.cc",
+ "GKb080154a2e5b19100b1b2c6e",
+ "a2c6b1379c2f731d3e6e5a408dd4d6cffca7511717675f55114ff94828febca1",
+ "testbucket"
+ )
+
+ # Should return nothing when no lifecycle is set
+ lc = get_lifecycle(storage)
+ @test lc === nothing
+
+ # Set lifecycle
+ rule = LifecycleExpiration("lifecycle-test/", 45)
+ set_lifecycle_expiration(storage, rule)
+
+ # Should return configuration after setting
+ lc = get_lifecycle(storage)
+ @test lc !== nothing
+ @test haskey(lc, "Rule")
+
+ # Delete lifecycle
+ delete_lifecycle(storage)
+
+ # Should return nothing after deletion
+ lc = get_lifecycle(storage)
+ @test lc === nothing
+ end
+
+ @testset "integration: lifecycle with nested prefix" begin
+ storage = GarageStorage(
+ "https://s3-api.yiem.cc",
+ "GKb080154a2e5b19100b1b2c6e",
+ "a2c6b1379c2f731d3e6e5a408dd4d6cffca7511717675f55114ff94828febca1",
+ "testbucket"
+ )
+
+ rule = LifecycleExpiration("a/b/c/", 20; id="nested-rule")
+ set_lifecycle_expiration(storage, rule)
+
+ lc = get_lifecycle(storage)
+ @test lc !== nothing
+ @test haskey(lc, "Rule")
+
+ delete_lifecycle(storage)
+ @test get_lifecycle(storage) === nothing
+ end
+
+ @testset "integration: lifecycle enabled/disabled toggle" begin
+ storage = GarageStorage(
+ "https://s3-api.yiem.cc",
+ "GKb080154a2e5b19100b1b2c6e",
+ "a2c6b1379c2f731d3e6e5a408dd4d6cffca7511717675f55114ff94828febca1",
+ "testbucket"
+ )
+
+ # Set enabled rule
+ rule1 = LifecycleExpiration("toggle/", 10; enabled=true)
+ set_lifecycle_expiration(storage, rule1)
+ lc = get_lifecycle(storage)
+ @test lc !== nothing
+
+ # Override with disabled rule
+ rule2 = LifecycleExpiration("toggle/", 10; enabled=false)
+ set_lifecycle_expiration(storage, rule2)
+ lc = get_lifecycle(storage)
+ @test lc !== nothing
+
+ delete_lifecycle(storage)
+ end
+
end