Repository: localstack/localstack
Pull request: Add `AWS::OpenSearchService::Domain`
This PR adds the `Domain` resource for OpenSearch in CloudFormation. It functions the same as the Elasticsearch Domain (except some differences in parameters). 

I chose the code duplication because the OpenSearch Resource might diverge in the future.

Fixes #6163

Problem statement:
question: Are there any plans to add AWS::OpenSearchService::Domain resource?
### Is there an existing issue for this?

- [X] I have searched the existing issues and read the documentation

### Question

I am using serverless framework with localstack and I am unable to define an `AWS::OpenSearchService::Domain` resource. I guess I'll have to use `AWS::Elasticsearch::Domain` instead but it is deprecated. Are there any plans to add `AWS::OpenSearchService::Domain` resource soon? 

### Anything else?

_No response_

Hints from issue discussion:

Commit message: Remove unused variable

Patch to review:
diff --git a/localstack/services/cloudformation/models/__init__.py b/localstack/services/cloudformation/models/__init__.py
index dbe6d3792729b..23431649b3ffd 100644
--- a/localstack/services/cloudformation/models/__init__.py
+++ b/localstack/services/cloudformation/models/__init__.py
@@ -8,6 +8,7 @@
     "ec2",
     "ecr",
     "elasticsearch",
+    "opensearch",
     "events",
     "iam",
     "kinesis",
diff --git a/localstack/services/cloudformation/models/opensearch.py b/localstack/services/cloudformation/models/opensearch.py
new file mode 100644
index 0000000000000..daa43acd80de1
--- /dev/null
+++ b/localstack/services/cloudformation/models/opensearch.py
@@ -0,0 +1,82 @@
+from localstack.aws.api.opensearch import (
+    OpenSearchPartitionInstanceType,
+    OpenSearchWarmPartitionInstanceType,
+)
+from localstack.services.cloudformation.deployment_utils import remove_none_values
+from localstack.services.cloudformation.service_models import GenericBaseModel
+from localstack.utils.aws import aws_stack
+from localstack.utils.collections import select_attributes
+
+
+# OpenSearch still uses "es" ARNs
+# See examples in:
+# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchservice-domain.html
+def opensearch_add_tags_params(params, **kwargs):
+    es_arn = aws_stack.es_domain_arn(params.get("DomainName"))
+    tags = params.get("Tags", [])
+    return {"ARN": es_arn, "TagList": tags}
+
+
+class OpenSearchDomain(GenericBaseModel):
+    @staticmethod
+    def cloudformation_type():
+        return "AWS::OpenSearchService::Domain"
+
+    def get_physical_resource_id(self, attribute=None, **kwargs):
+        domain_name = self._domain_name()
+        if attribute == "Arn":
+            # As mentioned above, OpenSearch still uses "es" ARNs
+            return aws_stack.elasticsearch_domain_arn(domain_name)
+        return domain_name
+
+    def fetch_state(self, stack_name, resources):
+        domain_name = self._domain_name()
+        domain_name = self.resolve_refs_recursively(stack_name, domain_name, resources)
+        return aws_stack.connect_to_service("opensearch").describe_domain(DomainName=domain_name)
+
+    def _domain_name(self):
+        return self.props.get("DomainName") or self.resource_id
+
+    @staticmethod
+    def get_deploy_templates():
+        def _create_params(params, **kwargs):
+            attributes = [
+                "AccessPolicies",
+                "AdvancedOptions",
+                "CognitoOptions",
+                "DomainName",
+                "EBSOptions",
+                "ClusterConfig",
+                "EngineVersion",
+                "EncryptionAtRestOptions",
+                "LogPublishingOptions",
+                "NodeToNodeEncryptionOptions",
+                "SnapshotOptions",
+                "VPCOptions",
+            ]
+            result = select_attributes(params, attributes)
+            result = remove_none_values(result)
+            cluster_config = result.get("ClusterConfig")
+            if isinstance(cluster_config, dict):
+                # set defaults required for boto3 calls
+                cluster_config.setdefault(
+                    "DedicatedMasterType", OpenSearchPartitionInstanceType.m3_medium_search
+                )
+                cluster_config.setdefault(
+                    "WarmType", OpenSearchWarmPartitionInstanceType.ultrawarm1_medium_search
+                )
+            return result
+
+        return {
+            "create": [
+                {
+                    "function": "create_domain",
+                    "parameters": _create_params,
+                },
+                {"function": "add_tags", "parameters": opensearch_add_tags_params},
+            ],
+            "delete": {
+                "function": "delete_domain",
+                "parameters": {"DomainName": "DomainName"},
+            },
+        }
diff --git a/localstack/utils/cloudformation/template_deployer.py b/localstack/utils/cloudformation/template_deployer.py
index a328bcc903cb1..6853b6f06636f 100644
--- a/localstack/utils/cloudformation/template_deployer.py
+++ b/localstack/utils/cloudformation/template_deployer.py
@@ -123,6 +123,8 @@ def get_service_name(resource):
         return "cognito-idp"
     if parts[-2] == "Elasticsearch":
         return "es"
