Repository: localstack/localstack

Issue:
feature request: implement es update-elasticsearch-domain-config
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Feature description

Hello team. Please, kindly work on this feature for awslocal CLI:

awslocal es update-elasticsearch-domain-config --domain-name my-domain --region us-east-1 \
      --advanced-security-options '{ "Enabled": true, "InternalUserDatabaseEnabled":true, "MasterUserOptions": {"MasterUserName":"master-username","MasterUserPassword":"master-password"},"AnonymousAuthEnabled": true}'
      
 Right now it returns the following response:
 
An error occurred (InternalFailure) when calling the UpdateElasticsearchDomainConfig operation: API action 'UpdateElasticsearchDomainConfig' for service 'es' not yet implemented
 
 Thank you,
 Victor

### 🧑‍💻 Implementation

_No response_

### Anything else?

_No response_


Discussion:

Resolving patch:
diff --git a/localstack/services/es/provider.py b/localstack/services/es/provider.py
--- a/localstack/services/es/provider.py
+++ b/localstack/services/es/provider.py
@@ -1,10 +1,10 @@
 from contextlib import contextmanager
-from typing import Optional, cast
+from typing import Dict, Optional, cast
 
 from botocore.exceptions import ClientError
 
 from localstack import constants
-from localstack.aws.api import RequestContext
+from localstack.aws.api import RequestContext, handler
 from localstack.aws.api.es import (
     ARN,
     AccessDeniedException,
@@ -57,6 +57,8 @@
     SnapshotOptions,
     StringList,
     TagList,
+    UpdateElasticsearchDomainConfigRequest,
+    UpdateElasticsearchDomainConfigResponse,
     ValidationException,
     VPCOptions,
 )
@@ -301,6 +303,26 @@ def describe_elasticsearch_domain(
         status = _domainstatus_from_opensearch(opensearch_status)
         return DescribeElasticsearchDomainResponse(DomainStatus=status)
 
+    @handler("UpdateElasticsearchDomainConfig", expand=False)
+    def update_elasticsearch_domain_config(
+        self, context: RequestContext, payload: UpdateElasticsearchDomainConfigRequest
+    ) -> UpdateElasticsearchDomainConfigResponse:
+        opensearch_client = aws_stack.connect_to_service("opensearch", region_name=context.region)
+
+        payload: Dict
+        if "ElasticsearchClusterConfig" in payload:
+            payload["ClusterConfig"] = payload["ElasticsearchClusterConfig"]
+            payload["ClusterConfig"]["InstanceType"] = _instancetype_to_opensearch(
+                payload["ClusterConfig"]["InstanceType"]
+            )
+            payload.pop("ElasticsearchClusterConfig")
+
+        with exception_mapper():
+            opensearch_config = opensearch_client.update_domain_config(**payload)["DomainConfig"]
+
+        config = _domainconfig_from_opensearch(opensearch_config)
+        return UpdateElasticsearchDomainConfigResponse(DomainConfig=config)
+
     def describe_elasticsearch_domains(
         self, context: RequestContext, domain_names: DomainNameList
     ) -> DescribeElasticsearchDomainsResponse:
diff --git a/localstack/services/opensearch/provider.py b/localstack/services/opensearch/provider.py
--- a/localstack/services/opensearch/provider.py
+++ b/localstack/services/opensearch/provider.py
@@ -4,7 +4,7 @@
 from random import randint
 from typing import Dict, Optional
 
-from localstack.aws.api import RequestContext
+from localstack.aws.api import RequestContext, handler
 from localstack.aws.api.opensearch import (
     ARN,
     AccessPoliciesStatus,
@@ -67,6 +67,8 @@
     StringList,
     TagList,
     TLSSecurityPolicy,
+    UpdateDomainConfigRequest,
+    UpdateDomainConfigResponse,
     ValidationException,
     VersionStatus,
     VersionString,
@@ -83,8 +85,9 @@
     create_cluster_manager,
 )
 from localstack.utils.analytics import event_publisher
-from localstack.utils.common import synchronized
+from localstack.utils.collections import PaginatedList, remove_none_values_from_dict
 from localstack.utils.serving import Server
+from localstack.utils.sync import synchronized
 from localstack.utils.tagging import TaggingService
 
 LOG = logging.getLogger(__name__)
@@ -182,6 +185,10 @@ def __init__(self):
 
 def get_domain_config(domain_key) -> DomainConfig:
     status = get_domain_status(domain_key)
+    return _status_to_config(status)
+
+
+def _status_to_config(status: DomainStatus) -> DomainConfig:
     cluster_cfg = status.get("ClusterConfig") or {}
     default_cfg = DEFAULT_OPENSEARCH_CLUSTER_CONFIG
     config_status = get_domain_config_status()
@@ -368,6 +375,13 @@ def _ensure_domain_exists(arn: ARN) -> None:
         raise ValidationException("Invalid ARN. Domain not found.")
 
 
+def _update_domain_config_request_to_status(request: UpdateDomainConfigRequest) -> DomainStatus:
+    request: Dict
+    request.pop("DryRun", None)
+    request.pop("DomainName", None)
+    return request
+
+
 class OpensearchProvider(OpensearchApi):
     def create_domain(
         self,
@@ -462,6 +476,26 @@ def describe_domain(
             status = get_domain_status(domain_key)
         return DescribeDomainResponse(DomainStatus=status)
 
+    @handler("UpdateDomainConfig", expand=False)
+    def update_domain_config(
+        self, context: RequestContext, payload: UpdateDomainConfigRequest
+    ) -> UpdateDomainConfigResponse:
+        domain_key = DomainKey(
+            domain_name=payload["DomainName"],
+            region=context.region,
+            account=context.account_id,
+        )
+        region = OpenSearchServiceBackend.get(domain_key.region)
+        with _domain_mutex:
+            domain_status = region.opensearch_domains.get(domain_key.domain_name, None)
+            if domain_status is None:
+                raise ResourceNotFoundException(f"Domain not found: {domain_key.domain_name}")
+
+            status_update: Dict = _update_domain_config_request_to_status(payload)
+            domain_status.update(status_update)
+
+        return UpdateDomainConfigResponse(DomainConfig=_status_to_config(domain_status))
+
     def describe_domains(
         self, context: RequestContext, domain_names: DomainNameList
     ) -> DescribeDomainsResponse:
@@ -498,7 +532,14 @@ def list_versions(
         max_results: MaxResults = None,
         next_token: NextToken = None,
     ) -> ListVersionsResponse:
-        return ListVersionsResponse(Versions=list(versions.install_versions.keys()))
+        version_list = PaginatedList(versions.install_versions.keys())
+        page, nxt = version_list.get_page(
+            lambda x: x,
+            next_token=next_token,
+            page_size=max_results,
+        )
+        response = ListVersionsResponse(Versions=page, NextToken=nxt)
+        return remove_none_values_from_dict(response)
 
     def get_compatible_versions(
         self, context: RequestContext, domain_name: DomainName = None


Test patch:
diff --git a/tests/integration/test_es.py b/tests/integration/test_es.py
--- a/tests/integration/test_es.py
+++ b/tests/integration/test_es.py
@@ -152,3 +152,34 @@ def test_path_endpoint_strategy(self, monkeypatch, opensearch_create_domain, es_
         assert "Endpoint" in status
         endpoint = status["Endpoint"]
         assert endpoint.endswith(f"/{domain_name}")
+
+    def test_update_domain_config(self, es_client, opensearch_domain):
+        initial_response = es_client.describe_elasticsearch_domain_config(
+            DomainName=opensearch_domain
+        )
+        update_response = es_client.update_elasticsearch_domain_config(
+            DomainName=opensearch_domain,
+            ElasticsearchClusterConfig={"InstanceType": "r4.16xlarge.elasticsearch"},
+        )
+        final_response = es_client.describe_elasticsearch_domain_config(
+            DomainName=opensearch_domain
+        )
+
+        assert (
+            initial_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"][
+                "InstanceType"
+            ]
+            != update_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"][
+                "InstanceType"
+            ]
+        )
+        assert (
+            update_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"]["InstanceType"]
+            == "r4.16xlarge.elasticsearch"
+        )
+        assert (
+            update_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"]["InstanceType"]
+            == final_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"][
+                "InstanceType"
+            ]
+        )
diff --git a/tests/integration/test_opensearch.py b/tests/integration/test_opensearch.py
--- a/tests/integration/test_opensearch.py
+++ b/tests/integration/test_opensearch.py
@@ -306,6 +306,26 @@ def test_domain_version(self, opensearch_client, opensearch_domain, opensearch_c
         assert "EngineVersion" in status
         assert status["EngineVersion"] == "OpenSearch_1.0"
 
+    def test_update_domain_config(self, opensearch_client, opensearch_domain):
+        initial_response = opensearch_client.describe_domain_config(DomainName=opensearch_domain)
+        update_response = opensearch_client.update_domain_config(
+            DomainName=opensearch_domain, ClusterConfig={"InstanceType": "r4.16xlarge.search"}
+        )
+        final_response = opensearch_client.describe_domain_config(DomainName=opensearch_domain)
+
+        assert (
+            initial_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"]
+            != update_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"]
+        )
+        assert (
+            update_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"]
+            == "r4.16xlarge.search"
+        )
+        assert (
+            update_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"]
+            == final_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"]
+        )
+
     def test_create_indices(self, opensearch_endpoint):
         indices = ["index1", "index2"]
         for index_name in indices: