diff --git a/moto/s3/models.py b/moto/s3/models.py
index 2d9f56a06ecd..31897892743f 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -637,6 +637,47 @@ def to_config_dict(self) -> List[Dict[str, Any]]:
return data
+class LifecycleTransition(BaseModel):
+ def __init__(
+ self,
+ date: Optional[str] = None,
+ days: Optional[int] = None,
+ storage_class: Optional[str] = None,
+ ):
+ self.date = date
+ self.days = days
+ self.storage_class = storage_class
+
+ def to_config_dict(self) -> Dict[str, Any]:
+ config: Dict[str, Any] = {}
+ if self.date is not None:
+ config["date"] = self.date
+ if self.days is not None:
+ config["days"] = self.days
+ if self.storage_class is not None:
+ config["storageClass"] = self.storage_class
+ return config
+
+
+class LifeCycleNoncurrentVersionTransition(BaseModel):
+ def __init__(
+ self, days: int, storage_class: str, newer_versions: Optional[int] = None
+ ):
+ self.newer_versions = newer_versions
+ self.days = days
+ self.storage_class = storage_class
+
+ def to_config_dict(self) -> Dict[str, Any]:
+ config: Dict[str, Any] = {}
+ if self.newer_versions is not None:
+ config["newerNoncurrentVersions"] = self.newer_versions
+ if self.days is not None:
+ config["noncurrentDays"] = self.days
+ if self.storage_class is not None:
+ config["storageClass"] = self.storage_class
+ return config
+
+
class LifecycleRule(BaseModel):
def __init__(
self,
@@ -646,13 +687,12 @@ def __init__(
status: Optional[str] = None,
expiration_days: Optional[str] = None,
expiration_date: Optional[str] = None,
- transition_days: Optional[str] = None,
- transition_date: Optional[str] = None,
- storage_class: Optional[str] = None,
+ transitions: Optional[List[LifecycleTransition]] = None,
expired_object_delete_marker: Optional[str] = None,
nve_noncurrent_days: Optional[str] = None,
- nvt_noncurrent_days: Optional[str] = None,
- nvt_storage_class: Optional[str] = None,
+ noncurrent_version_transitions: Optional[
+ List[LifeCycleNoncurrentVersionTransition]
+ ] = None,
aimu_days: Optional[str] = None,
):
self.id = rule_id
@@ -661,22 +701,15 @@ def __init__(
self.status = status
self.expiration_days = expiration_days
self.expiration_date = expiration_date
- self.transition_days = transition_days
- self.transition_date = transition_date
- self.storage_class = storage_class
+ self.transitions = transitions
self.expired_object_delete_marker = expired_object_delete_marker
self.nve_noncurrent_days = nve_noncurrent_days
- self.nvt_noncurrent_days = nvt_noncurrent_days
- self.nvt_storage_class = nvt_storage_class
+ self.noncurrent_version_transitions = noncurrent_version_transitions
self.aimu_days = aimu_days
def to_config_dict(self) -> Dict[str, Any]:
"""Converts the object to the AWS Config data dict.
- Note: The following are missing that should be added in the future:
- - transitions (returns None for now)
- - noncurrentVersionTransitions (returns None for now)
-
:param kwargs:
:return:
"""
@@ -691,10 +724,22 @@ def to_config_dict(self) -> Dict[str, Any]:
"expiredObjectDeleteMarker": self.expired_object_delete_marker,
"noncurrentVersionExpirationInDays": -1 or int(self.nve_noncurrent_days), # type: ignore
"expirationDate": self.expiration_date,
- "transitions": None, # Replace me with logic to fill in
- "noncurrentVersionTransitions": None, # Replace me with logic to fill in
}
+ if self.transitions:
+ lifecycle_dict["transitions"] = [
+ t.to_config_dict() for t in self.transitions
+ ]
+ else:
+ lifecycle_dict["transitions"] = None
+
+ if self.noncurrent_version_transitions:
+ lifecycle_dict["noncurrentVersionTransitions"] = [
+ t.to_config_dict() for t in self.noncurrent_version_transitions
+ ]
+ else:
+ lifecycle_dict["noncurrentVersionTransitions"] = None
+
if self.aimu_days:
lifecycle_dict["abortIncompleteMultipartUpload"] = {
"daysAfterInitiation": self.aimu_days
@@ -965,7 +1010,19 @@ def set_lifecycle(self, rules: List[Dict[str, Any]]) -> None:
for rule in rules:
# Extract and validate actions from Lifecycle rule
expiration = rule.get("Expiration")
- transition = rule.get("Transition")
+
+ transitions_input = rule.get("Transition", [])
+ if transitions_input and not isinstance(transitions_input, list):
+ transitions_input = [rule.get("Transition")]
+
+ transitions = [
+ LifecycleTransition(
+ date=transition.get("Date"),
+ days=transition.get("Days"),
+ storage_class=transition.get("StorageClass"),
+ )
+ for transition in transitions_input
+ ]
try:
top_level_prefix = (
@@ -982,17 +1039,21 @@ def set_lifecycle(self, rules: List[Dict[str, Any]]) -> None:
"NoncurrentDays"
]
- nvt_noncurrent_days = None
- nvt_storage_class = None
- if rule.get("NoncurrentVersionTransition") is not None:
- if rule["NoncurrentVersionTransition"].get("NoncurrentDays") is None:
- raise MalformedXML()
- if rule["NoncurrentVersionTransition"].get("StorageClass") is None:
+ nv_transitions_input = rule.get("NoncurrentVersionTransition", [])
+ if nv_transitions_input and not isinstance(nv_transitions_input, list):
+ nv_transitions_input = [rule.get("NoncurrentVersionTransition")]
+
+ noncurrent_version_transitions = []
+ for nvt in nv_transitions_input:
+ if nvt.get("NoncurrentDays") is None or nvt.get("StorageClass") is None:
raise MalformedXML()
- nvt_noncurrent_days = rule["NoncurrentVersionTransition"][
- "NoncurrentDays"
- ]
- nvt_storage_class = rule["NoncurrentVersionTransition"]["StorageClass"]
+
+ transition = LifeCycleNoncurrentVersionTransition(
+ newer_versions=nvt.get("NewerNoncurrentVersions"),
+ days=nvt.get("NoncurrentDays"),
+ storage_class=nvt.get("StorageClass"),
+ )
+ noncurrent_version_transitions.append(transition)
aimu_days = None
if rule.get("AbortIncompleteMultipartUpload") is not None:
@@ -1085,15 +1146,10 @@ def set_lifecycle(self, rules: List[Dict[str, Any]]) -> None:
status=rule["Status"],
expiration_days=expiration.get("Days") if expiration else None,
expiration_date=expiration.get("Date") if expiration else None,
- transition_days=transition.get("Days") if transition else None,
- transition_date=transition.get("Date") if transition else None,
- storage_class=transition.get("StorageClass")
- if transition
- else None,
+ transitions=transitions,
expired_object_delete_marker=eodm,
nve_noncurrent_days=nve_noncurrent_days,
- nvt_noncurrent_days=nvt_noncurrent_days,
- nvt_storage_class=nvt_storage_class,
+ noncurrent_version_transitions=noncurrent_version_transitions,
aimu_days=aimu_days,
)
)
@@ -2317,7 +2373,6 @@ def delete_object(
for key in bucket.keys.getlist(key_name):
if str(key.version_id) == str(version_id):
-
if (
hasattr(key, "is_locked")
and key.is_locked
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 3d8a5a5ecf96..d9dc5ad77708 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -2465,17 +2465,19 @@ def _invalid_headers(self, url: str, headers: Dict[str, str]) -> bool:
{% endif %}
{% endif %}
{{ rule.status }}
- {% if rule.storage_class %}
-
- {% if rule.transition_days %}
- {{ rule.transition_days }}
- {% endif %}
- {% if rule.transition_date %}
- {{ rule.transition_date }}
- {% endif %}
- {{ rule.storage_class }}
-
- {% endif %}
+ {% for transition in rule.transitions %}
+
+ {% if transition.days %}
+ {{ transition.days }}
+ {% endif %}
+ {% if transition.date %}
+ {{ transition.date }}
+ {% endif %}
+ {% if transition.storage_class %}
+ {{ transition.storage_class }}
+ {% endif %}
+
+ {% endfor %}
{% if rule.expiration_days or rule.expiration_date or rule.expired_object_delete_marker %}
{% if rule.expiration_days %}
@@ -2489,12 +2491,19 @@ def _invalid_headers(self, url: str, headers: Dict[str, str]) -> bool:
{% endif %}
{% endif %}
- {% if rule.nvt_noncurrent_days and rule.nvt_storage_class %}
-
- {{ rule.nvt_noncurrent_days }}
- {{ rule.nvt_storage_class }}
-
- {% endif %}
+ {% for nvt in rule.noncurrent_version_transitions %}
+
+ {% if nvt.newer_versions %}
+ {{ nvt.newer_versions }}
+ {% endif %}
+ {% if nvt.days %}
+ {{ nvt.days }}
+ {% endif %}
+ {% if nvt.storage_class %}
+ {{ nvt.storage_class }}
+ {% endif %}
+
+ {% endfor %}
{% if rule.nve_noncurrent_days %}
{{ rule.nve_noncurrent_days }}
diff --git a/tests/test_s3/test_s3_config.py b/tests/test_s3/test_s3_config.py
index 50e9ab71ceef..d38a3eb31bc5 100644
--- a/tests/test_s3/test_s3_config.py
+++ b/tests/test_s3/test_s3_config.py
@@ -12,7 +12,6 @@
@mock_s3
def test_s3_public_access_block_to_config_dict():
-
# With 1 bucket in us-west-2:
s3_config_query_backend.create_bucket("bucket1", "us-west-2")
@@ -48,7 +47,6 @@ def test_s3_public_access_block_to_config_dict():
@mock_s3
def test_list_config_discovered_resources():
-
# Without any buckets:
assert s3_config_query.list_config_service_resources(
"global", "global", None, None, 100, None
@@ -173,6 +171,18 @@ def test_s3_lifecycle_config_dict():
"Filter": {"Prefix": ""},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 1},
},
+ {
+ "ID": "rule5",
+ "Status": "Enabled",
+ "Filter": {"Prefix": ""},
+ "Transition": [{"Days": 10, "StorageClass": "GLACIER"}],
+ "NoncurrentVersionTransition": [
+ {
+ "NoncurrentDays": 10,
+ "StorageClass": "GLACIER",
+ }
+ ],
+ },
]
s3_config_query_backend.put_bucket_lifecycle("bucket1", lifecycle)
@@ -253,6 +263,22 @@ def test_s3_lifecycle_config_dict():
"filter": {"predicate": {"type": "LifecyclePrefixPredicate", "prefix": ""}},
}
+ assert lifecycles[4] == {
+ "id": "rule5",
+ "prefix": None,
+ "status": "Enabled",
+ "expirationInDays": None,
+ "expiredObjectDeleteMarker": None,
+ "noncurrentVersionExpirationInDays": -1,
+ "expirationDate": None,
+ "abortIncompleteMultipartUpload": None,
+ "filter": {"predicate": {"type": "LifecyclePrefixPredicate", "prefix": ""}},
+ "transitions": [{"days": 10, "storageClass": "GLACIER"}],
+ "noncurrentVersionTransitions": [
+ {"noncurrentDays": 10, "storageClass": "GLACIER"}
+ ],
+ }
+
@mock_s3
def test_s3_notification_config_dict():
diff --git a/tests/test_s3/test_s3_lifecycle.py b/tests/test_s3/test_s3_lifecycle.py
index 76df2b405747..15dee4a390b1 100644
--- a/tests/test_s3/test_s3_lifecycle.py
+++ b/tests/test_s3/test_s3_lifecycle.py
@@ -376,6 +376,76 @@ def test_lifecycle_with_nvt():
assert err.value.response["Error"]["Code"] == "MalformedXML"
+@mock_s3
+def test_lifecycle_with_multiple_nvt():
+ client = boto3.client("s3")
+ client.create_bucket(
+ Bucket="bucket", CreateBucketConfiguration={"LocationConstraint": "us-west-1"}
+ )
+
+ lfc = {
+ "Rules": [
+ {
+ "NoncurrentVersionTransitions": [
+ {"NoncurrentDays": 30, "StorageClass": "ONEZONE_IA"},
+ {"NoncurrentDays": 50, "StorageClass": "GLACIER"},
+ ],
+ "ID": "wholebucket",
+ "Filter": {"Prefix": ""},
+ "Status": "Enabled",
+ }
+ ]
+ }
+ client.put_bucket_lifecycle_configuration(
+ Bucket="bucket", LifecycleConfiguration=lfc
+ )
+ result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
+ assert len(result["Rules"]) == 1
+ assert result["Rules"][0]["NoncurrentVersionTransitions"][0] == {
+ "NoncurrentDays": 30,
+ "StorageClass": "ONEZONE_IA",
+ }
+ assert result["Rules"][0]["NoncurrentVersionTransitions"][1] == {
+ "NoncurrentDays": 50,
+ "StorageClass": "GLACIER",
+ }
+
+
+@mock_s3
+def test_lifecycle_with_multiple_transitions():
+ client = boto3.client("s3")
+ client.create_bucket(
+ Bucket="bucket", CreateBucketConfiguration={"LocationConstraint": "us-west-1"}
+ )
+
+ lfc = {
+ "Rules": [
+ {
+ "Transitions": [
+ {"Days": 30, "StorageClass": "ONEZONE_IA"},
+ {"Days": 50, "StorageClass": "GLACIER"},
+ ],
+ "ID": "wholebucket",
+ "Filter": {"Prefix": ""},
+ "Status": "Enabled",
+ }
+ ]
+ }
+ client.put_bucket_lifecycle_configuration(
+ Bucket="bucket", LifecycleConfiguration=lfc
+ )
+ result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
+ assert len(result["Rules"]) == 1
+ assert result["Rules"][0]["Transitions"][0] == {
+ "Days": 30,
+ "StorageClass": "ONEZONE_IA",
+ }
+ assert result["Rules"][0]["Transitions"][1] == {
+ "Days": 50,
+ "StorageClass": "GLACIER",
+ }
+
+
@mock_s3
def test_lifecycle_with_aimu():
client = boto3.client("s3")