Repository: localstack/localstack

Issue:
S3 Terraform issue
<!-- Love localstack? Please consider supporting our collective:
👉  https://opencollective.com/localstack/donate -->

Hi there.

I'm having an issue when using terraform to deploy resources into a running instance of localtack.
This happens when I start localstack from a `docker-compose` file using the `localstack/localstack:latest` image and also running `localstack start`. I'm using Terraform 0.12 and the following `.tf` file:

```
variable "prefix" {}
variable "region" {}

terraform {
  required_version = ">= 0.12"
  backend "local" {
  }
}

provider "aws" {
  region                        = var.region
  skip_credentials_validation   = true
  skip_metadata_api_check       = true
  skip_region_validation        = true
  s3_force_path_style           = true

  endpoints {
    s3 = "http://localhost:4572"
  }
}

resource "aws_s3_bucket" "test-bucket" {
  acl    = "public-read"
  bucket = "${var.prefix}-${terraform.workspace}-sample-bucket"
  force_destroy = true
}
```

Executing `tf init`, `tf plan` and `tf apply` passing the necessary variables creates the bucket with no problem. The second time I run `tf apply` with no changes to our `.tf` file, terraform tells me that it needs to refresh state to the following:

```
Terraform will perform the following actions:

  # aws_s3_bucket.test-bucket will be updated in-place
  ~ resource "aws_s3_bucket" "test-bucket" {
        acl                         = "public-read"
        arn                         = "arn:aws:s3:::anz-pos-default-sample-bucket"
        bucket                      = "anz-pos-default-sample-bucket"
        bucket_domain_name          = "anz-pos-default-sample-bucket.s3.amazonaws.com"
        bucket_regional_domain_name = "anz-pos-default-sample-bucket.s3.ap-southeast-2.amazonaws.com"
        force_destroy               = true
        hosted_zone_id              = "Z1WCIGYICN2BYD"
        id                          = "anz-pos-default-sample-bucket"
        region                      = "ap-southeast-2"
        tags                        = {}

      - object_lock_configuration {
        }

      - replication_configuration {
        }

      - server_side_encryption_configuration {
        }

        versioning {
            enabled    = false
            mfa_delete = false
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.
```

Why do `object_lock_configuration`, `replication_configuration` and `server_side_encryption_configuration` need to be removed?

I'm posting this issue here, since I can't replicate the error when using an actual AWS account as a remote AWS provider, so I'm assuming this may be something I'm doing wrong with Localstack.


Discussion:

Resolving patch:
diff --git a/localstack/services/es/es_api.py b/localstack/services/es/es_api.py
--- a/localstack/services/es/es_api.py
+++ b/localstack/services/es/es_api.py
@@ -74,6 +74,9 @@ def get_domain_config(domain_name):
                 },
                 'Status': config_status
             },
+            'CognitoOptions': {
+                'Enabled': False
+            },
             'ElasticsearchVersion': {
                 'Options': '5.3',
                 'Status': config_status
@@ -148,6 +151,9 @@ def get_domain_status(domain_name, deleted=False):
                 'VolumeSize': 10,
                 'Iops': 0
             },
+            'CognitoOptions': {
+                'Enabled': False
+            },
         }
     }
 
diff --git a/localstack/services/s3/s3_listener.py b/localstack/services/s3/s3_listener.py
--- a/localstack/services/s3/s3_listener.py
+++ b/localstack/services/s3/s3_listener.py
@@ -30,9 +30,18 @@
 # mappings for bucket CORS settings
 BUCKET_CORS = {}
 
-# mappings for bucket lifecycle settings
+# maps bucket name to lifecycle settings
 BUCKET_LIFECYCLE = {}
 
+# maps bucket name to replication settings
+BUCKET_REPLICATIONS = {}
+
+# maps bucket name to encryption settings
+BUCKET_ENCRYPTIONS = {}
+
+# maps bucket name to object lock settings
+OBJECT_LOCK_CONFIGS = {}
+
 # set up logger
 LOGGER = logging.getLogger(__name__)
 
@@ -78,10 +87,11 @@ def filter_rules_match(filters, object_path):
     filters = filters or {}
     s3_filter = _get_s3_filter(filters)
     for rule in s3_filter.get('FilterRule', []):
-        if rule['Name'] == 'prefix':
+        rule_name_lower = rule['Name'].lower()
+        if rule_name_lower == 'prefix':
             if not prefix_with_slash(object_path).startswith(prefix_with_slash(rule['Value'])):
                 return False
-        elif rule['Name'] == 'suffix':
+        elif rule_name_lower == 'suffix':
             if not object_path.endswith(rule['Value']):
                 return False
         else:
@@ -330,62 +340,119 @@ def append_metadata_headers(method, query_map, headers):
             if headers.get(key) is None:
                 headers[key] = value[0]
 
+# --------------
+# HELPER METHODS
+#   for lifecycle/replication/encryption/...
+# --------------
+
 
 def get_lifecycle(bucket_name):
     bucket_name = normalize_bucket_name(bucket_name)
-
     lifecycle = BUCKET_LIFECYCLE.get(bucket_name)
+    status_code = 200
     if not lifecycle:
-        # TODO: check if bucket exists, otherwise return 404-like error
+        # TODO: check if bucket actually exists
         lifecycle = {
-            'LifecycleConfiguration': {}
+            'Error': {
+                'Code': 'NoSuchLifecycleConfiguration',
+                'Message': 'The lifecycle configuration does not exist'
+            }
         }
+        status_code = 404
     body = xmltodict.unparse(lifecycle)
-    return requests_response(body)
+    return requests_response(body, status_code=status_code)
 
 
 def get_replication(bucket_name):
     bucket_name = normalize_bucket_name(bucket_name)
-
-    # TODO return actual value
-    # result = {
-    #     'Error': {
-    #         'Code': 'NoSuchReplicationConfiguration',
-    #         'Message': 'There is no replication configuration with that name.'
-    #     }
-    # }
-    # content = xmltodict.unparse(result)
-    # return requests_response(content, status_code=404)
-    # see https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketGETreplication.html
-    config = {}
-    result = {
-        'ReplicationConfiguration': config
-    }
-    body = xmltodict.unparse(result)
-    return requests_response(body)
+    replication = BUCKET_REPLICATIONS.get(bucket_name)
+    status_code = 200
+    if not replication:
+        # TODO: check if bucket actually exists
+        replication = {
+            'Error': {
+                'Code': 'ReplicationConfigurationNotFoundError',
+                'Message': 'The replication configuration was not found'
+            }
+        }
+        status_code = 404
+    body = xmltodict.unparse(replication)
+    return requests_response(body, status_code=status_code)
 
 
 def get_encryption(bucket_name):
     bucket_name = normalize_bucket_name(bucket_name)
+    encryption = BUCKET_ENCRYPTIONS.get(bucket_name)
+    status_code = 200
+    if not encryption:
+        # TODO: check if bucket actually exists
+        encryption = {
+            'Error': {
+                'Code': 'ServerSideEncryptionConfigurationNotFoundError',
+                'Message': 'The server side encryption configuration was not found'
+            }
+        }
+        status_code = 404
+    body = xmltodict.unparse(encryption)
+    return requests_response(body, status_code=status_code)
 
-    # TODO return actual value
-    result = {
-        'ServerSideEncryptionConfiguration': {}
-    }
-    body = xmltodict.unparse(result)
-    return requests_response(body)
+
+def get_object_lock(bucket_name):
+    bucket_name = normalize_bucket_name(bucket_name)
+    lock_config = OBJECT_LOCK_CONFIGS.get(bucket_name)
+    status_code = 200
+    if not lock_config:
+        # TODO: check if bucket actually exists
+        lock_config = {
+            'Error': {
+                'Code': 'ObjectLockConfigurationNotFoundError',
+                'Message': 'Object Lock configuration does not exist for this bucket'
+            }
+        }
+        status_code = 404
+    body = xmltodict.unparse(lock_config)
+    return requests_response(body, status_code=status_code)
 
 
 def set_lifecycle(bucket_name, lifecycle):
     bucket_name = normalize_bucket_name(bucket_name)
-
     # TODO: check if bucket exists, otherwise return 404-like error
     if isinstance(to_str(lifecycle), six.string_types):
         lifecycle = xmltodict.parse(lifecycle)
     BUCKET_LIFECYCLE[bucket_name] = lifecycle
-    response = Response()
-    response.status_code = 200
-    return response
+    return 200
+
+
+def set_replication(bucket_name, replication):
+    bucket_name = normalize_bucket_name(bucket_name)
+    # TODO: check if bucket exists, otherwise return 404-like error
+    if isinstance(to_str(replication), six.string_types):
+        replication = xmltodict.parse(replication)
+    BUCKET_REPLICATIONS[bucket_name] = replication
+    return 200
+
+
+def set_encryption(bucket_name, encryption):
+    bucket_name = normalize_bucket_name(bucket_name)
+    # TODO: check if bucket exists, otherwise return 404-like error
+    if isinstance(to_str(encryption), six.string_types):
+        encryption = xmltodict.parse(encryption)
+    BUCKET_ENCRYPTIONS[bucket_name] = encryption
+    return 200
+
+
+def set_object_lock(bucket_name, lock_config):
+    bucket_name = normalize_bucket_name(bucket_name)
+    # TODO: check if bucket exists, otherwise return 404-like error
+    if isinstance(to_str(lock_config), six.string_types):
+        lock_config = xmltodict.parse(lock_config)
+    OBJECT_LOCK_CONFIGS[bucket_name] = lock_config
+    return 200
+
+
+# -------------
+# UTIL METHODS
+# -------------
 
 
 def strip_chunk_signatures(data):
@@ -653,10 +720,20 @@ def forward_request(self, method, path, data, headers):
         if query == 'replication' or 'replication' in query_map:
             if method == 'GET':
                 return get_replication(bucket)
+            if method == 'PUT':
+                return set_replication(bucket, data)
 
         if query == 'encryption' or 'encryption' in query_map:
             if method == 'GET':
                 return get_encryption(bucket)
+            if method == 'PUT':
+                return set_encryption(bucket, data)
+
+        if query == 'object-lock' or 'object-lock' in query_map:
+            if method == 'GET':
+                return get_object_lock(bucket)
+            if method == 'PUT':
+                return set_object_lock(bucket, data)
 
         if modified_data is not None:
             return Request(data=modified_data, headers=headers, method=method)
@@ -758,7 +835,7 @@ def return_response(self, method, path, data, headers, response):
         if response:
             reset_content_length = False
 
-            # append CORS headers and other annotations to response
+            # append CORS headers and other annotations/patches to response
             append_cors_headers(bucket_name, request_method=method, request_headers=headers, response=response)
             append_last_modified_headers(response=response)
             append_list_objects_marker(method, path, data, response)


Test patch: