diff --git a/moto/s3/models.py b/moto/s3/models.py
index 1db77064f..dd49550a6 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -1477,6 +1477,28 @@ class S3Backend(BaseBackend):
def delete_bucket_encryption(self, bucket_name):
self.get_bucket(bucket_name).encryption = None
+ def get_bucket_replication(self, bucket_name):
+ bucket = self.get_bucket(bucket_name)
+ return getattr(bucket, "replication", None)
+
+ def put_bucket_replication(self, bucket_name, replication):
+ if isinstance(replication["Rule"], dict):
+ replication["Rule"] = [replication["Rule"]]
+ for rule in replication["Rule"]:
+ if "Priority" not in rule:
+ rule["Priority"] = 1
+ if "ID" not in rule:
+ rule["ID"] = "".join(
+ random.choice(string.ascii_letters + string.digits)
+ for _ in range(30)
+ )
+ bucket = self.get_bucket(bucket_name)
+ bucket.replication = replication
+
+ def delete_bucket_replication(self, bucket_name):
+ bucket = self.get_bucket(bucket_name)
+ bucket.replication = None
+
def put_bucket_lifecycle(self, bucket_name, rules):
bucket = self.get_bucket(bucket_name)
bucket.set_lifecycle(rules)
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 7904253dc..73115d32c 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -513,6 +513,13 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
return 200, {}, template.render(encryption=encryption)
elif querystring.get("list-type", [None])[0] == "2":
return 200, {}, self._handle_list_objects_v2(bucket_name, querystring)
+ elif "replication" in querystring:
+ replication = self.backend.get_bucket_replication(bucket_name)
+ if not replication:
+ template = self.response_template(S3_NO_REPLICATION)
+ return 404, {}, template.render(bucket_name=bucket_name)
+ template = self.response_template(S3_REPLICATION_CONFIG)
+ return 200, {}, template.render(replication=replication)
bucket = self.backend.get_bucket(bucket_name)
prefix = querystring.get("prefix", [None])[0]
@@ -781,6 +788,14 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
raise MalformedXML()
except Exception as e:
raise e
+ elif "replication" in querystring:
+ bucket = self.backend.get_bucket(bucket_name)
+ if not bucket.is_versioned:
+ template = self.response_template(S3_NO_VERSIONING_ENABLED)
+ return 400, {}, template.render(bucket_name=bucket_name)
+ replication_config = self._replication_config_from_xml(body)
+ self.backend.put_bucket_replication(bucket_name, replication_config)
+ return ""
else:
# us-east-1, the default AWS region behaves a bit differently
# - you should not use it as a location constraint --> it fails
@@ -865,6 +880,9 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
elif "encryption" in querystring:
self.backend.delete_bucket_encryption(bucket_name)
return 204, {}, ""
+ elif "replication" in querystring:
+ self.backend.delete_bucket_replication(bucket_name)
+ return 204, {}, ""
removed_bucket = self.backend.delete_bucket(bucket_name)
@@ -1893,6 +1911,11 @@ class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin):
config = parsed_xml["AccelerateConfiguration"]
return config["Status"]
+ def _replication_config_from_xml(self, xml):
+ parsed_xml = xmltodict.parse(xml, dict_constructor=dict)
+ config = parsed_xml["ReplicationConfiguration"]
+ return config
+
def _key_response_delete(self, headers, bucket_name, query, key_name):
self._set_action("KEY", "DELETE", query)
self._authenticate_and_authorize_s3_action()
@@ -2707,3 +2730,45 @@ S3_DUPLICATE_BUCKET_ERROR = """
9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg=
"""
+
+S3_NO_REPLICATION = """
+
+ ReplicationConfigurationNotFoundError
+ The replication configuration was not found
+ {{ bucket_name }}
+ ZM6MA8EGCZ1M9EW9
+ SMUZFedx1CuwjSaZQnM2bEVpet8UgX9uD/L7e MlldClgtEICTTVFz3C66cz8Bssci2OsWCVlog=
+
+"""
+
+S3_NO_VERSIONING_ENABLED = """
+
+ InvalidRequest
+ Versioning must be 'Enabled' on the bucket to apply a replication configuration
+ {{ bucket_name }}
+ ZM6MA8EGCZ1M9EW9
+ SMUZFedx1CuwjSaZQnM2bEVpet8UgX9uD/L7e MlldClgtEICTTVFz3C66cz8Bssci2OsWCVlog=
+
+"""
+
+S3_REPLICATION_CONFIG = """
+
+{% for rule in replication["Rule"] %}
+
+ {{ rule["ID"] }}
+ {{ rule["Priority"] }}
+ {{ rule["Status"] }}
+
+ Disabled
+
+
+
+
+
+ {{ rule["Destination"]["Bucket"] }}
+
+
+{% endfor %}
+{{ replication["Role"] }}
+
+"""
diff --git a/tests/test_s3/test_s3_replication.py b/tests/test_s3/test_s3_replication.py
new file mode 100644
index 000000000..442e63706
--- /dev/null
+++ b/tests/test_s3/test_s3_replication.py
@@ -0,0 +1,176 @@
+import boto3
+import pytest
+import sure # noqa
+
+from botocore.exceptions import ClientError
+from moto import mock_s3
+from uuid import uuid4
+
+DEFAULT_REGION_NAME = "us-east-1"
+
+
+@mock_s3
+def test_get_bucket_replication_for_unexisting_bucket():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+ with pytest.raises(ClientError) as exc:
+ s3.get_bucket_replication(Bucket=bucket_name)
+ err = exc.value.response["Error"]
+ err["Code"].should.equal("NoSuchBucket")
+ err["Message"].should.equal("The specified bucket does not exist")
+ err["BucketName"].should.equal(bucket_name)
+
+
+@mock_s3
+def test_get_bucket_replication_bucket_without_replication():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+ s3.create_bucket(Bucket=bucket_name)
+
+ with pytest.raises(ClientError) as exc:
+ s3.get_bucket_replication(Bucket=bucket_name)
+ err = exc.value.response["Error"]
+ err["Code"].should.equal("ReplicationConfigurationNotFoundError")
+ err["Message"].should.equal("The replication configuration was not found")
+ err["BucketName"].should.equal(bucket_name)
+
+
+@mock_s3
+def test_delete_bucket_replication_unknown_bucket():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+ with pytest.raises(ClientError) as exc:
+ s3.delete_bucket_replication(Bucket=bucket_name)
+ err = exc.value.response["Error"]
+ err["Code"].should.equal("NoSuchBucket")
+ err["Message"].should.equal("The specified bucket does not exist")
+ err["BucketName"].should.equal(bucket_name)
+
+
+@mock_s3
+def test_delete_bucket_replication_bucket_without_replication():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+
+ s3.create_bucket(Bucket=bucket_name)
+ # No-op
+ s3.delete_bucket_replication(Bucket=bucket_name)
+
+
+@mock_s3
+def test_create_replication_without_versioning():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+ s3.create_bucket(Bucket=bucket_name)
+
+ with pytest.raises(ClientError) as exc:
+ s3.put_bucket_replication(
+ Bucket=bucket_name,
+ ReplicationConfiguration={
+ "Role": "myrole",
+ "Rules": [
+ {"Destination": {"Bucket": "secondbucket"}, "Status": "Enabled"}
+ ],
+ },
+ )
+ err = exc.value.response["Error"]
+ err["Code"].should.equal("InvalidRequest")
+ err["Message"].should.equal(
+ "Versioning must be 'Enabled' on the bucket to apply a replication configuration"
+ )
+ err["BucketName"].should.equal(bucket_name)
+
+
+@mock_s3
+def test_create_and_retrieve_replication_with_single_rules():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+
+ s3.create_bucket(Bucket=bucket_name)
+ s3.put_bucket_versioning(
+ Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"}
+ )
+ s3.put_bucket_replication(
+ Bucket=bucket_name,
+ ReplicationConfiguration={
+ "Role": "myrole",
+ "Rules": [
+ {
+ "ID": "firstrule",
+ "Priority": 2,
+ "Destination": {"Bucket": "secondbucket"},
+ "Status": "Enabled",
+ }
+ ],
+ },
+ )
+
+ config = s3.get_bucket_replication(Bucket=bucket_name)["ReplicationConfiguration"]
+ config.should.equal(
+ {
+ "Role": "myrole",
+ "Rules": [
+ {
+ "DeleteMarkerReplication": {"Status": "Disabled"},
+ "Destination": {"Bucket": "secondbucket"},
+ "Filter": {"Prefix": ""},
+ "ID": "firstrule",
+ "Priority": 2,
+ "Status": "Enabled",
+ }
+ ],
+ }
+ )
+
+ s3.delete_bucket_replication(Bucket=bucket_name)
+
+ # Can't retrieve replication that has been deleted
+ with pytest.raises(ClientError) as exc:
+ s3.get_bucket_replication(Bucket=bucket_name)
+ err = exc.value.response["Error"]
+ err["Code"].should.equal("ReplicationConfigurationNotFoundError")
+ err["Message"].should.equal("The replication configuration was not found")
+ err["BucketName"].should.equal(bucket_name)
+
+
+@mock_s3
+def test_create_and_retrieve_replication_with_multiple_rules():
+ bucket_name = str(uuid4())
+ s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+
+ s3.create_bucket(Bucket=bucket_name)
+ s3.put_bucket_versioning(
+ Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"}
+ )
+ s3.put_bucket_replication(
+ Bucket=bucket_name,
+ ReplicationConfiguration={
+ "Role": "myrole",
+ "Rules": [
+ {"Destination": {"Bucket": "secondbucket"}, "Status": "Enabled"},
+ {
+ "ID": "secondrule",
+ "Priority": 2,
+ "Destination": {"Bucket": "thirdbucket"},
+ "Status": "Disabled",
+ },
+ ],
+ },
+ )
+
+ config = s3.get_bucket_replication(Bucket=bucket_name)["ReplicationConfiguration"]
+ config.should.have.key("Role").equal("myrole")
+ rules = config["Rules"]
+ rules.should.have.length_of(2)
+
+ first_rule = rules[0]
+ first_rule.should.have.key("ID")
+ first_rule.should.have.key("Priority").equal(1)
+ first_rule.should.have.key("Status").equal("Enabled")
+ first_rule.should.have.key("Destination").equal({"Bucket": "secondbucket"})
+
+ second = rules[1]
+ second.should.have.key("ID").equal("secondrule")
+ second.should.have.key("Priority").equal(2)
+ second.should.have.key("Status").equal("Disabled")
+ second.should.have.key("Destination").equal({"Bucket": "thirdbucket"})