Repository: localstack/localstack

Issue:
unable to create opensearch domain and elastic search domain 
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

Using cloudformation template to create resources 
Only some of the resources are getting replicated 

this is cloudformation template 
```
{
  "Resources": {
    "LocalBucket": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "BucketName": "cfn-quickstart-bucket"
      }
    },
    "myDynamoDBTable" : {
      "Type" : "AWS::DynamoDB::Table",
      "Properties" : {
        "AttributeDefinitions" : [
          {
            "AttributeName" : "Album",
            "AttributeType" : "S"   
          },
          {
            "AttributeName" : "Artist",
            "AttributeType" : "S"
          },
          {
            "AttributeName" : "Sales",
            "AttributeType" : "N"
          },
          {
            "AttributeName" : "NumberOfSongs",
            "AttributeType" : "N"
          }
        ],
        "KeySchema" : [
          {
            "AttributeName" : "Album",
            "KeyType" : "HASH"
          },
          {
            "AttributeName" : "Artist",
            "KeyType" : "RANGE"
          }
        ],
        "ProvisionedThroughput" : {
          "ReadCapacityUnits" : "5",
          "WriteCapacityUnits" : "5"
        },
        "TableName" : "myTableName",
        "GlobalSecondaryIndexes" : [{
          "IndexName" : "myGSI",
          "KeySchema" : [
            {
              "AttributeName" : "Sales",
              "KeyType" : "HASH"
            },
            {
              "AttributeName" : "Artist",
              "KeyType" : "RANGE"
            }
          ],                         
          "Projection" : {
            "NonKeyAttributes" : ["Album","NumberOfSongs"],
            "ProjectionType" : "INCLUDE"
          },
          "ProvisionedThroughput" : {
            "ReadCapacityUnits" : "5",
            "WriteCapacityUnits" : "5"
          }
        },
        {
          "IndexName" : "myGSI2",
          "KeySchema" : [
            {
              "AttributeName" : "NumberOfSongs",
              "KeyType" : "HASH"
            },
            {
              "AttributeName" : "Sales",
              "KeyType" : "RANGE"
            }
          ],                         
          "Projection" : {
            "NonKeyAttributes" : ["Album","Artist"],
            "ProjectionType" : "INCLUDE"
          },
          "ProvisionedThroughput" : {
            "ReadCapacityUnits" : "5",
            "WriteCapacityUnits" : "5"
          }
        }],
        "LocalSecondaryIndexes" :[{
          "IndexName" : "myLSI",
          "KeySchema" : [
            {
              "AttributeName" : "Album",
              "KeyType" : "HASH"
            },
            {
              "AttributeName" : "Sales",
              "KeyType" : "RANGE"
            }
          ],                           
          "Projection" : {
            "NonKeyAttributes" : ["Artist","NumberOfSongs"],
            "ProjectionType" : "INCLUDE"
          }
        }]
      }
    },
    "OpenSearchServiceDomain": {
   "Type":"AWS::OpenSearchService::Domain",
   "Properties": {
      "DomainName": "test",
      "EngineVersion": "OpenSearch_1.0",
      "ClusterConfig": {
         "DedicatedMasterEnabled": true,
         "InstanceCount": "2",
         "ZoneAwarenessEnabled": true,
         "InstanceType": "m3.medium.search",
         "DedicatedMasterType": "m3.medium.search",
         "DedicatedMasterCount": "3"
      },
      "EBSOptions":{
         "EBSEnabled": true,
         "Iops": "0",
         "VolumeSize": "20",
         "VolumeType": "gp2"
      }
    }
    },
    "ElasticsearchDomain": {
   "Type":"AWS::Elasticsearch::Domain",
   "Properties": {
      "DomainName": "test",
      "ElasticsearchVersion": "7.10",
      "ElasticsearchClusterConfig": {
         "DedicatedMasterEnabled": true,
         "InstanceCount": "2",
         "ZoneAwarenessEnabled": true,
         "InstanceType": "m3.medium.elasticsearch",
         "DedicatedMasterType": "m3.medium.elasticsearch",
         "DedicatedMasterCount": "3"
      },
      "EBSOptions":{
         "EBSEnabled": true,
         "Iops": "0",
         "VolumeSize": "20",
         "VolumeType": "gp2"
      }
    }
  }
  }
}
 
```
I want to mock these resources on my local machine using localstack, but i am only able to create dynamodb table and s3 bucket
I don't know what is the problem with opensearch
this is my docker-compose.yml

```
version: '2.1'
services:
  localstack:
    image: 'localstack/localstack:latest'
    container_name: 'localstack_re'    
    ports:      
      - '4566-4620:4566-4620'
      - '127.0.0.1:8055:8080'      
    environment:      
      - SERVICES=lambda,s3,apigateway,cloudformation,dynamodb,opensearch,sns
      - DEBUG=1
      - EDGE_PORT=4566
      - DATA_DIR=/var/lib/localstack/data
      - DOCKER_HOST=unix:///var/run/docker.sock
      - HOST_TMP_FOLDER=${TMPDIR}
      - LAMBDA_EXECUTOR=docker          
      - DYNAMODB_SHARE_DB=1   
      - DISABLE_CORS_CHECKS=1  
      - AWS_DDB_ENDPOINT=http://localhost:4566 
      
    volumes:
      - '${TMPDIR:-/var/lib/localstack}:/var/lib/localstack'
      - '/var/run/docker.sock:/var/run/docker.sock'     
    networks:
      - 'local' 
```




### Expected Behavior

Should create all the resources 

### How are you starting LocalStack?

With a docker-compose file

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    docker run localstack/localstack

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

    awslocal s3 mb s3://mybucket


### Environment

```markdown
- OS: windows 11
- LocalStack: 1.3.0
```


### Anything else?

_No response_


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [contributing guide](https://docs.localstack.cloud/contributing/).

Resolving patch:
diff --git a/localstack/services/cloudformation/models/elasticsearch.py b/localstack/services/cloudformation/models/elasticsearch.py
--- a/localstack/services/cloudformation/models/elasticsearch.py
+++ b/localstack/services/cloudformation/models/elasticsearch.py
@@ -1,7 +1,8 @@
+from localstack.aws.api.es import CreateElasticsearchDomainRequest
 from localstack.services.cloudformation.deployment_utils import remove_none_values
 from localstack.services.cloudformation.service_models import GenericBaseModel
 from localstack.utils.aws import arns, aws_stack
-from localstack.utils.common import select_attributes
+from localstack.utils.collections import convert_to_typed_dict
 
 
 def es_add_tags_params(params, **kwargs):
@@ -45,21 +46,7 @@ def _domain_name(self):
     @staticmethod
     def get_deploy_templates():
         def _create_params(params, **kwargs):
-            attributes = [
-                "AccessPolicies",
-                "AdvancedOptions",
-                "CognitoOptions",
-                "DomainName",
-                "EBSOptions",
-                "ElasticsearchClusterConfig",
-                "ElasticsearchVersion",
-                "EncryptionAtRestOptions",
-                "LogPublishingOptions",
-                "NodeToNodeEncryptionOptions",
-                "SnapshotOptions",
-                "VPCOptions",
-            ]
-            result = select_attributes(params, attributes)
+            result = convert_to_typed_dict(CreateElasticsearchDomainRequest, params)
             result = remove_none_values(result)
             cluster_config = result.get("ElasticsearchClusterConfig")
             if isinstance(cluster_config, dict):
diff --git a/localstack/services/cloudformation/models/opensearch.py b/localstack/services/cloudformation/models/opensearch.py
--- a/localstack/services/cloudformation/models/opensearch.py
+++ b/localstack/services/cloudformation/models/opensearch.py
@@ -6,7 +6,7 @@
 from localstack.services.cloudformation.deployment_utils import remove_none_values
 from localstack.services.cloudformation.service_models import GenericBaseModel
 from localstack.utils.aws import arns, aws_stack
-from localstack.utils.collections import select_attributes
+from localstack.utils.collections import convert_to_typed_dict
 
 
 # OpenSearch still uses "es" ARNs
@@ -41,14 +41,8 @@ def _domain_name(self):
     @staticmethod
     def get_deploy_templates():
         def _create_params(params, **kwargs):
-            # Tags handled outside of creation
-            attributes = [
-                attribute
-                for attribute in CreateDomainRequest.__annotations__.keys()
-                if "Tag" not in attribute
-            ]
-            result = select_attributes(params, attributes)
-            result = remove_none_values(result)
+            params = remove_none_values(params)
+            result = convert_to_typed_dict(CreateDomainRequest, params)
             cluster_config = result.get("ClusterConfig")
             if isinstance(cluster_config, dict):
                 # set defaults required for boto3 calls
diff --git a/localstack/utils/collections.py b/localstack/utils/collections.py
--- a/localstack/utils/collections.py
+++ b/localstack/utils/collections.py
@@ -7,14 +7,27 @@
 import re
 import sys
 from collections.abc import Mapping
-from typing import Any, Callable, Dict, Iterator, List, Optional, Sized, Tuple, Type, TypeVar, Union
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterator,
+    List,
+    Optional,
+    Sized,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+    cast,
+)
 
 import cachetools
 
 if sys.version_info >= (3, 8):
-    from typing import TypedDict
+    from typing import TypedDict, get_args, get_origin
 else:
-    from typing_extensions import TypedDict
+    from typing_extensions import TypedDict, get_args, get_origin
 
 
 LOG = logging.getLogger(__name__)
@@ -426,5 +439,42 @@ def select_from_typed_dict(typed_dict: Type[TypedDict], obj: Dict, filter: bool
         obj, [*typed_dict.__required_keys__, *typed_dict.__optional_keys__]
     )
     if filter:
-        return {k: v for k, v in selection.items() if v}
+        selection = {k: v for k, v in selection.items() if v}
     return selection
+
+
+T = TypeVar("T", bound=Dict)
+
+
+def convert_to_typed_dict(typed_dict: Type[T], obj: Dict, strict: bool = False) -> T:
+    """
+    Converts the given object to the given typed dict (by calling the type constructors).
+    Limitations:
+    - This does not work for ForwardRefs (type refs in quotes).
+    - If a type is a Union, the first type is used for the conversion.
+    - The conversion fails for types which cannot be instantiated with the constructor.
+
+    :param typed_dict: to convert the given object to
+    :param obj: object to convert matching keys to the types defined in the typed dict
+    :param strict: True if a TypeError should be raised in case the conversion fails
+    :return: obj converted to the typed dict T
+    """
+    result = cast(T, select_from_typed_dict(typed_dict, obj, filter=True))
+    for key, key_type in typed_dict.__annotations__.items():
+        if key in result:
+            # If it's a Union, or optional, we extract the first type argument
+            if get_origin(key_type) in [Union, Optional]:
+                key_type = get_args(key_type)[0]
+            # Use duck-typing to check if the dict is a typed dict
+            if hasattr(key_type, "__required_keys__") and hasattr(key_type, "__optional_keys__"):
+                result[key] = convert_to_typed_dict(key_type, result[key])
+            else:
+                # Otherwise, we call the type's constructor (on a best-effort basis)
+                try:
+                    result[key] = key_type(result[key])
+                except TypeError as e:
+                    if strict:
+                        raise e
+                    else:
+                        LOG.debug("Could not convert %s to %s.", key, key_type)
+    return result


Test patch:
diff --git a/tests/integration/cloudformation/resources/test_elasticsearch.py b/tests/integration/cloudformation/resources/test_elasticsearch.py
--- a/tests/integration/cloudformation/resources/test_elasticsearch.py
+++ b/tests/integration/cloudformation/resources/test_elasticsearch.py
@@ -14,7 +14,7 @@
     Properties:
       DomainName: !Ref "DomainName"
       ElasticsearchClusterConfig:
-        InstanceCount: 1
+        InstanceCount: "1"
         InstanceType: 'm5.large.elasticsearch'
         ZoneAwarenessEnabled: false
         # remaining required attributes (DedicatedMasterType, WarmType) should get filled in by template deployer
diff --git a/tests/integration/templates/opensearch_domain.yml b/tests/integration/templates/opensearch_domain.yml
--- a/tests/integration/templates/opensearch_domain.yml
+++ b/tests/integration/templates/opensearch_domain.yml
@@ -10,4 +10,6 @@ Resources:
         Ref: domainName
       EngineVersion: OpenSearch_1.0
       ClusterConfig:
-        InstanceType: 'm3.medium.search'
\ No newline at end of file
+        InstanceType: 'm3.medium.search'
+        # test using string types instead of int
+        InstanceCount: "2"
diff --git a/tests/unit/utils/test_collections.py b/tests/unit/utils/test_collections.py
--- a/tests/unit/utils/test_collections.py
+++ b/tests/unit/utils/test_collections.py
@@ -1,4 +1,4 @@
-from typing import Optional, TypedDict
+from typing import Optional, TypedDict, Union
 
 import pytest
 
@@ -7,6 +7,7 @@
     HashableList,
     ImmutableDict,
     ImmutableList,
+    convert_to_typed_dict,
     select_from_typed_dict,
 )
 
@@ -108,3 +109,72 @@ def test_hashable_list():
     with pytest.raises(Exception) as exc:
         l1[0] = "foo"
     exc.match("does not support item assignment")
+
+
+def test_convert_to_typed_dict():
+    class TestTypedDict(TypedDict):
+        str_member: str
+        int_member: int
+        dict_member: dict
+
+    test_dict = {"str_member": 1, "int_member": "1", "dict_member": {"inner_member": 1}}
+
+    result = convert_to_typed_dict(TestTypedDict, test_dict)
+    assert isinstance(result, dict)
+    assert result["str_member"] == "1"
+    assert result["int_member"] == 1
+    assert result["dict_member"] == {"inner_member": 1}
+
+
+def test_convert_to_typed_dict_with_union():
+    class TestTypedDict(TypedDict):
+        union_member: Union[str, int]
+
+    test_dict = {"union_member": 1}
+
+    result = convert_to_typed_dict(TestTypedDict, test_dict)
+    assert isinstance(result, dict)
+    assert result["union_member"] == "1"
+
+
+def test_convert_to_typed_dict_with_optional():
+    class TestTypedDict(TypedDict):
+        optional_member: Optional[str]
+
+    test_dict = {"optional_member": 1}
+
+    result = convert_to_typed_dict(TestTypedDict, test_dict)
+    assert isinstance(result, dict)
+    assert result["optional_member"] == "1"
+
+
+def test_convert_to_typed_dict_with_strict_mode():
+    class ClassWithoutValueConstructor:
+        pass
+
+    class TestTypedDict(TypedDict):
+        non_convertable: ClassWithoutValueConstructor
+
+    test_dict = {"non_convertable": ClassWithoutValueConstructor()}
+
+    # ensure the strict conversion fails
+    with pytest.raises(TypeError):
+        convert_to_typed_dict(TestTypedDict, test_dict, strict=True)
+
+    # ensure the non-strict conversion contains the original values
+    result = convert_to_typed_dict(TestTypedDict, test_dict)
+    assert test_dict == result
+
+
+def test_convert_to_typed_dict_with_typed_subdict():
+    class InnerTypedDict(TypedDict):
+        str_member: str
+
+    class TestTypedDict(TypedDict):
+        subdict: InnerTypedDict
+
+    test_dict = {"subdict": {"str_member": 1}}
+
+    result = convert_to_typed_dict(TestTypedDict, test_dict)
+    assert isinstance(result, dict)
+    assert result["subdict"] == {"str_member": "1"}