Skip to content

Commit 4184acc

Browse files
mikegrimaJackDanger
authored andcommitted
Added Filtering support for S3 lifecycle (#1535)
* Added Filtering support for S3 lifecycle Also added `ExpiredObjectDeleteMarker`. closes #1533 closes #1479 * Result set no longer contains "Prefix" if "Filter" is set.
1 parent 0a4d203 commit 4184acc

File tree

4 files changed

+253
-7
lines changed

4 files changed

+253
-7
lines changed

moto/s3/models.py

+58-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from moto.core import BaseBackend, BaseModel
1616
from moto.core.utils import iso_8601_datetime_with_milliseconds, rfc_1123_datetime
1717
from .exceptions import BucketAlreadyExists, MissingBucket, InvalidPart, EntityTooSmall, MissingKey, \
18-
InvalidNotificationDestination
18+
InvalidNotificationDestination, MalformedXML
1919
from .utils import clean_key_name, _VersionedKeyStore
2020

2121
UPLOAD_ID_BYTES = 43
@@ -311,18 +311,35 @@ def __init__(self, key, value=None):
311311
self.value = value
312312

313313

314+
class LifecycleFilter(BaseModel):
315+
316+
def __init__(self, prefix=None, tag=None, and_filter=None):
317+
self.prefix = prefix or ''
318+
self.tag = tag
319+
self.and_filter = and_filter
320+
321+
322+
class LifecycleAndFilter(BaseModel):
323+
324+
def __init__(self, prefix=None, tags=None):
325+
self.prefix = prefix or ''
326+
self.tags = tags
327+
328+
314329
class LifecycleRule(BaseModel):
315330

316-
def __init__(self, id=None, prefix=None, status=None, expiration_days=None,
317-
expiration_date=None, transition_days=None,
331+
def __init__(self, id=None, prefix=None, lc_filter=None, status=None, expiration_days=None,
332+
expiration_date=None, transition_days=None, expired_object_delete_marker=None,
318333
transition_date=None, storage_class=None):
319334
self.id = id
320335
self.prefix = prefix
336+
self.filter = lc_filter
321337
self.status = status
322338
self.expiration_days = expiration_days
323339
self.expiration_date = expiration_date
324340
self.transition_days = transition_days
325341
self.transition_date = transition_date
342+
self.expired_object_delete_marker = expired_object_delete_marker
326343
self.storage_class = storage_class
327344

328345

@@ -387,12 +404,50 @@ def set_lifecycle(self, rules):
387404
for rule in rules:
388405
expiration = rule.get('Expiration')
389406
transition = rule.get('Transition')
407+
408+
eodm = None
409+
if expiration and expiration.get("ExpiredObjectDeleteMarker") is not None:
410+
# This cannot be set if Date or Days is set:
411+
if expiration.get("Days") or expiration.get("Date"):
412+
raise MalformedXML()
413+
eodm = expiration["ExpiredObjectDeleteMarker"]
414+
415+
# Pull out the filter:
416+
lc_filter = None
417+
if rule.get("Filter"):
418+
# Can't have both `Filter` and `Prefix` (need to check for the presence of the key):
419+
try:
420+
if rule["Prefix"] or not rule["Prefix"]:
421+
raise MalformedXML()
422+
except KeyError:
423+
pass
424+
425+
and_filter = None
426+
if rule["Filter"].get("And"):
427+
and_tags = []
428+
if rule["Filter"]["And"].get("Tag"):
429+
if not isinstance(rule["Filter"]["And"]["Tag"], list):
430+
rule["Filter"]["And"]["Tag"] = [rule["Filter"]["And"]["Tag"]]
431+
432+
for t in rule["Filter"]["And"]["Tag"]:
433+
and_tags.append(FakeTag(t["Key"], t.get("Value", '')))
434+
435+
and_filter = LifecycleAndFilter(prefix=rule["Filter"]["And"]["Prefix"], tags=and_tags)
436+
437+
filter_tag = None
438+
if rule["Filter"].get("Tag"):
439+
filter_tag = FakeTag(rule["Filter"]["Tag"]["Key"], rule["Filter"]["Tag"].get("Value", ''))
440+
441+
lc_filter = LifecycleFilter(prefix=rule["Filter"]["Prefix"], tag=filter_tag, and_filter=and_filter)
442+
390443
self.rules.append(LifecycleRule(
391444
id=rule.get('ID'),
392445
prefix=rule.get('Prefix'),
446+
lc_filter=lc_filter,
393447
status=rule['Status'],
394448
expiration_days=expiration.get('Days') if expiration else None,
395449
expiration_date=expiration.get('Date') if expiration else None,
450+
expired_object_delete_marker=eodm,
396451
transition_days=transition.get('Days') if transition else None,
397452
transition_date=transition.get('Date') if transition else None,
398453
storage_class=transition[

moto/s3/responses.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -1176,7 +1176,30 @@ def _key_response_post(self, request, body, bucket_name, query, key_name, header
11761176
{% for rule in rules %}
11771177
<Rule>
11781178
<ID>{{ rule.id }}</ID>
1179+
{% if rule.filter %}
1180+
<Filter>
1181+
<Prefix>{{ rule.filter.prefix }}</Prefix>
1182+
{% if rule.filter.tag %}
1183+
<Tag>
1184+
<Key>{{ rule.filter.tag.key }}</Key>
1185+
<Value>{{ rule.filter.tag.value }}</Value>
1186+
</Tag>
1187+
{% endif %}
1188+
{% if rule.filter.and_filter %}
1189+
<And>
1190+
<Prefix>{{ rule.filter.and_filter.prefix }}</Prefix>
1191+
{% for tag in rule.filter.and_filter.tags %}
1192+
<Tag>
1193+
<Key>{{ tag.key }}</Key>
1194+
<Value>{{ tag.value }}</Value>
1195+
</Tag>
1196+
{% endfor %}
1197+
</And>
1198+
{% endif %}
1199+
</Filter>
1200+
{% else %}
11791201
<Prefix>{{ rule.prefix if rule.prefix != None }}</Prefix>
1202+
{% endif %}
11801203
<Status>{{ rule.status }}</Status>
11811204
{% if rule.storage_class %}
11821205
<Transition>
@@ -1189,14 +1212,17 @@ def _key_response_post(self, request, body, bucket_name, query, key_name, header
11891212
<StorageClass>{{ rule.storage_class }}</StorageClass>
11901213
</Transition>
11911214
{% endif %}
1192-
{% if rule.expiration_days or rule.expiration_date %}
1215+
{% if rule.expiration_days or rule.expiration_date or rule.expired_object_delete_marker %}
11931216
<Expiration>
11941217
{% if rule.expiration_days %}
11951218
<Days>{{ rule.expiration_days }}</Days>
11961219
{% endif %}
11971220
{% if rule.expiration_date %}
11981221
<Date>{{ rule.expiration_date }}</Date>
11991222
{% endif %}
1223+
{% if rule.expired_object_delete_marker %}
1224+
<ExpiredObjectDeleteMarker>{{ rule.expired_object_delete_marker }}</ExpiredObjectDeleteMarker>
1225+
{% endif %}
12001226
</Expiration>
12011227
{% endif %}
12021228
</Rule>

setup.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
install_requires = [
99
"Jinja2>=2.7.3",
1010
"boto>=2.36.0",
11-
"boto3>=1.2.1",
12-
"botocore>=1.7.12",
11+
"boto3>=1.6.16",
12+
"botocore>=1.9.16",
1313
"cookies",
1414
"cryptography>=2.0.0",
1515
"requests>=2.5",

tests/test_s3/test_s3_lifecycle.py

+166-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from __future__ import unicode_literals
22

33
import boto
4+
import boto3
45
from boto.exception import S3ResponseError
56
from boto.s3.lifecycle import Lifecycle, Transition, Expiration, Rule
67

78
import sure # noqa
9+
from botocore.exceptions import ClientError
10+
from datetime import datetime
11+
from nose.tools import assert_raises
812

9-
from moto import mock_s3_deprecated
13+
from moto import mock_s3_deprecated, mock_s3
1014

1115

1216
@mock_s3_deprecated
@@ -26,6 +30,167 @@ def test_lifecycle_create():
2630
list(lifecycle.transition).should.equal([])
2731

2832

33+
@mock_s3
34+
def test_lifecycle_with_filters():
35+
client = boto3.client("s3")
36+
client.create_bucket(Bucket="bucket")
37+
38+
# Create a lifecycle rule with a Filter (no tags):
39+
lfc = {
40+
"Rules": [
41+
{
42+
"Expiration": {
43+
"Days": 7
44+
},
45+
"ID": "wholebucket",
46+
"Filter": {
47+
"Prefix": ""
48+
},
49+
"Status": "Enabled"
50+
}
51+
]
52+
}
53+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
54+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
55+
assert len(result["Rules"]) == 1
56+
assert result["Rules"][0]["Filter"]["Prefix"] == ''
57+
assert not result["Rules"][0]["Filter"].get("And")
58+
assert not result["Rules"][0]["Filter"].get("Tag")
59+
with assert_raises(KeyError):
60+
assert result["Rules"][0]["Prefix"]
61+
62+
# With a tag:
63+
lfc["Rules"][0]["Filter"]["Tag"] = {
64+
"Key": "mytag",
65+
"Value": "mytagvalue"
66+
}
67+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
68+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
69+
assert len(result["Rules"]) == 1
70+
assert result["Rules"][0]["Filter"]["Prefix"] == ''
71+
assert not result["Rules"][0]["Filter"].get("And")
72+
assert result["Rules"][0]["Filter"]["Tag"]["Key"] == "mytag"
73+
assert result["Rules"][0]["Filter"]["Tag"]["Value"] == "mytagvalue"
74+
with assert_raises(KeyError):
75+
assert result["Rules"][0]["Prefix"]
76+
77+
# With And (single tag):
78+
lfc["Rules"][0]["Filter"]["And"] = {
79+
"Prefix": "some/prefix",
80+
"Tags": [
81+
{
82+
"Key": "mytag",
83+
"Value": "mytagvalue"
84+
}
85+
]
86+
}
87+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
88+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
89+
assert len(result["Rules"]) == 1
90+
assert result["Rules"][0]["Filter"]["Prefix"] == ""
91+
assert result["Rules"][0]["Filter"]["And"]["Prefix"] == "some/prefix"
92+
assert len(result["Rules"][0]["Filter"]["And"]["Tags"]) == 1
93+
assert result["Rules"][0]["Filter"]["And"]["Tags"][0]["Key"] == "mytag"
94+
assert result["Rules"][0]["Filter"]["And"]["Tags"][0]["Value"] == "mytagvalue"
95+
assert result["Rules"][0]["Filter"]["Tag"]["Key"] == "mytag"
96+
assert result["Rules"][0]["Filter"]["Tag"]["Value"] == "mytagvalue"
97+
with assert_raises(KeyError):
98+
assert result["Rules"][0]["Prefix"]
99+
100+
# With multiple And tags:
101+
lfc["Rules"][0]["Filter"]["And"] = {
102+
"Prefix": "some/prefix",
103+
"Tags": [
104+
{
105+
"Key": "mytag",
106+
"Value": "mytagvalue"
107+
},
108+
{
109+
"Key": "mytag2",
110+
"Value": "mytagvalue2"
111+
}
112+
]
113+
}
114+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
115+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
116+
assert len(result["Rules"]) == 1
117+
assert result["Rules"][0]["Filter"]["Prefix"] == ""
118+
assert result["Rules"][0]["Filter"]["And"]["Prefix"] == "some/prefix"
119+
assert len(result["Rules"][0]["Filter"]["And"]["Tags"]) == 2
120+
assert result["Rules"][0]["Filter"]["And"]["Tags"][0]["Key"] == "mytag"
121+
assert result["Rules"][0]["Filter"]["And"]["Tags"][0]["Value"] == "mytagvalue"
122+
assert result["Rules"][0]["Filter"]["Tag"]["Key"] == "mytag"
123+
assert result["Rules"][0]["Filter"]["Tag"]["Value"] == "mytagvalue"
124+
assert result["Rules"][0]["Filter"]["And"]["Tags"][1]["Key"] == "mytag2"
125+
assert result["Rules"][0]["Filter"]["And"]["Tags"][1]["Value"] == "mytagvalue2"
126+
assert result["Rules"][0]["Filter"]["Tag"]["Key"] == "mytag"
127+
assert result["Rules"][0]["Filter"]["Tag"]["Value"] == "mytagvalue"
128+
with assert_raises(KeyError):
129+
assert result["Rules"][0]["Prefix"]
130+
131+
# Can't have both filter and prefix:
132+
lfc["Rules"][0]["Prefix"] = ''
133+
with assert_raises(ClientError) as err:
134+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
135+
assert err.exception.response["Error"]["Code"] == "MalformedXML"
136+
137+
lfc["Rules"][0]["Prefix"] = 'some/path'
138+
with assert_raises(ClientError) as err:
139+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
140+
assert err.exception.response["Error"]["Code"] == "MalformedXML"
141+
142+
# No filters -- just a prefix:
143+
del lfc["Rules"][0]["Filter"]
144+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
145+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
146+
assert not result["Rules"][0].get("Filter")
147+
assert result["Rules"][0]["Prefix"] == "some/path"
148+
149+
150+
@mock_s3
151+
def test_lifecycle_with_eodm():
152+
client = boto3.client("s3")
153+
client.create_bucket(Bucket="bucket")
154+
155+
lfc = {
156+
"Rules": [
157+
{
158+
"Expiration": {
159+
"ExpiredObjectDeleteMarker": True
160+
},
161+
"ID": "wholebucket",
162+
"Filter": {
163+
"Prefix": ""
164+
},
165+
"Status": "Enabled"
166+
}
167+
]
168+
}
169+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
170+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
171+
assert len(result["Rules"]) == 1
172+
assert result["Rules"][0]["Expiration"]["ExpiredObjectDeleteMarker"]
173+
174+
# Set to False:
175+
lfc["Rules"][0]["Expiration"]["ExpiredObjectDeleteMarker"] = False
176+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
177+
result = client.get_bucket_lifecycle_configuration(Bucket="bucket")
178+
assert len(result["Rules"]) == 1
179+
assert not result["Rules"][0]["Expiration"]["ExpiredObjectDeleteMarker"]
180+
181+
# With failure:
182+
lfc["Rules"][0]["Expiration"]["Days"] = 7
183+
with assert_raises(ClientError) as err:
184+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
185+
assert err.exception.response["Error"]["Code"] == "MalformedXML"
186+
del lfc["Rules"][0]["Expiration"]["Days"]
187+
188+
lfc["Rules"][0]["Expiration"]["Date"] = datetime(2015, 1, 1)
189+
with assert_raises(ClientError) as err:
190+
client.put_bucket_lifecycle_configuration(Bucket="bucket", LifecycleConfiguration=lfc)
191+
assert err.exception.response["Error"]["Code"] == "MalformedXML"
192+
193+
29194
@mock_s3_deprecated
30195
def test_lifecycle_with_glacier_transition():
31196
conn = boto.s3.connect_to_region("us-west-1")

0 commit comments

Comments
 (0)