+    if parts[-2] == "OpenSearchService":
+        return "opensearch"
     if parts[-2] == "KinesisFirehose":
         return "firehose"
     if parts[-2] == "ResourceGroups":
diff --git a/tests/integration/templates/opensearch_domain.yaml b/tests/integration/templates/opensearch_domain.yaml
new file mode 100644
index 0000000000000..a5d35b7274045
--- /dev/null
+++ b/tests/integration/templates/opensearch_domain.yaml
@@ -0,0 +1,10 @@
+AWSTemplateFormatVersion: 2010-09-09
+Parameters:
+  OpenSearchDomainName:
+    Type: String
+Resources:
+  OpenSearchServiceDomain:
+    Type: AWS::OpenSearchService::Domain
+    Properties:
+      DomainName:
+        Ref: OpenSearchDomainName
\ No newline at end of file
diff --git a/tests/integration/test_opensearch.py b/tests/integration/test_opensearch.py
index b0d63b98c5286..af59946fc553f 100644
--- a/tests/integration/test_opensearch.py
+++ b/tests/integration/test_opensearch.py
@@ -1,5 +1,6 @@
 import json
 import logging
+import os
 import threading
 
 import botocore.exceptions
@@ -385,6 +386,20 @@ def test_endpoint_strategy_port(self, monkeypatch, opensearch_create_domain, ope
             config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END
         )
 
+    # testing CloudFormation deployment here to make sure OpenSearch is installed
+    def test_cloudformation_deployment(self, deploy_cfn_template, opensearch_client):
+        domain_name = f"domain-{short_uid()}"
+        deploy_cfn_template(
+            template_path=os.path.join(
+                os.path.dirname(__file__), "templates/opensearch_domain.yaml"
+            ),
+            parameters={"OpenSearchDomainName": domain_name},
+        )
+
+        response = opensearch_client.list_domain_names(EngineType="OpenSearch")
+        domain_names = [domain["DomainName"] for domain in response["DomainNames"]]
+        assert domain_name in domain_names
+
 
 @pytest.mark.skip_offline
 class TestEdgeProxiedOpensearchCluster:


Code review comments:
diff_hunk: @@ -0,0 +1,82 @@
+from localstack.aws.api.opensearch import (
+    OpenSearchPartitionInstanceType,
+    OpenSearchWarmPartitionInstanceType,
+)
+from localstack.services.cloudformation.deployment_utils import remove_none_values
+from localstack.services.cloudformation.service_models import GenericBaseModel
+from localstack.utils.aws import aws_stack
+from localstack.utils.collections import select_attributes
+
+
+# OpenSearch still uses "es" ARNs
+# See examples in:
+# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchservice-domain.html
+def opensearch_add_tags_params(params, **kwargs):
+    es_arn = aws_stack.es_domain_arn(params.get("DomainName"))
+    tags = params.get("Tags", [])
+    return {"ARN": es_arn, "TagList": tags}
+
+
+class OpenSearchDomain(GenericBaseModel):
+    @staticmethod
+    def cloudformation_type():
+        return "AWS::OpenSearchService::Domain"
+
+    def get_physical_resource_id(self, attribute=None, **kwargs):
+        domain_name = self._domain_name()
+        if attribute == "Arn":
+            # As mentioned above, OpenSearch still uses "es" ARNs
+            return aws_stack.elasticsearch_domain_arn(domain_name)
+        return domain_name
+
+    def fetch_state(self, stack_name, resources):
+        domain_name = self._domain_name()
+        domain_name = self.resolve_refs_recursively(stack_name, domain_name, resources)
+        return aws_stack.connect_to_service("opensearch").describe_domain(DomainName=domain_name)
+
+    def _domain_name(self):
+        return self.props.get("DomainName") or self.resource_id
+
+    @staticmethod
+    def get_deploy_templates():
+        def _create_params(params, **kwargs):
+            attributes = [
+                "AccessPolicies",
+                "AdvancedOptions",
+                "CognitoOptions",
+                "DomainName",
+                "EBSOptions",
+                "ClusterConfig",
+                "EngineVersion",
+                "EncryptionAtRestOptions",
+                "LogPublishingOptions",
+                "NodeToNodeEncryptionOptions",
+                "SnapshotOptions",
+                "VPCOptions",
+            ]
path: localstack/services/cloudformation/models/opensearch.py
text: @author:
As far as I've understood it, the tags get handled via the `add_tags` operation in the `create` list of the return value of `get_deploy_templates`. So, omitting that value from the ASF-based list would suffice.