1
0
Эх сурвалжийг харах

Add Enterprise Search Module (#94381)

Create base module for ent-search with CRUD APIs to manage behavioral analytics and Search Application.

---------

Co-authored-by: Jim Ferenczi <jim.ferenczi@elastic.co>
Co-authored-by: Aurélien FOUCRET <aurelien.foucret@gmail.com>
Co-authored-by: Kathleen DeRusso <63422879+kderusso@users.noreply.github.com>
Co-authored-by: Ioana Tagirta <ioanatia@users.noreply.github.com>
Co-authored-by: Joseph McElroy <joseph.mcelroy@elastic.co>
Co-authored-by: Aurelien FOUCRET <aurelien.foucret@elastic.co>
Co-authored-by: Benjamin Trent <ben.w.trent@gmail.com>
Carlos Delgado 2 жил өмнө
parent
commit
7a782031c4
100 өөрчлөгдсөн 7665 нэмэгдсэн , 1 устгасан
  1. 5 0
      docs/changelog/94381.yaml
  2. 45 0
      docs/reference/behavioral-analytics/apis/delete-analytics-collection.asciidoc
  3. 19 0
      docs/reference/behavioral-analytics/apis/index.asciidoc
  4. 111 0
      docs/reference/behavioral-analytics/apis/list-analytics-collection.asciidoc
  5. 43 0
      docs/reference/behavioral-analytics/apis/put-analytics-collection.asciidoc
  6. 6 0
      docs/reference/migration/apis/feature-migration.asciidoc
  7. 4 0
      docs/reference/rest-api/index.asciidoc
  8. 49 0
      docs/reference/search-application/apis/delete-search-application.asciidoc
  9. 58 0
      docs/reference/search-application/apis/get-search-application.asciidoc
  10. 24 0
      docs/reference/search-application/apis/index.asciidoc
  11. 74 0
      docs/reference/search-application/apis/list-search-applications.asciidoc
  12. 66 0
      docs/reference/search-application/apis/put-search-application.asciidoc
  13. 35 0
      rest-api-spec/src/main/resources/rest-api-spec/api/behavioral_analytics.delete.json
  14. 41 0
      rest-api-spec/src/main/resources/rest-api-spec/api/behavioral_analytics.list.json
  15. 35 0
      rest-api-spec/src/main/resources/rest-api-spec/api/behavioral_analytics.put.json
  16. 35 0
      rest-api-spec/src/main/resources/rest-api-spec/api/search_application.delete.json
  17. 35 0
      rest-api-spec/src/main/resources/rest-api-spec/api/search_application.get.json
  18. 43 0
      rest-api-spec/src/main/resources/rest-api-spec/api/search_application.list.json
  19. 45 0
      rest-api-spec/src/main/resources/rest-api-spec/api/search_application.put.json
  20. 5 1
      test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java
  21. 2 0
      x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc
  22. 18 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java
  23. 1 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ClientHelper.java
  24. 2 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackField.java
  25. 8 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java
  26. 14 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java
  27. 33 0
      x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-default_policy.json
  28. 128 0
      x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-mappings.json
  29. 21 0
      x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-settings.json
  30. 14 0
      x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-template.json
  31. 27 0
      x-pack/plugin/ent-search/build.gradle
  32. 0 0
      x-pack/plugin/ent-search/qa/build.gradle
  33. 21 0
      x-pack/plugin/ent-search/qa/rest/build.gradle
  34. 26 0
      x-pack/plugin/ent-search/qa/rest/roles.yml
  35. 43 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/java/org/elasticsearch/xpack/entsearch/EnterpriseSearchRestIT.java
  36. 13 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/10_basic.yml
  37. 21 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/15_search_application_before_setup.yml
  38. 126 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/20_search_application_put.yml
  39. 51 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/30_search_application_get.yml
  40. 58 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/40_search_application_delete.yml
  41. 161 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/50_search_application_list.yml
  42. 58 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/60_behavioral_analytics_list.yml
  43. 30 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/70_behavioral_analytics_put.yml
  44. 32 0
      x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/80_behavioral_analytics_delete.yml
  45. 22 0
      x-pack/plugin/ent-search/src/main/java/module-info.java
  46. 179 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java
  47. 147 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollection.java
  48. 118 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionResolver.java
  49. 148 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionService.java
  50. 137 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsTemplateRegistry.java
  51. 79 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/DeleteAnalyticsCollectionAction.java
  52. 131 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/GetAnalyticsCollectionAction.java
  53. 131 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/PutAnalyticsCollectionAction.java
  54. 38 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestDeleteAnalyticsCollectionAction.java
  55. 43 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestGetAnalyticsCollectionAction.java
  56. 43 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestPutAnalyticsCollectionAction.java
  57. 76 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/TransportDeleteAnalyticsCollectionAction.java
  58. 77 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/TransportGetAnalyticsCollectionAction.java
  59. 78 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/TransportPutAnalyticsCollectionAction.java
  60. 260 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/SearchApplication.java
  61. 503 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/SearchApplicationIndexService.java
  62. 139 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/SearchApplicationListItem.java
  63. 78 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/DeleteSearchApplicationAction.java
  64. 121 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/GetSearchApplicationAction.java
  65. 139 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/ListSearchApplicationAction.java
  66. 149 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/PutSearchApplicationAction.java
  67. 37 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestDeleteSearchApplicationAction.java
  68. 37 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestGetSearchApplicationAction.java
  69. 45 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestListSearchApplicationAction.java
  70. 49 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestPutSearchApplicationAction.java
  71. 56 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/SearchApplicationTransportAction.java
  72. 53 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportDeleteSearchApplicationAction.java
  73. 52 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportGetSearchApplicationAction.java
  74. 58 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportListSearchApplicationAction.java
  75. 54 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportPutSearchApplicationAction.java
  76. 49 0
      x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/utils/LicenseUtils.java
  77. 172 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionResolverTests.java
  78. 471 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionServiceTests.java
  79. 95 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionTests.java
  80. 495 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsTemplateRegistryTests.java
  81. 32 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/DeleteAnalyticsCollectionRequestSerializingTests.java
  82. 31 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/GetAnalyticsCollectionRequestSerializingTests.java
  83. 34 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/GetAnalyticsCollectionResponseSerializingTests.java
  84. 31 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/PutAnalyticsCollectionRequestSerializingTests.java
  85. 31 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/PutAnalyticsCollectionResponseSerializingTests.java
  86. 110 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/TransportDeleteAnalyticsCollectionActionTests.java
  87. 106 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/TransportGetAnalyticsCollectionActionTests.java
  88. 106 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/TransportPutAnalyticsCollectionActionTests.java
  89. 365 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/SearchApplicationIndexServiceTests.java
  90. 40 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/SearchApplicationTestUtils.java
  91. 156 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/SearchApplicationTests.java
  92. 30 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/DeleteSearchApplicationActionRequestSerializingTests.java
  93. 29 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/GetSearchApplicationActionRequestSerializingTests.java
  94. 31 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/GetSearchApplicationActionResponseSerializingTests.java
  95. 35 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/ListSearchApplicationActionRequestSerializingTests.java
  96. 40 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/ListSearchApplicationActionResponseSerializingTests.java
  97. 30 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/PutSearchApplicationActionRequestSerializingTests.java
  98. 33 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/PutSearchApplicationActionResponseSerializingTests.java
  99. 75 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/TransportDeleteSearchApplicationActionTests.java
  100. 75 0
      x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/TransportGetSearchApplicationActionTests.java

+ 5 - 0
docs/changelog/94381.yaml

@@ -0,0 +1,5 @@
+pr: 94381
+summary: Add Enterprise Search Module
+area: Search
+type: feature
+issues: []

+ 45 - 0
docs/reference/behavioral-analytics/apis/delete-analytics-collection.asciidoc

@@ -0,0 +1,45 @@
+[role="xpack"]
+[[delete-analytics-collection]]
+=== Delete Analytics Collection
+
+++++
+<titleabbrev>Delete Analytics Collection</titleabbrev>
+++++
+
+Removes an Analytics Collection and its associated data stream.
+
+[[delete-analytics-collection-request]]
+==== {api-request-title}
+
+`DELETE _application/analytics/<name>`
+
+[[delete-analytics-collection-prereq]]
+==== {api-prereq-title}
+
+Requires the `manage_behavioral_analytics` cluster privilege.
+
+[[delete-analytics-collection-path-params]]
+==== {api-path-parms-title}
+
+`<name>`::
+(Required, string)
+
+[[delete-analytics-collection-response-codes]]
+==== {api-response-codes-title}
+
+`400`::
+The `name` was not provided.
+
+`404` (Missing resources)::
+No Analytics Collection matching `name` could be found.
+
+[[delete-analytics-collection-example]]
+==== {api-examples-title}
+
+The following example deletes the Analytics Collection named `my_analytics_collection`:
+
+[source,console]
+----
+DELETE _application/analytics/my_analytics_collection/
+----
+// TEST[skip:TBD]

+ 19 - 0
docs/reference/behavioral-analytics/apis/index.asciidoc

@@ -0,0 +1,19 @@
+[[behavioral-analytics-apis]]
+== Behavioral Analytics APIs
+
+++++
+<titleabbrev>Behavioral Analytics APIs</titleabbrev>
+++++
+
+---
+
+Use the following APIs to manage tasks and resources related to Behavioral Analytics:
+
+* <<put-analytics-collection>>
+* <<delete-analytics-collection>>
+* <<list-analytics-collection>>
+
+
+include::put-analytics-collection.asciidoc[]
+include::delete-analytics-collection.asciidoc[]
+include::list-analytics-collection.asciidoc[]

+ 111 - 0
docs/reference/behavioral-analytics/apis/list-analytics-collection.asciidoc

@@ -0,0 +1,111 @@
+[role="xpack"]
+[[list-analytics-collection]]
+=== List Analytics Collections
+
+++++
+<titleabbrev>List Analytics Collections</titleabbrev>
+++++
+
+Returns information about Analytics Collections.
+
+[[list-analytics-collection-request]]
+==== {api-request-title}
+
+`GET _application/analytics/<criteria>`
+
+[[list-analytics-collection-prereq]]
+==== {api-prereq-title}
+
+Requires the `manage_behavioral_analytics` cluster privilege.
+
+[[list-analytics-collection-path-params]]
+==== {api-path-parms-title}
+
+`<criteria>`::
+(optional, string)
+Criteria is used to find a matching analytics collection. This could be the name of the collection or a pattern to match multiple. If not specified, will return all analytics collections.
+
+[[list-analytics-collection-response-codes]]
+==== {api-response-codes-title}
+
+`404`::
+Criteria does not match any Analytics Collections.
+
+==== {api-response-codes-title}
+
+[[list-analytics-collection-example]]
+==== {api-examples-title}
+
+The following example lists all configured Analytics Collections:
+
+[source,console]
+----
+GET _application/analytics/
+----
+// TEST[skip:TBD]
+
+A sample response:
+
+[source,console-result]
+----
+{
+  "my_analytics_collection": {
+      "event_data_stream": {
+          "name": "behavioral_analytics-events-my_analytics_collection"
+      }
+  },
+  "my_analytics_collection2": {
+      "event_data_stream": {
+          "name": "behavioral_analytics-events-my_analytics_collection2"
+      }
+  }
+}
+----
+
+The following example returns the Analytics Collection that matches `my_analytics_collection`:
+
+[source,console]
+----
+GET _application/analytics/my_analytics_collection
+----
+// TEST[skip:TBD]
+
+A sample response:
+
+[source,console-result]
+----
+{
+  "my_analytics_collection": {
+      "event_data_stream": {
+          "name": "behavioral_analytics-events-my_analytics_collection"
+      }
+  }
+}
+----
+
+The following example returns all Analytics Collections prefixed with `my`:
+
+[source,console]
+----
+GET _application/analytics/my*
+----
+// TEST[skip:TBD]
+
+A sample response:
+
+[source,console-result]
+----
+{
+  "my_analytics_collection": {
+      "event_data_stream": {
+          "name": "behavioral_analytics-events-my_analytics_collection"
+      }
+  },
+  "my_analytics_collection2": {
+      "event_data_stream": {
+          "name": "behavioral_analytics-events-my_analytics_collection2"
+      }
+  }
+}
+----
+// TEST[skip:TBD]

+ 43 - 0
docs/reference/behavioral-analytics/apis/put-analytics-collection.asciidoc

@@ -0,0 +1,43 @@
+[role="xpack"]
+[[put-analytics-collection]]
+=== Put Analytics Collection
+
+++++
+<titleabbrev>Put Analytics Collection</titleabbrev>
+++++
+
+Creates an Analytics Collection.
+
+[[put-analytics-collection-request]]
+==== {api-request-title}
+
+`PUT _application/analytics/<name>`
+
+[[put-analytics-collection-path-params]]
+==== {api-path-parms-title}
+
+`<name>`::
+(Required, string)
+
+[[put-analytics-collection-prereqs]]
+==== {api-prereq-title}
+
+Requires the `manage_behavioral_analytics` cluster privilege.
+
+[[put-analytics-collection-response-codes]]
+==== {api-response-codes-title}
+
+`400`::
+Analytics Collection `<name>` exists.
+
+[[put-analytics-collection-example]]
+==== {api-examples-title}
+
+The following example creates a new Analytics Collection called `my_analytics_collection`:
+
+[source,console]
+----
+PUT _application/analytics/my_analytics_collection
+
+----
+// TEST[skip:TBD]

+ 6 - 0
docs/reference/migration/apis/feature-migration.asciidoc

@@ -66,6 +66,12 @@ Example response:
       "migration_status" : "NO_MIGRATION_NEEDED",
       "indices" : [ ]
     },
+    {
+      "feature_name" : "ent_search",
+      "minimum_index_version" : "8.0.0",
+      "migration_status" : "NO_MIGRATION_NEEDED",
+      "indices" : [ ]
+    },
     {
       "feature_name" : "fleet",
       "minimum_index_version" : "8.0.0",

+ 4 - 0
docs/reference/rest-api/index.asciidoc

@@ -14,6 +14,7 @@ not be included yet.
 * <<common-options, Common Options>>
 * <<rest-api-compatibility, REST API Compatibility>>
 * <<autoscaling-apis, Autoscaling APIs>>
+* <<behavioral-analytics-apis, Behavioral Analytics APIs>>
 * <<cat, cat APIs>>
 * <<cluster, Cluster APIs>>
 * <<features-apis,Features APIs>>
@@ -43,6 +44,7 @@ not be included yet.
 * <<rollup-apis,Rollup APIs>>
 * <<script-apis,Script APIs>>
 * <<search, Search APIs>>
+* <<search-application-apis, Search Application APIs>>
 * <<searchable-snapshots-apis, Searchable snapshots APIs>>
 * <<security-api,Security APIs>>
 * <<snapshot-restore-apis,Snapshot and restore APIs>>
@@ -57,6 +59,7 @@ include::{es-repo-dir}/api-conventions.asciidoc[]
 include::{es-repo-dir}/rest-api/common-options.asciidoc[]
 include::{es-repo-dir}/rest-api/rest-api-compatibility.asciidoc[]
 include::{es-repo-dir}/autoscaling/apis/autoscaling-apis.asciidoc[]
+include::{es-repo-dir}/behavioral-analytics/apis/index.asciidoc[]
 include::{es-repo-dir}/cat.asciidoc[]
 include::{es-repo-dir}/cluster.asciidoc[]
 include::{es-repo-dir}/ccr/apis/ccr-apis.asciidoc[]
@@ -85,6 +88,7 @@ include::{es-repo-dir}/repositories-metering-api/repositories-metering-apis.asci
 include::{es-repo-dir}/rollup/rollup-apis.asciidoc[]
 include::{es-repo-dir}/scripting/apis/script-apis.asciidoc[]
 include::{es-repo-dir}/search.asciidoc[]
+include::{es-repo-dir}/search-application/apis/index.asciidoc[]
 include::{es-repo-dir}/searchable-snapshots/apis/searchable-snapshots-apis.asciidoc[]
 include::{xes-repo-dir}/rest-api/security.asciidoc[]
 include::{es-repo-dir}/snapshot-restore/apis/snapshot-restore-apis.asciidoc[]

+ 49 - 0
docs/reference/search-application/apis/delete-search-application.asciidoc

@@ -0,0 +1,49 @@
+[role="xpack"]
+[[delete-search-application]]
+=== Delete Search Application
+
+beta::[]
+
+++++
+<titleabbrev>Delete Search Application</titleabbrev>
+++++
+
+Removes a Search Application and its associated alias.
+Indices attached to the Search Application are not removed.
+
+[[delete-search-application-request]]
+==== {api-request-title}
+
+`DELETE _application/search_application/<name>`
+
+[[delete-search-application-prereq]]
+==== {api-prereq-title}
+
+Requires the `manage_search_application` cluster privilege.
+Also requires <<privileges-list-indices,manage privileges>> on all indices that are included in the Search Application.
+
+[[delete-search-application-path-params]]
+==== {api-path-parms-title}
+
+`<name>`::
+(Required, string)
+
+[[delete-search-application-response-codes]]
+==== {api-response-codes-title}
+
+`400`::
+The `name` was not provided.
+
+`404` (Missing resources)::
+No Search Application matching `name` could be found.
+
+[[delete-search-application-example]]
+==== {api-examples-title}
+
+The following example deletes the Search Application named `my-app`:
+
+[source,console]
+----
+DELETE _application/search_application/my-app/
+----
+// TEST[skip:TBD]

+ 58 - 0
docs/reference/search-application/apis/get-search-application.asciidoc

@@ -0,0 +1,58 @@
+[role="xpack"]
+[[get-search-application]]
+=== Get Search Application
+
+beta::[]
+
+++++
+<titleabbrev>Get Search Application</titleabbrev>
+++++
+
+Retrieves information about a Search Application.
+
+[[get-search-application-request]]
+==== {api-request-title}
+
+`GET _application/search_application/<name>`
+
+[[get-search-application-prereq]]
+==== {api-prereq-title}
+
+Requires the `manage_search_application` cluster privilege.
+
+[[get-search-application-path-params]]
+==== {api-path-parms-title}
+
+`<name>`::
+(Required, string)
+
+[[get-search-application-response-codes]]
+==== {api-response-codes-title}
+
+`400`::
+The `name` was not provided.
+
+`404` (Missing resources)::
+No Search Application matching `name` could be found.
+
+[[get-search-application-example]]
+==== {api-examples-title}
+
+The following example gets the Search Application named `my-app`:
+
+[source,console]
+----
+GET _application/search_application/my-app/
+----
+// TEST[skip:TBD]
+
+A sample response:
+
+[source,console-result]
+----
+{
+    "name": "my-app",
+    "indices": [ "index1", "index2" ],
+    "analytics_collection_name": "my-analytics"
+}
+----

+ 24 - 0
docs/reference/search-application/apis/index.asciidoc

@@ -0,0 +1,24 @@
+[[search-application-apis]]
+== Search Application APIs
+
+beta::[]
+
+++++
+<titleabbrev>Search Application APIs</titleabbrev>
+++++
+
+---
+
+Use Search Application APIs to manage tasks and resources related to Search Applications.
+
+* <<put-search-application>>
+* <<get-search-application>>
+* <<list-search-applications>>
+* <<delete-search-application>>
+
+
+include::put-search-application.asciidoc[]
+include::get-search-application.asciidoc[]
+include::list-search-applications.asciidoc[]
+include::delete-search-application.asciidoc[]
+

+ 74 - 0
docs/reference/search-application/apis/list-search-applications.asciidoc

@@ -0,0 +1,74 @@
+[role="xpack"]
+[[list-search-applications]]
+=== List Search Applications
+
+beta::[]
+
+++++
+<titleabbrev>List Search Applications</titleabbrev>
+++++
+
+Returns information about Search Applications.
+
+[[list-search-applications-request]]
+==== {api-request-title}
+
+`GET _application/search_application/`
+
+[[list-search-applications-prereq]]
+==== {api-prereq-title}
+
+Requires the `manage_search_application` cluster privilege.
+
+[[list-search-applications-path-params]]
+==== {api-path-parms-title}
+
+`q`::
+(Optional, string) Query in the Lucene query string syntax, to return only Search Applications matching the query.
+
+`size`::
+(Optional, integer) Maximum number of results to retrieve.
+
+`from`::
+(Optional, integer) The offset from the first result to fetch.
+
+[[list-search-applications-response-codes]]
+==== {api-response-codes-title}
+
+[[list-search-applications-example]]
+==== {api-examples-title}
+
+The following example lists all configured Search Applications:
+
+[source,console]
+----
+GET _application/search_application/
+----
+// TEST[skip:TBD]
+
+The following example lists the first three Search Applications whose names match the query string `app`:
+
+[source,console]
+----
+GET _application/search_application/?from=0&size=3&q=app
+----
+// TEST[skip:TBD]
+
+A sample response:
+
+[source,console-result]
+----
+{
+  "count": 2,
+  "results": [
+    {
+      "name": "app-1",
+      "indices": [ "index1", "index2" ]
+    },
+    {
+      "name": "app-2",
+      "indices": [ "index3", "index4" ]
+    }
+  ]
+}
+----

+ 66 - 0
docs/reference/search-application/apis/put-search-application.asciidoc

@@ -0,0 +1,66 @@
+[role="xpack"]
+[[put-search-application]]
+=== Put Search Application
+
+beta::[]
+
+++++
+<titleabbrev>Put Search Application</titleabbrev>
+++++
+
+Creates or updates a Search Application.
+
+[[put-search-application-request]]
+==== {api-request-title}
+
+`PUT _application/search_application/<name>`
+
+[[put-search-application-prereqs]]
+==== {api-prereq-title}
+
+Requires the `manage_search_application` cluster privilege.
+Also requires <<privileges-list-indices,manage privileges>> on all indices that are added to the Search Application.
+
+[[put-search-application-path-params]]
+==== {api-path-parms-title}
+
+`create`::
+(Optional, Boolean) If `true`, this request cannot replace or update existing Search Applications.
+Defaults to `false`.
+
+[[put-search-application-response-codes]]
+==== {api-response-codes-title}
+
+`404`::
+Search Application `<name>` does not exist.
+
+`409`::
+Search Application `<name>` exists and `create` is `true`.
+
+[[put-search-application-example]]
+==== {api-examples-title}
+
+The following example creates a new Search Application called `my-app`:
+
+[source,console]
+----
+PUT _application/search_application/my-app?create
+{
+  "indices": [ "index1", "index2" ],
+  "analytics_collection_name": "my-analytics-collection"
+}
+----
+// TEST[skip:TBD]
+
+The following example creates or updates an existing Search Application called `my_app`:
+
+[source,console]
+----
+PUT _application/search_application/my-app
+{
+  "indices": [ "index1", "index2", "index3" ],
+  "analytics_collection_name": "my-analytics-collection"
+}
+----
+// TEST[skip:TBD]
+

+ 35 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/behavioral_analytics.delete.json

@@ -0,0 +1,35 @@
+{
+  "behavioral_analytics.delete": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Delete a behavioral analytics collection."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/analytics/{name}",
+          "methods": [
+            "DELETE"
+          ],
+          "parts": {
+            "name": {
+              "type": "string",
+              "description": "The name of the analytics collection to be deleted"
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 41 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/behavioral_analytics.list.json

@@ -0,0 +1,41 @@
+{
+  "behavioral_analytics.list": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Returns the existing behavioral analytics collections."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/analytics",
+          "methods": [
+            "GET"
+          ]
+        },
+        {
+          "path": "/_application/analytics/{name}",
+          "methods": [
+            "GET"
+          ],
+          "parts":{
+            "name":{
+              "type":"list",
+              "description":"A comma-separated list of analytics collections to limit the returned information"
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 35 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/behavioral_analytics.put.json

@@ -0,0 +1,35 @@
+{
+  "behavioral_analytics.put": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Creates a behavioral analytics collection."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/analytics/{name}",
+          "methods": [
+            "PUT"
+          ],
+          "parts": {
+            "name": {
+              "type": "string",
+              "description": "The name of the analytics collection to be created or updated"
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 35 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/search_application.delete.json

@@ -0,0 +1,35 @@
+{
+  "search_application.delete": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Deletes a search application."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/search_application/{name}",
+          "methods": [
+            "DELETE"
+          ],
+          "parts": {
+            "name": {
+              "type": "string",
+              "description": "The name of the search application"
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 35 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/search_application.get.json

@@ -0,0 +1,35 @@
+{
+  "search_application.get": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Returns the details about a search application."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/search_application/{name}",
+          "methods": [
+            "GET"
+          ],
+          "parts": {
+            "name": {
+              "type": "string",
+              "description": "The name of the search application"
+            }
+          }
+        }
+      ]
+    }
+  }
+}

+ 43 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/search_application.list.json

@@ -0,0 +1,43 @@
+{
+  "search_application.list": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Returns the existing search applications."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/search_application",
+          "methods": [
+            "GET"
+          ]
+        }
+      ]
+    },
+    "params": {
+      "q": {
+        "type": "string",
+        "description": "Query in the Lucene query string syntax"
+      },
+      "from": {
+        "type": "int",
+        "description": "Starting offset (default: 0)"
+      },
+      "size": {
+        "type": "int",
+        "description": "specifies a max number of results to get"
+      }
+    }
+  }
+}

+ 45 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/search_application.put.json

@@ -0,0 +1,45 @@
+{
+  "search_application.put": {
+    "documentation": {
+      "url": "http://todo.com/tbd",
+      "description": "Creates or updates a search application."
+    },
+    "stability": "experimental",
+    "visibility": "feature_flag",
+    "feature_flag": "xpack.ent-search.enabled",
+    "headers": {
+      "accept": [
+        "application/json"
+      ],
+      "content_type": [
+        "application/json"
+      ]
+    },
+    "url": {
+      "paths": [
+        {
+          "path": "/_application/search_application/{name}",
+          "methods": [
+            "PUT"
+          ],
+          "parts": {
+            "name": {
+              "type": "string",
+              "description": "The name of the search application to be created or updated"
+            }
+          }
+        }
+      ]
+    },
+    "params": {
+      "create": {
+        "type": "boolean",
+        "description": "If true, requires that a search application with the specified resource_id does not already exist. (default: false)"
+      }
+    },
+    "body": {
+      "description": "The search application configuration, including `indices`",
+      "required": true
+    }
+  }
+}

+ 5 - 1
test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java

@@ -631,7 +631,8 @@ public abstract class ESRestTestCase extends ESTestCase {
             ".fleet-file-data-ilm-policy",
             ".fleet-files-ilm-policy",
             ".deprecation-indexing-ilm-policy",
-            ".monitoring-8-ilm-policy"
+            ".monitoring-8-ilm-policy",
+            "behavioral_analytics-events-default_policy"
         );
     }
 
@@ -1805,6 +1806,9 @@ public abstract class ESRestTestCase extends ESTestCase {
         if (name.startsWith(".fleet-")) {
             return true;
         }
+        if (name.startsWith("behavioral_analytics-")) {
+            return true;
+        }
         switch (name) {
             case ".watches":
             case "security_audit_log":

+ 2 - 0
x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc

@@ -70,6 +70,7 @@ A successful call returns an object with "cluster" and "index" fields.
     "manage",
     "manage_api_key",
     "manage_autoscaling",
+    "manage_behavioral_analytics",
     "manage_ccr",
     "manage_data_frame_transforms",
     "manage_enrich",
@@ -83,6 +84,7 @@ A successful call returns an object with "cluster" and "index" fields.
     "manage_pipeline",
     "manage_rollup",
     "manage_saml",
+    "manage_search_application",
     "manage_security",
     "manage_service_account",
     "manage_slm",

+ 18 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java

@@ -57,6 +57,7 @@ public class XPackLicenseState {
         messages.put(XPackField.DEPRECATION, new String[] { "Deprecation APIs are disabled" });
         messages.put(XPackField.UPGRADE, new String[] { "Upgrade API is disabled" });
         messages.put(XPackField.SQL, new String[] { "SQL support is disabled" });
+        messages.put(XPackField.ENTERPRISE_SEARCH, new String[] { "Search Applications and behavioral analytics will be disabled" });
         messages.put(
             XPackField.ROLLUP,
             new String[] {
@@ -98,6 +99,7 @@ public class XPackLicenseState {
         messages.put(XPackField.BEATS, XPackLicenseState::beatsAcknowledgementMessages);
         messages.put(XPackField.SQL, XPackLicenseState::sqlAcknowledgementMessages);
         messages.put(XPackField.CCR, XPackLicenseState::ccrAcknowledgementMessages);
+        messages.put(XPackField.ENTERPRISE_SEARCH, XPackLicenseState::enterpriseSearchAcknowledgementMessages);
         ACKNOWLEDGMENT_MESSAGES = Collections.unmodifiableMap(messages);
     }
 
@@ -211,6 +213,22 @@ public class XPackLicenseState {
         return Strings.EMPTY_ARRAY;
     }
 
+    private static String[] enterpriseSearchAcknowledgementMessages(OperationMode currentMode, OperationMode newMode) {
+        switch (newMode) {
+            case BASIC:
+            case STANDARD:
+            case GOLD:
+                switch (currentMode) {
+                    case TRIAL:
+                    case PLATINUM:
+                    case ENTERPRISE:
+                        return new String[] { "Search Applications and behavioral analytics will be disabled" };
+                }
+                break;
+        }
+        return Strings.EMPTY_ARRAY;
+    }
+
     private static String[] machineLearningAcknowledgementMessages(OperationMode currentMode, OperationMode newMode) {
         switch (newMode) {
             case BASIC:

+ 1 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ClientHelper.java

@@ -190,6 +190,7 @@ public final class ClientHelper {
     public static final String SEARCHABLE_SNAPSHOTS_ORIGIN = "searchable_snapshots";
     public static final String LOGSTASH_MANAGEMENT_ORIGIN = "logstash_management";
     public static final String FLEET_ORIGIN = "fleet";
+    public static final String ENT_SEARCH_ORIGIN = "enterprise_search";
 
     private ClientHelper() {}
 

+ 2 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackField.java

@@ -75,6 +75,8 @@ public final class XPackField {
     public static final String ARCHIVE = "archive";
     /** Name constant for the health api feature. */
     public static final String HEALTH_API = "health_api";
+    /** Name for Enterprise Search. */
+    public static final String ENTERPRISE_SEARCH = "enterprise_search";
     public static final String REMOTE_CLUSTERS = "remote_clusters";
 
     private XPackField() {}

+ 8 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/XPackSettings.java

@@ -93,6 +93,13 @@ public class XPackSettings {
         Setting.Property.NodeScope
     );
 
+    /** Setting for enabling or disabling enterprise search. Defaults to true. */
+    public static final Setting<Boolean> ENTERPRISE_SEARCH_ENABLED = Setting.boolSetting(
+        "xpack.ent-search.enabled",
+        true,
+        Setting.Property.NodeScope
+    );
+
     /** Setting for enabling or disabling auditing. Defaults to false. */
     public static final Setting<Boolean> AUDIT_ENABLED = Setting.boolSetting(
         "xpack.security.audit.enabled",
@@ -311,6 +318,7 @@ public class XPackSettings {
         settings.add(SECURITY_ENABLED);
         settings.add(GRAPH_ENABLED);
         settings.add(MACHINE_LEARNING_ENABLED);
+        settings.add(ENTERPRISE_SEARCH_ENABLED);
         settings.add(AUDIT_ENABLED);
         settings.add(WATCHER_ENABLED);
         settings.add(DLS_FLS_ENABLED);

+ 14 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java

@@ -82,6 +82,7 @@ public class ClusterPrivilegeResolver {
     private static final Set<String> MANAGE_OIDC_PATTERN = Set.of("cluster:admin/xpack/security/oidc/*");
     private static final Set<String> MANAGE_TOKEN_PATTERN = Set.of("cluster:admin/xpack/security/token/*");
     private static final Set<String> MANAGE_API_KEY_PATTERN = Set.of("cluster:admin/xpack/security/api_key/*");
+    private static final Set<String> MANAGE_BEHAVIORAL_ANALYTICS_PATTERN = Set.of("cluster:admin/xpack/application/analytics/*");
     private static final Set<String> MANAGE_SERVICE_ACCOUNT_PATTERN = Set.of("cluster:admin/xpack/security/service_account/*");
     private static final Set<String> MANAGE_USER_PROFILE_PATTERN = Set.of("cluster:admin/xpack/security/profile/*");
     private static final Set<String> GRANT_API_KEY_PATTERN = Set.of(GrantApiKeyAction.NAME + "*");
@@ -147,6 +148,9 @@ public class ClusterPrivilegeResolver {
         GetStatusAction.NAME
     );
     private static final Set<String> READ_SLM_PATTERN = Set.of(GetSnapshotLifecycleAction.NAME, GetStatusAction.NAME);
+
+    private static final Set<String> MANAGE_SEARCH_APPLICATION_PATTERN = Set.of("cluster:admin/xpack/application/search_application/*");
+
     private static final Set<String> CROSS_CLUSTER_ACCESS_PATTERN = Set.of(
         RemoteClusterService.REMOTE_CLUSTER_HANDSHAKE_ACTION_NAME,
         RemoteClusterNodesAction.NAME
@@ -258,6 +262,14 @@ public class ClusterPrivilegeResolver {
 
     public static final NamedClusterPrivilege CANCEL_TASK = new ActionClusterPrivilege("cancel_task", Set.of(CancelTasksAction.NAME + "*"));
 
+    public static final NamedClusterPrivilege MANAGE_SEARCH_APPLICATION = new ActionClusterPrivilege(
+        "manage_search_application",
+        MANAGE_SEARCH_APPLICATION_PATTERN
+    );
+    public static final NamedClusterPrivilege MANAGE_BEHAVIORAL_ANALYTICS = new ActionClusterPrivilege(
+        "manage_behavioral_analytics",
+        MANAGE_BEHAVIORAL_ANALYTICS_PATTERN
+    );
     public static final NamedClusterPrivilege CROSS_CLUSTER_ACCESS = new ActionClusterPrivilege(
         "cross_cluster_access",
         CROSS_CLUSTER_ACCESS_PATTERN
@@ -308,6 +320,8 @@ public class ClusterPrivilegeResolver {
             MANAGE_ENRICH,
             MANAGE_LOGSTASH_PIPELINES,
             CANCEL_TASK,
+            MANAGE_SEARCH_APPLICATION,
+            MANAGE_BEHAVIORAL_ANALYTICS,
             TcpTransport.isUntrustedRemoteClusterEnabled() ? CROSS_CLUSTER_ACCESS : null
         ).filter(Objects::nonNull).toList()
     );

+ 33 - 0
x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-default_policy.json

@@ -0,0 +1,33 @@
+{
+  "phases": {
+    "hot": {
+      "actions": {
+        "rollover": {
+          "max_age": "30d",
+          "max_size": "3GB"
+        }
+      }
+    },
+    "warm": {
+      "min_age": "2d",
+      "actions": {
+        "shrink": {
+          "number_of_shards": 1
+        },
+        "forcemerge": {
+          "max_num_segments": 1
+        }
+      }
+    },
+    "delete": {
+      "min_age": "180d",
+      "actions":{
+        "delete": {}
+      }
+    }
+  },
+  "_meta": {
+    "description": "Built-in policy applied by default to behavioral analytics event data streams.",
+    "managed": true
+  }
+}

+ 128 - 0
x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-mappings.json

@@ -0,0 +1,128 @@
+{
+  "template": {
+    "mappings": {
+      "dynamic": "strict",
+      "properties": {
+        "@timestamp": {
+          "type": "date"
+        },
+        "data_stream": {
+          "properties": {
+            "namespace": {
+              "type": "keyword"
+            },
+            "dataset": {
+              "type": "keyword"
+            },
+            "type": {
+              "type": "keyword"
+            }
+          }
+        },
+        "event": {
+          "properties": {
+            "action": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "dataset": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            }
+          }
+        },
+        "http": {
+          "properties": {
+            "request": {
+              "properties": {
+                "referrer": {
+                  "ignore_above": 1024,
+                  "type": "keyword"
+                }
+              }
+            }
+          }
+        },
+        "labels": {
+          "type": "object"
+        },
+        "url": {
+          "properties": {
+            "domain": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "extension": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "fragment": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "full": {
+              "fields": {
+                "text": {
+                  "norms": false,
+                  "type": "text"
+                }
+              },
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "original": {
+              "fields": {
+                "text": {
+                  "norms": false,
+                  "type": "text"
+                }
+              },
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "password": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "path": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "port": {
+              "type": "long"
+            },
+            "query": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "registered_domain": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "scheme": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "subdomain": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "top_level_domain": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            },
+            "username": {
+              "ignore_above": 1024,
+              "type": "keyword"
+            }
+          }
+        }
+      }
+    }
+  },
+  "_meta": {
+    "description": "Built-in mapping applied by default to behavioral analytics event data streams.",
+    "managed": true
+  },
+  "version": ${xpack.entsearch.analytics.template.version}
+}

+ 21 - 0
x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-settings.json

@@ -0,0 +1,21 @@
+{
+  "template": {
+    "settings": {
+      "index": {
+        "lifecycle": {
+          "name": "behavioral_analytics-events-default_policy"
+        },
+        "codec": "best_compression",
+        "refresh_interval": "1s",
+        "number_of_shards": 1,
+        "number_of_replicas": 0,
+        "auto_expand_replicas": "0-1"
+      }
+    }
+  },
+  "_meta": {
+    "description": "Built-in settings applied by default to behavioral analytics event data streams.",
+    "managed": true
+  },
+  "version": ${xpack.entsearch.analytics.template.version}
+}

+ 14 - 0
x-pack/plugin/core/src/main/resources/org/elasticsearch/xpack/entsearch/analytics/behavioral_analytics-events-template.json

@@ -0,0 +1,14 @@
+{
+  "index_patterns": ["${event_data_stream.index_pattern}"],
+  "data_stream": {},
+  "priority": 100,
+  "composed_of": [
+    "behavioral_analytics-events-settings",
+    "behavioral_analytics-events-mappings"
+  ],
+  "_meta": {
+    "description": "Built-in template applied by default to behavioral analytics event data streams.",
+    "managed": true
+  },
+  "version": ${xpack.entsearch.analytics.template.version}
+}

+ 27 - 0
x-pack/plugin/ent-search/build.gradle

@@ -0,0 +1,27 @@
+apply plugin: 'elasticsearch.internal-es-plugin'
+apply plugin: 'elasticsearch.internal-cluster-test'
+apply plugin: 'elasticsearch.legacy-java-rest-test'
+
+esplugin {
+  name 'x-pack-ent-search'
+  description 'Elasticsearch Expanded Pack Plugin - Enterprise Search'
+  classname 'org.elasticsearch.xpack.application.EnterpriseSearch'
+  extendedPlugins = ['x-pack-core']
+}
+
+archivesBaseName = 'x-pack-ent-search'
+
+dependencies {
+  compileOnly project(path: xpackModule('core'))
+  api project(':modules:lang-mustache')
+
+  testImplementation(testArtifact(project(xpackModule('core'))))
+  testImplementation(project(':modules:lang-mustache'))
+
+  javaRestTestImplementation(project(path: xpackModule('core')))
+  javaRestTestImplementation(testArtifact(project(xpackModule('core'))))
+  javaRestTestImplementation(project(':modules:lang-mustache'))
+}
+
+addQaCheckDependencies(project)
+

+ 0 - 0
x-pack/plugin/ent-search/qa/build.gradle


+ 21 - 0
x-pack/plugin/ent-search/qa/rest/build.gradle

@@ -0,0 +1,21 @@
+apply plugin: 'elasticsearch.legacy-yaml-rest-test'
+apply plugin: 'elasticsearch.legacy-yaml-rest-compat-test'
+
+dependencies {
+  yamlRestTestImplementation(testArtifact(project(xpackModule('core'))))
+}
+
+restResources {
+  restApi {
+    include '_common', 'cluster', 'nodes', 'indices', 'index', 'search_application', 'behavioral_analytics'
+  }
+}
+
+testClusters.configureEach {
+  testDistribution = 'DEFAULT'
+  setting 'xpack.security.enabled', 'true'
+  setting 'xpack.license.self_generated.type', 'trial'
+  extraConfigFile 'roles.yml', file('roles.yml')
+  user username: 'entsearch-admin', password: 'entsearch-admin-password', role: 'superuser'
+  user username: 'entsearch-user', password: 'entsearch-user-password', role: 'entsearch'
+}

+ 26 - 0
x-pack/plugin/ent-search/qa/rest/roles.yml

@@ -0,0 +1,26 @@
+entsearch:
+  cluster:
+    - manage_search_application
+    - manage_behavioral_analytics
+    - monitor
+  indices:
+    - names: [
+        # indices
+        "test-index",
+        "test-index1",
+        "test-index2",
+        "test-index3",
+        "test-index4",
+        "test-index-does-not-exist",
+        # Search Applications (needed to create aliases)
+        "test-search-application",
+        "test-search-application-1",
+        "test-search-application-2",
+        "another-test-search-application",
+        "test-updated-search-application",
+        "test-error-search-application",
+        "test-re-creating-search-application",
+        "test-search-application-to-delete",
+        "test-nonexistent-search-application",
+      ]
+      privileges: [ "manage" ]

+ 43 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/java/org/elasticsearch/xpack/entsearch/EnterpriseSearchRestIT.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.entsearch;
+
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate;
+import org.elasticsearch.test.rest.yaml.ESClientYamlSuiteTestCase;
+
+import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
+
+public class EnterpriseSearchRestIT extends ESClientYamlSuiteTestCase {
+
+    public EnterpriseSearchRestIT(final ClientYamlTestCandidate testCandidate) {
+        super(testCandidate);
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() throws Exception {
+        return createParameters();
+    }
+
+    @Override
+    protected Settings restAdminSettings() {
+        final String value = basicAuthHeaderValue("entsearch-admin", new SecureString("entsearch-admin-password".toCharArray()));
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", value).build();
+    }
+
+    @Override
+    protected Settings restClientSettings() {
+        final String value = basicAuthHeaderValue("entsearch-user", new SecureString("entsearch-user-password".toCharArray()));
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", value).build();
+    }
+
+}

+ 13 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/10_basic.yml

@@ -0,0 +1,13 @@
+"Enterprise Search loaded":
+    - skip:
+          features: contains
+    - do:
+          cluster.state: {}
+
+    # Get master node id
+    - set: { master_node: master }
+
+    - do:
+          nodes.info: {}
+
+    - contains:  { nodes.$master.modules: { name: x-pack-ent-search } }

+ 21 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/15_search_application_before_setup.yml

@@ -0,0 +1,21 @@
+---
+"Get Search Application returns a 404 when no Search Applications exist":
+  - do:
+      catch: "missing"
+      search_application.get:
+        name: test-no-search-applications
+
+---
+"Delete Search Application returns a 404 when no Search Applications exist":
+  - do:
+      catch: "missing"
+      search_application.delete:
+        name: test-no-search-applications
+
+---
+"List Search Application returns an empty list when no Search Applications exist":
+  - do:
+      search_application.list: { }
+
+  - match: { count: 0 }
+

+ 126 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/20_search_application_put.yml

@@ -0,0 +1,126 @@
+setup:
+  - do:
+      indices.create:
+        index: test-index1
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      indices.create:
+        index: test-index2
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      indices.create:
+        index: test-index3
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      indices.create:
+        index: test-index4
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+---
+teardown:
+  - do:
+      search_application.delete:
+        name: test-search-application
+        ignore: 404
+
+  - do:
+      search_application.delete:
+        name: test-updated-search-application
+        ignore: 404
+
+  - do:
+      search_application.delete:
+        name: test-re-creating-search-application
+        ignore: 404
+
+---
+"Create Search Application":
+  - do:
+      search_application.put:
+        name: test-search-application
+        body:
+          indices: [ "test-index1", "test-index2" ]
+
+  - match: { result: "created" }
+
+  - do:
+      indices.exists_alias:
+        name: test-search-application
+
+  - is_true: ''
+---
+"Update Search Application":
+  - do:
+      search_application.put:
+        name: test-updated-search-application
+        body:
+          indices: [ "test-index1", "test-index2" ]
+
+  - do:
+      search_application.put:
+        name: test-updated-search-application
+        body:
+          indices: [ "test-index3", "test-index4" ]
+
+  - match: { result: "updated" }
+
+---
+"Create Search Application - Index does not exist":
+  - do:
+      catch: bad_request
+      search_application.put:
+        name: test-error-search-application
+        body:
+          indices: [ "test-index1", "test-index-does-not-exist" ]
+
+---
+"Create Search Application - Resource already exists":
+  - do:
+      search_application.put:
+        name: test-re-creating-search-application
+        create: true
+        body:
+          indices: [ "test-index1" ]
+
+  - match: { result: "created" }
+
+  - do:
+      catch: conflict
+      search_application.put:
+        name: test-re-creating-search-application
+        create: true
+        body:
+          indices: [ "test-index1" ]
+
+  - match: { error.type: "version_conflict_engine_exception" }
+
+---
+"Create Search Application - Insufficient privilege":
+  - do:
+      catch: forbidden
+      search_application.put:
+        name: another-search-application
+        create: true
+        body:
+          indices: [ "another-index" ]
+
+  - match: { error.type: "security_exception" }

+ 51 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/30_search_application_get.yml

@@ -0,0 +1,51 @@
+setup:
+  - do:
+      indices.create:
+        index: test-index1
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      indices.create:
+        index: test-index2
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      search_application.put:
+        name: test-search-application
+        body:
+          indices: [ "test-index1", "test-index2"]
+          analytics_collection_name: "test-analytics"
+
+---
+teardown:
+  - do:
+      search_application.delete:
+        name: test-search-application
+        ignore: 404
+
+---
+"Get Search Application":
+  - do:
+      search_application.get:
+        name: test-search-application
+
+  - match: { name: "test-search-application" }
+  - match: { indices: [ "test-index1", "test-index2" ] }
+  - match: { analytics_collection_name: "test-analytics" }
+  - gte: { updated_at_millis: 0 }
+
+---
+"Get Search Application - Resource does not exist":
+  - do:
+      catch: "missing"
+      search_application.get:
+        name: test-nonexistent-search-application
+

+ 58 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/40_search_application_delete.yml

@@ -0,0 +1,58 @@
+setup:
+  - do:
+      indices.create:
+        index: test-index
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+  - do:
+      search_application.put:
+        name: test-search-application-to-delete
+        body:
+          indices: [ "test-index" ]
+
+---
+teardown:
+  - do:
+      search_application.delete:
+        name: test-search-application-to-delete
+        ignore: 404
+
+---
+"Delete Search Application":
+  - do:
+      search_application.delete:
+        name: test-search-application-to-delete
+
+  - match: { acknowledged: true }
+
+  - do:
+      catch: "missing"
+      search_application.get:
+        name: test-search-application-to-delete
+
+  - do:
+      indices.exists_alias:
+        name: test-search-application-to-delete
+
+  - is_false: ''
+
+---
+"Delete Search Application - Index does not exist":
+  - do:
+      catch: "missing"
+      search_application.delete:
+        name: test-nonexistent-search-application
+
+---
+"Delete Search Application - Alias does not exist as indices were deleted":
+  - do:
+      indices.delete:
+        index: test-index
+
+      search_application.delete:
+        name: test-search-application-to-delete
+
+  - match: { acknowledged: true }

+ 161 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/50_search_application_list.yml

@@ -0,0 +1,161 @@
+setup:
+  - do:
+      indices.create:
+        index: test-index1
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      indices.create:
+        index: test-index2
+        body:
+          settings:
+            index:
+              number_of_shards: 1
+              number_of_replicas: 0
+
+  - do:
+      search_application.put:
+        name: test-search-application-1
+        body:
+          indices: [ "test-index1" ]
+          analytics_collection_name: "test-analytics1"
+
+  - do:
+      search_application.put:
+        name: test-search-application-2
+        body:
+          indices: [ "test-index2" ]
+          analytics_collection_name: "test-analytics2"
+
+  - do:
+      search_application.put:
+        name: another-test-search-application
+        body:
+          indices: [ "test-index1", "test-index2" ]
+          analytics_collection_name: "test-another-analytics"
+
+---
+teardown:
+  - do:
+      search_application.delete:
+        name: test-search-application-1
+        ignore: 404
+
+  - do:
+      search_application.delete:
+        name: test-search-application-2
+        ignore: 404
+
+  - do:
+      search_application.delete:
+        name: another-test-search-application
+        ignore: 404
+
+---
+"List Search Applications":
+  - do:
+      search_application.list: { }
+  - set: { results.0.updated_at_millis: updatedAtMillis0 }
+  - set: { results.1.updated_at_millis: updatedAtMillis1 }
+  - set: { results.2.updated_at_millis: updatedAtMillis2 }
+
+  - match: { count: 3 }
+
+  # Alphabetical order for results
+  - match: { results.0.name: "another-test-search-application" }
+  - match: { results.0.indices: [ "test-index1", "test-index2" ] }
+  - match: { results.0.analytics_collection_name: "test-another-analytics" }
+  - match: { results.0.updated_at_millis: $updatedAtMillis0 }
+  - gte: { results.0.updated_at_millis: 0 }
+
+  - match: { results.1.name: "test-search-application-1" }
+  - match: { results.1.indices: [ "test-index1" ] }
+  - match: { results.1.analytics_collection_name: "test-analytics1" }
+  - match: { results.1.updated_at_millis: $updatedAtMillis1 }
+  - gte: { results.1.updated_at_millis: 0 }
+
+  - match: { results.2.name: "test-search-application-2" }
+  - match: { results.2.indices: [ "test-index2" ] }
+  - match: { results.2.analytics_collection_name: "test-analytics2" }
+  - match: { results.2.updated_at_millis: $updatedAtMillis2 }
+  - gte: { results.2.updated_at_millis: 0 }
+
+---
+"Pagination - From":
+  - do:
+      search_application.list:
+        from: 1
+  - set: { results.0.updated_at_millis: updatedAtMillis0 }
+  - set: { results.1.updated_at_millis: updatedAtMillis1 }
+
+  - match: { count: 3 }
+
+  - match: { results.0.name: "test-search-application-1" }
+  - match: { results.0.indices: [ "test-index1" ] }
+  - match: { results.0.analytics_collection_name: "test-analytics1" }
+  - match: { results.0.updated_at_millis: $updatedAtMillis0 }
+  - gte: { results.0.updated_at_millis: 0 }
+
+  - match: { results.1.name: "test-search-application-2" }
+  - match: { results.1.indices: [ "test-index2" ] }
+  - match: { results.1.analytics_collection_name: "test-analytics2" }
+  - match: { results.1.updated_at_millis: $updatedAtMillis1 }
+  - gte: { results.1.updated_at_millis: 0 }
+
+---
+"Pagination - Size":
+  - do:
+      search_application.list:
+        size: 2
+  - set: { results.0.updated_at_millis: updatedAtMillis0 }
+  - set: { results.1.updated_at_millis: updatedAtMillis1 }
+
+  - match: { count: 3 }
+
+  - match: { results.0.name: "another-test-search-application" }
+  - match: { results.0.indices: [ "test-index1", "test-index2" ] }
+  - match: { results.0.analytics_collection_name: "test-another-analytics" }
+  - match: { results.0.updated_at_millis: $updatedAtMillis0 }
+  - gte: { results.0.updated_at_millis: 0 }
+
+  - match: { results.1.name: "test-search-application-1" }
+  - match: { results.1.indices: [ "test-index1" ] }
+  - match: { results.1.analytics_collection_name: "test-analytics1" }
+  - match: { results.1.updated_at_millis: $updatedAtMillis1 }
+  - gte: { results.1.updated_at_millis: 0 }
+
+
+---
+"Query":
+  - do:
+      search_application.list:
+        q: "test-search-application-*"
+  - set: { results.0.updated_at_millis: updatedAtMillis0 }
+  - set: { results.1.updated_at_millis: updatedAtMillis1 }
+
+  - match: { count: 2 }
+
+  - match: { results.0.name: "test-search-application-1" }
+  - match: { results.0.indices: [ "test-index1" ] }
+  - match: { results.0.analytics_collection_name: "test-analytics1" }
+  - match: { results.0.updated_at_millis: $updatedAtMillis0 }
+  - gte: { results.0.updated_at_millis: 0 }
+
+  - match: { results.1.name: "test-search-application-2" }
+  - match: { results.1.indices: [ "test-index2" ] }
+  - match: { results.1.analytics_collection_name: "test-analytics2" }
+  - match: { results.1.updated_at_millis: $updatedAtMillis1 }
+  - gte: { results.1.updated_at_millis: 0 }
+
+---
+"Empty query results":
+  - do:
+      search_application.list:
+        q: "non-existing-query"
+
+  - match: { count: 0 }
+  - match: { results: [] }

+ 58 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/60_behavioral_analytics_list.yml

@@ -0,0 +1,58 @@
+setup:
+  - do:
+      behavioral_analytics.put:
+        name: my-test-analytics-collection
+
+  - do:
+      behavioral_analytics.put:
+        name: my-test-analytics-collection2
+
+---
+teardown:
+  - do:
+      behavioral_analytics.delete:
+        name: my-test-analytics-collection
+        ignore: 404
+
+---
+"Get Analytics Collection for a particular collection":
+  - do:
+      behavioral_analytics.list:
+        name: my-test-analytics-collection
+
+  - match: {
+      "my-test-analytics-collection": {
+        event_data_stream: {
+          name: "behavioral_analytics-events-my-test-analytics-collection"
+        }
+      }
+    }
+
+---
+"Get Analytics Collection list":
+  - do:
+      behavioral_analytics.list:
+        name:
+
+  - match: {
+    "my-test-analytics-collection": {
+      event_data_stream: {
+        name: "behavioral_analytics-events-my-test-analytics-collection"
+      }
+    }
+  }
+  - match: {
+    "my-test-analytics-collection2": {
+      event_data_stream: {
+        name: "behavioral_analytics-events-my-test-analytics-collection2"
+      }
+    }
+  }
+
+---
+"Get Analytics Collection - Resource does not exist":
+  - do:
+      catch: "missing"
+      behavioral_analytics.list:
+        name: test-nonexistent-analytics-collection
+

+ 30 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/70_behavioral_analytics_put.yml

@@ -0,0 +1,30 @@
+teardown:
+  - do:
+      behavioral_analytics.delete:
+        name: test-analytics-collection
+        ignore: 404
+
+---
+"Create Analytics Collection":
+  - do:
+      behavioral_analytics.put:
+        name: test-analytics-collection
+
+  - match: { acknowledged: true }
+  - match: { name: "test-analytics-collection" }
+
+---
+"Create Analytics Collection - analytics collection already exists":
+  - do:
+      behavioral_analytics.put:
+        name: test-analytics-collection
+
+  - match: { acknowledged: true }
+
+  - do:
+      catch: bad_request
+      behavioral_analytics.put:
+        name: test-analytics-collection
+
+  - match: { error.type: "resource_already_exists_exception" }
+

+ 32 - 0
x-pack/plugin/ent-search/qa/rest/src/yamlRestTest/resources/rest-api-spec/test/entsearch/80_behavioral_analytics_delete.yml

@@ -0,0 +1,32 @@
+setup:
+  - do:
+      behavioral_analytics.put:
+        name: my-test-analytics-collection
+
+---
+teardown:
+  - do:
+      behavioral_analytics.delete:
+        name: test-analytics-collection-to-delete
+        ignore: 404
+
+---
+"Delete Analytics Collection":
+  - do:
+      behavioral_analytics.delete:
+        name: my-test-analytics-collection
+
+  - match: { acknowledged: true }
+
+  - do:
+      catch: "missing"
+      behavioral_analytics.list:
+        name: my-test-analytics-collection
+
+---
+"Delete Analytics Collection - Analytics Collection does not exist":
+  - do:
+      catch: "missing"
+      behavioral_analytics.delete:
+        name: test-nonexistent-analytics-collection
+

+ 22 - 0
x-pack/plugin/ent-search/src/main/java/module-info.java

@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+module org.elasticsearch.application {
+    requires org.apache.lucene.core;
+
+    requires org.elasticsearch.base;
+    requires org.elasticsearch.logging;
+    requires org.elasticsearch.server;
+    requires org.elasticsearch.xcontent;
+    requires org.elasticsearch.xcore;
+
+    exports org.elasticsearch.xpack.application.analytics;
+    exports org.elasticsearch.xpack.application.analytics.action;
+
+    exports org.elasticsearch.xpack.application.search;
+    exports org.elasticsearch.xpack.application.search.action;
+}

+ 179 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/EnterpriseSearch.java

@@ -0,0 +1,179 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.node.DiscoveryNodes;
+import org.elasticsearch.cluster.routing.allocation.AllocationService;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.settings.ClusterSettings;
+import org.elasticsearch.common.settings.IndexScopedSettings;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.settings.SettingsFilter;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.env.NodeEnvironment;
+import org.elasticsearch.indices.SystemIndexDescriptor;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.plugins.ActionPlugin;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.SystemIndexPlugin;
+import org.elasticsearch.repositories.RepositoriesService;
+import org.elasticsearch.rest.RestController;
+import org.elasticsearch.rest.RestHandler;
+import org.elasticsearch.script.ScriptService;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.tracing.Tracer;
+import org.elasticsearch.watcher.ResourceWatcherService;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xpack.application.analytics.AnalyticsTemplateRegistry;
+import org.elasticsearch.xpack.application.analytics.action.DeleteAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.GetAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.PutAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.RestDeleteAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.RestGetAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.RestPutAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.TransportDeleteAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.TransportGetAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.TransportPutAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.search.SearchApplicationIndexService;
+import org.elasticsearch.xpack.application.search.action.DeleteSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.GetSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.ListSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.PutSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.RestDeleteSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.RestGetSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.RestListSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.RestPutSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.TransportDeleteSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.TransportGetSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.TransportListSearchApplicationAction;
+import org.elasticsearch.xpack.application.search.action.TransportPutSearchApplicationAction;
+import org.elasticsearch.xpack.core.XPackPlugin;
+import org.elasticsearch.xpack.core.XPackSettings;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Supplier;
+
+public class EnterpriseSearch extends Plugin implements ActionPlugin, SystemIndexPlugin {
+    public static final String APPLICATION_API_ENDPOINT = "_application";
+
+    public static final String SEARCH_APPLICATION_API_ENDPOINT = APPLICATION_API_ENDPOINT + "/search_application";
+
+    public static final String BEHAVIORAL_ANALYTICS_API_ENDPOINT = APPLICATION_API_ENDPOINT + "/analytics";
+
+    private static final Logger logger = LogManager.getLogger(EnterpriseSearch.class);
+
+    public static final String FEATURE_NAME = "ent_search";
+
+    private final boolean enabled;
+
+    public EnterpriseSearch(Settings settings) {
+        this.enabled = XPackSettings.ENTERPRISE_SEARCH_ENABLED.get(settings);
+    }
+
+    protected XPackLicenseState getLicenseState() {
+        return XPackPlugin.getSharedLicenseState();
+    }
+
+    @Override
+    public List<ActionHandler<? extends ActionRequest, ? extends ActionResponse>> getActions() {
+        if (enabled == false) {
+            return Collections.emptyList();
+        }
+        return List.of(
+            new ActionHandler<>(PutAnalyticsCollectionAction.INSTANCE, TransportPutAnalyticsCollectionAction.class),
+            new ActionHandler<>(GetAnalyticsCollectionAction.INSTANCE, TransportGetAnalyticsCollectionAction.class),
+            new ActionHandler<>(DeleteAnalyticsCollectionAction.INSTANCE, TransportDeleteAnalyticsCollectionAction.class),
+            new ActionHandler<>(DeleteSearchApplicationAction.INSTANCE, TransportDeleteSearchApplicationAction.class),
+            new ActionHandler<>(GetSearchApplicationAction.INSTANCE, TransportGetSearchApplicationAction.class),
+            new ActionHandler<>(ListSearchApplicationAction.INSTANCE, TransportListSearchApplicationAction.class),
+            new ActionHandler<>(PutSearchApplicationAction.INSTANCE, TransportPutSearchApplicationAction.class)
+        );
+    }
+
+    @Override
+    public List<RestHandler> getRestHandlers(
+        Settings settings,
+        RestController restController,
+        ClusterSettings clusterSettings,
+        IndexScopedSettings indexScopedSettings,
+        SettingsFilter settingsFilter,
+        IndexNameExpressionResolver indexNameExpressionResolver,
+        Supplier<DiscoveryNodes> nodesInCluster
+    ) {
+
+        if (enabled == false) {
+            return Collections.emptyList();
+        }
+        return List.of(
+            new RestGetSearchApplicationAction(),
+            new RestListSearchApplicationAction(),
+            new RestPutSearchApplicationAction(),
+            new RestDeleteSearchApplicationAction(),
+            new RestPutAnalyticsCollectionAction(),
+            new RestGetAnalyticsCollectionAction(),
+            new RestDeleteAnalyticsCollectionAction()
+        );
+    }
+
+    @Override
+    public Collection<Object> createComponents(
+        Client client,
+        ClusterService clusterService,
+        ThreadPool threadPool,
+        ResourceWatcherService resourceWatcherService,
+        ScriptService scriptService,
+        NamedXContentRegistry xContentRegistry,
+        Environment environment,
+        NodeEnvironment nodeEnvironment,
+        NamedWriteableRegistry namedWriteableRegistry,
+        IndexNameExpressionResolver indexNameExpressionResolver,
+        Supplier<RepositoriesService> repositoriesServiceSupplier,
+        Tracer tracer,
+        AllocationService allocationService
+    ) {
+        if (enabled == false) {
+            return Collections.emptyList();
+        }
+
+        // Behavioral analytics components
+        final AnalyticsTemplateRegistry analyticsTemplateRegistry = new AnalyticsTemplateRegistry(
+            clusterService,
+            threadPool,
+            client,
+            xContentRegistry
+        );
+        analyticsTemplateRegistry.initialize();
+
+        return Arrays.asList(analyticsTemplateRegistry);
+    }
+
+    @Override
+    public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
+        return Arrays.asList(SearchApplicationIndexService.getSystemIndexDescriptor());
+    }
+
+    @Override
+    public String getFeatureName() {
+        return FEATURE_NAME;
+    }
+
+    @Override
+    public String getFeatureDescription() {
+        return "Manages configuration for Enterprise Search features";
+    }
+}

+ 147 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollection.java

@@ -0,0 +1,147 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+
+import java.io.IOException;
+import java.util.Objects;
+
+/**
+ * The {@link AnalyticsCollection} model.
+ */
+public class AnalyticsCollection implements Writeable, ToXContentObject {
+
+    private static final ObjectParser<AnalyticsCollection, String> PARSER = ObjectParser.fromBuilder(
+        "analytics_collection",
+        name -> new AnalyticsCollection(name)
+    );
+
+    private final String name;
+
+    /**
+     * Default public constructor.
+     *
+     * @param name Name of the analytics collection.
+     */
+    public AnalyticsCollection(String name) {
+        this.name = Objects.requireNonNull(name);
+    }
+
+    /**
+     * Build a new {@link AnalyticsCollection} from a stream.
+     */
+    public AnalyticsCollection(StreamInput in) throws IOException {
+        this(in.readString());
+    }
+
+    /**
+     * Getter for the {@link AnalyticsCollection} name.
+     *
+     * @return {@link AnalyticsCollection} name.
+     */
+    public String getName() {
+        return this.name;
+    }
+
+    /**
+     * The event data stream used by the {@link AnalyticsCollection} to store events.
+     * For now, it is a computed property because we have no real storage for the Analytics collection.
+     *
+     * @return Event data stream name/
+     */
+    public String getEventDataStream() {
+        return AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + name;
+    }
+
+    /**
+     * Serialize the {@link AnalyticsCollection} to a XContent.
+     *
+     * @return Serialized {@link AnalyticsCollection}
+     */
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.endObject();
+
+        return builder;
+    }
+
+    /**
+     * Parses an {@link AnalyticsCollection} from its {@param xContentType} representation in bytes.
+     *
+     * @param resourceName The name of the resource (must match the {@link AnalyticsCollection} name).
+     * @param source The bytes that represents the {@link AnalyticsCollection}.
+     * @param xContentType The format of the representation.
+     *
+     * @return The parsed {@link AnalyticsCollection}.
+     */
+    public static AnalyticsCollection fromXContentBytes(String resourceName, BytesReference source, XContentType xContentType) {
+        try (XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, source, xContentType)) {
+            return AnalyticsCollection.fromXContent(resourceName, parser);
+        } catch (IOException e) {
+            throw new ElasticsearchParseException("Failed to parse: " + source.utf8ToString(), e);
+        }
+    }
+
+    /**
+     * Parses an {@link AnalyticsCollection} through the provided {@param parser}.
+     *
+     * @param resourceName The name of the resource (must match the {@link AnalyticsCollection} name).
+     * @param parser The {@link XContentType} parser.
+     *
+     * @return The parsed {@link AnalyticsCollection}.
+     */
+    public static AnalyticsCollection fromXContent(String resourceName, XContentParser parser) throws IOException {
+        return PARSER.parse(parser, resourceName);
+    }
+
+    public static AnalyticsCollection fromDataStreamName(String dataStreamName) {
+        if (dataStreamName.startsWith(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX) == false) {
+            throw new IllegalArgumentException(
+                "Data stream name (" + dataStreamName + " must start with " + AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX
+            );
+        }
+
+        return new AnalyticsCollection(dataStreamName.replaceFirst(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX, ""));
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeString(name);
+    }
+
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        AnalyticsCollection other = (AnalyticsCollection) o;
+        return name.equals(other.name);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(name);
+    }
+
+    @Override
+    public String toString() {
+        return Strings.toString(this);
+    }
+}

+ 118 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionResolver.java

@@ -0,0 +1,118 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.ResourceNotFoundException;
+import org.elasticsearch.action.support.IndicesOptions;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.regex.Regex;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static java.util.function.Predicate.not;
+
+/**
+ * A service that allows the resolution of {@link AnalyticsCollection} by name.
+ */
+public class AnalyticsCollectionResolver {
+    private final IndexNameExpressionResolver indexNameExpressionResolver;
+
+    @Inject
+    public AnalyticsCollectionResolver(IndexNameExpressionResolver indexNameExpressionResolver) {
+        this.indexNameExpressionResolver = indexNameExpressionResolver;
+    }
+
+    /**
+     * Resolves a collection by exact name and returns it.
+     *
+     * @param state Cluster state.
+     * @param collectionName Collection name
+     * @return The {@link AnalyticsCollection} object
+     * @throws ResourceNotFoundException when no analytics collection is found.
+     */
+    public AnalyticsCollection collection(ClusterState state, String collectionName) {
+        AnalyticsCollection collection = new AnalyticsCollection(collectionName);
+
+        if (state.metadata().dataStreams().containsKey(collection.getEventDataStream()) == false) {
+            throw new ResourceNotFoundException("no such analytics collection [{}]", collectionName);
+        }
+
+        return collection;
+    }
+
+    /**
+     * Resolves one or several collection by expression and returns them as a list.
+     * Expressions can be exact collection name but also contains wildcards.
+     *
+     * @param state Cluster state.
+     * @param expressions Array of the collection name expressions to be matched.
+     * @return List of {@link AnalyticsCollection} objects that match the expressions.
+     * @throws ResourceNotFoundException when no analytics collection is found.
+     */
+    public List<AnalyticsCollection> collections(ClusterState state, String... expressions) {
+        // Listing data streams that are matching the analytics collection pattern.
+        List<String> dataStreams = indexNameExpressionResolver.dataStreamNames(
+            state,
+            IndicesOptions.lenientExpandOpen(),
+            AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PATTERN
+        );
+
+        Map<String, AnalyticsCollection> collections = dataStreams.stream()
+            .map(AnalyticsCollection::fromDataStreamName)
+            .filter(analyticsCollection -> matchAnyExpression(analyticsCollection, expressions))
+            .collect(Collectors.toMap(AnalyticsCollection::getName, Function.identity()));
+
+        List<String> missingCollections = Arrays.stream(expressions)
+            .filter(not(Regex::isMatchAllPattern))
+            .filter(not(Regex::isSimpleMatchPattern))
+            .filter(not(collections::containsKey))
+            .toList();
+
+        if (missingCollections.isEmpty() == false) {
+            throw new ResourceNotFoundException("no such analytics collection [{}] ", missingCollections.get(0));
+        }
+
+        return new ArrayList<>(collections.values());
+    }
+
+    private boolean matchExpression(String collectionName, String expression) {
+        if (Strings.isNullOrEmpty(expression)) {
+            return false;
+        }
+
+        if (Regex.isMatchAllPattern(expression)) {
+            return true;
+        }
+
+        if (Regex.isSimpleMatchPattern(expression)) {
+            return Regex.simpleMatch(expression, collectionName);
+        }
+
+        return collectionName.equals(expression);
+    }
+
+    private boolean matchAnyExpression(String collectionName, String... expressions) {
+        if (expressions.length < 1) {
+            return true;
+        }
+
+        return Arrays.stream(expressions).anyMatch(expression -> matchExpression(collectionName, expression));
+    }
+
+    private boolean matchAnyExpression(AnalyticsCollection collection, String... expressions) {
+        return matchAnyExpression(collection.getName(), expressions);
+    }
+}

+ 148 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionService.java

@@ -0,0 +1,148 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.ElasticsearchStatusException;
+import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.ResourceAlreadyExistsException;
+import org.elasticsearch.ResourceNotFoundException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.datastreams.CreateDataStreamAction;
+import org.elasticsearch.action.datastreams.DeleteDataStreamAction;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.client.internal.OriginSettingClient;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.xpack.application.analytics.action.DeleteAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.GetAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.PutAnalyticsCollectionAction;
+
+import static org.elasticsearch.xpack.core.ClientHelper.ENT_SEARCH_ORIGIN;
+
+/**
+ * A service that allows the manipulation of persistent {@link AnalyticsCollection} model.
+ * Until we have more specific need the {@link AnalyticsCollection} is just another representation
+ * of a {@link org.elasticsearch.cluster.metadata.DataStream}.
+ * As a consequence, this service is mostly a facade for the data stream API.
+ */
+public class AnalyticsCollectionService {
+
+    private static final Logger logger = LogManager.getLogger(AnalyticsCollectionService.class);
+
+    private final Client clientWithOrigin;
+
+    private final AnalyticsCollectionResolver analyticsCollectionResolver;
+
+    @Inject
+    public AnalyticsCollectionService(Client client, AnalyticsCollectionResolver analyticsCollectionResolver) {
+        this.clientWithOrigin = new OriginSettingClient(client, ENT_SEARCH_ORIGIN);
+        this.analyticsCollectionResolver = analyticsCollectionResolver;
+    }
+
+    /**
+     * Retrieve an analytics collection by name {@link AnalyticsCollection}
+     *
+     * @param state    Cluster state ({@link ClusterState}).
+     * @param request  {@link PutAnalyticsCollectionAction.Request} The request.
+     * @param listener The action listener to invoke on response/failure.
+     */
+    public void getAnalyticsCollection(
+        ClusterState state,
+        GetAnalyticsCollectionAction.Request request,
+        ActionListener<GetAnalyticsCollectionAction.Response> listener
+    ) {
+        // This operation is supposed to be executed on the master node only.
+        assert (state.nodes().isLocalNodeElectedMaster());
+
+        listener.onResponse(new GetAnalyticsCollectionAction.Response(analyticsCollectionResolver.collections(state, request.getNames())));
+    }
+
+    /**
+     * Create a new {@link AnalyticsCollection}
+     *
+     * @param state    Cluster state ({@link ClusterState}).
+     * @param request  {@link PutAnalyticsCollectionAction.Request} The request.
+     * @param listener The action listener to invoke on response/failure.
+     */
+    public void putAnalyticsCollection(
+        ClusterState state,
+        PutAnalyticsCollectionAction.Request request,
+        ActionListener<PutAnalyticsCollectionAction.Response> listener
+    ) {
+        // This operation is supposed to be executed on the master node only.
+        assert (state.nodes().isLocalNodeElectedMaster());
+
+        AnalyticsCollection collection = new AnalyticsCollection(request.getName());
+        CreateDataStreamAction.Request createDataStreamRequest = new CreateDataStreamAction.Request(collection.getEventDataStream());
+
+        ActionListener<AcknowledgedResponse> createDataStreamListener = ActionListener.wrap(
+            r -> listener.onResponse(new PutAnalyticsCollectionAction.Response(r.isAcknowledged(), request.getName())),
+            (Exception e) -> {
+                if (e instanceof ResourceAlreadyExistsException) {
+                    listener.onFailure(
+                        new ResourceAlreadyExistsException("analytics collection [{}] already exists", request.getName(), e)
+                    );
+                    return;
+                }
+
+                e = new ElasticsearchStatusException(
+                    "error while creating analytics collection [{}]",
+                    ExceptionsHelper.status(e),
+                    e,
+                    request.getName()
+                );
+                logger.error(e.getMessage(), e);
+
+                listener.onFailure(e);
+            }
+        );
+
+        clientWithOrigin.execute(CreateDataStreamAction.INSTANCE, createDataStreamRequest, createDataStreamListener);
+    }
+
+    /**
+     * Delete an analytics collection by name {@link AnalyticsCollection}
+     *
+     * @param state    Cluster state ({@link ClusterState}).
+     * @param request  {@link AnalyticsCollection} name.
+     * @param listener The action listener to invoke on response/failure.
+     */
+    public void deleteAnalyticsCollection(
+        ClusterState state,
+        DeleteAnalyticsCollectionAction.Request request,
+        ActionListener<AcknowledgedResponse> listener
+    ) {
+        // This operation is supposed to be executed on the master node.
+        assert (state.nodes().isLocalNodeElectedMaster());
+
+        AnalyticsCollection collection = new AnalyticsCollection(request.getCollectionName());
+        DeleteDataStreamAction.Request deleteDataStreamRequest = new DeleteDataStreamAction.Request(collection.getEventDataStream());
+        ActionListener<AcknowledgedResponse> deleteDataStreamListener = ActionListener.wrap(listener::onResponse, (Exception e) -> {
+            if (e instanceof ResourceNotFoundException) {
+                listener.onFailure(new ResourceNotFoundException("analytics collection [{}] does not exists", request.getCollectionName()));
+                return;
+            }
+
+            e = new ElasticsearchStatusException(
+                "error while deleting analytics collection [{}]",
+                ExceptionsHelper.status(e),
+                e,
+                request.getCollectionName()
+            );
+
+            logger.error(e.getMessage(), e);
+
+            listener.onFailure(e);
+        });
+
+        clientWithOrigin.execute(DeleteDataStreamAction.INSTANCE, deleteDataStreamRequest, deleteDataStreamListener);
+    }
+}

+ 137 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/AnalyticsTemplateRegistry.java

@@ -0,0 +1,137 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.metadata.ComponentTemplate;
+import org.elasticsearch.cluster.metadata.ComposableIndexTemplate;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.json.JsonXContent;
+import org.elasticsearch.xpack.core.ilm.LifecyclePolicy;
+import org.elasticsearch.xpack.core.template.IndexTemplateConfig;
+import org.elasticsearch.xpack.core.template.IndexTemplateRegistry;
+import org.elasticsearch.xpack.core.template.LifecyclePolicyConfig;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.elasticsearch.xpack.core.ClientHelper.ENT_SEARCH_ORIGIN;
+
+public class AnalyticsTemplateRegistry extends IndexTemplateRegistry {
+    public static final String ROOT_RESOURCE_PATH = "/org/elasticsearch/xpack/entsearch/analytics/";
+
+    // This number must be incremented when we make changes to built-in templates.
+    public static final int REGISTRY_VERSION = 1;
+
+    // The variable to be replaced with the template version number
+    public static final String TEMPLATE_VERSION_VARIABLE = "xpack.entsearch.analytics.template.version";
+
+    // ILM Policies configuration
+    public static final String EVENT_DATA_STREAM_ILM_POLICY_NAME = "behavioral_analytics-events-default_policy";
+    private static final List<LifecyclePolicy> LIFECYCLE_POLICIES = Stream.of(
+        new LifecyclePolicyConfig(EVENT_DATA_STREAM_ILM_POLICY_NAME, ROOT_RESOURCE_PATH + EVENT_DATA_STREAM_ILM_POLICY_NAME + ".json")
+    ).map(config -> config.load(LifecyclePolicyConfig.DEFAULT_X_CONTENT_REGISTRY)).toList();
+
+    // Index template components configuration
+    public static final String EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME = "behavioral_analytics-events-settings";
+    public static final String EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME = "behavioral_analytics-events-mappings";
+
+    private static final Map<String, ComponentTemplate> COMPONENT_TEMPLATES;
+
+    static {
+        final Map<String, ComponentTemplate> componentTemplates = new HashMap<>();
+        for (IndexTemplateConfig config : List.of(
+            new IndexTemplateConfig(
+                EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME,
+                ROOT_RESOURCE_PATH + EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME + ".json",
+                REGISTRY_VERSION,
+                TEMPLATE_VERSION_VARIABLE
+            ),
+            new IndexTemplateConfig(
+                EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME,
+                ROOT_RESOURCE_PATH + EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME + ".json",
+                REGISTRY_VERSION,
+                TEMPLATE_VERSION_VARIABLE
+            )
+        )) {
+            try {
+                componentTemplates.put(
+                    config.getTemplateName(),
+                    ComponentTemplate.parse(JsonXContent.jsonXContent.createParser(XContentParserConfiguration.EMPTY, config.loadBytes()))
+                );
+            } catch (IOException e) {
+                throw new AssertionError(e);
+            }
+        }
+        COMPONENT_TEMPLATES = Map.copyOf(componentTemplates);
+    }
+
+    // Composable index templates configuration.
+    public static final String EVENT_DATA_STREAM_INDEX_PREFIX = "behavioral_analytics-events-";
+
+    public static final String EVENT_DATA_STREAM_INDEX_PATTERN = EVENT_DATA_STREAM_INDEX_PREFIX + "*";
+    public static final String EVENT_DATA_STREAM_TEMPLATE_NAME = "behavioral_analytics-events-default";
+
+    private static final String EVENT_DATA_STREAM_TEMPLATE_FILENAME = "behavioral_analytics-events-template";
+
+    private static final Map<String, ComposableIndexTemplate> COMPOSABLE_INDEX_TEMPLATES = parseComposableTemplates(
+        new IndexTemplateConfig(
+            EVENT_DATA_STREAM_TEMPLATE_NAME,
+            ROOT_RESOURCE_PATH + EVENT_DATA_STREAM_TEMPLATE_FILENAME + ".json",
+            REGISTRY_VERSION,
+            TEMPLATE_VERSION_VARIABLE,
+            Map.of("event_data_stream.index_pattern", EVENT_DATA_STREAM_INDEX_PATTERN)
+        )
+    );
+
+    public AnalyticsTemplateRegistry(
+        ClusterService clusterService,
+        ThreadPool threadPool,
+        Client client,
+        NamedXContentRegistry xContentRegistry
+    ) {
+        super(Settings.EMPTY, clusterService, threadPool, client, xContentRegistry);
+    }
+
+    @Override
+    protected String getOrigin() {
+        return ENT_SEARCH_ORIGIN;
+    }
+
+    @Override
+    protected List<LifecyclePolicy> getPolicyConfigs() {
+        return LIFECYCLE_POLICIES;
+    }
+
+    @Override
+    protected Map<String, ComponentTemplate> getComponentTemplateConfigs() {
+        return COMPONENT_TEMPLATES;
+    }
+
+    @Override
+    protected Map<String, ComposableIndexTemplate> getComposableTemplateConfigs() {
+        return COMPOSABLE_INDEX_TEMPLATES;
+    }
+
+    @Override
+    protected boolean requiresMasterNode() {
+        // We are using the composable index template and component APIs,
+        // these APIs aren't supported in 7.7 and earlier and in mixed cluster
+        // environments this can cause a lot of ActionNotFoundTransportException
+        // errors in the logs during rolling upgrades. If these templates
+        // are only installed via elected master node then the APIs are always
+        // there and the ActionNotFoundTransportException errors are then prevented.
+        return true;
+    }
+}

+ 79 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/DeleteAnalyticsCollectionAction.java

@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.action.support.master.MasterNodeRequest;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class DeleteAnalyticsCollectionAction extends ActionType<AcknowledgedResponse> {
+
+    public static final DeleteAnalyticsCollectionAction INSTANCE = new DeleteAnalyticsCollectionAction();
+    public static final String NAME = "cluster:admin/xpack/application/analytics/delete";
+
+    private DeleteAnalyticsCollectionAction() {
+        super(NAME, AcknowledgedResponse::readFrom);
+    }
+
+    public static class Request extends MasterNodeRequest<Request> {
+        private final String collectionName;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.collectionName = in.readString();
+        }
+
+        public Request(String collectionName) {
+            this.collectionName = collectionName;
+        }
+
+        public String getCollectionName() {
+            return collectionName;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (Strings.isNullOrEmpty(collectionName)) {
+                validationException = addValidationError("collection name missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(collectionName);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request that = (Request) o;
+            return Objects.equals(collectionName, that.collectionName);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(collectionName);
+        }
+
+    }
+}

+ 131 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/GetAnalyticsCollectionAction.java

@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.support.master.MasterNodeReadRequest;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollection;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+public class GetAnalyticsCollectionAction extends ActionType<GetAnalyticsCollectionAction.Response> {
+
+    public static final GetAnalyticsCollectionAction INSTANCE = new GetAnalyticsCollectionAction();
+    public static final String NAME = "cluster:admin/xpack/application/analytics/get";
+
+    private GetAnalyticsCollectionAction() {
+        super(NAME, GetAnalyticsCollectionAction.Response::new);
+    }
+
+    public static class Request extends MasterNodeReadRequest<Request> {
+        private final String[] names;
+
+        public Request(String[] names) {
+            this.names = Objects.requireNonNull(names);
+        }
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.names = in.readStringArray();
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            return null;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeStringArray(names);
+        }
+
+        public String[] getNames() {
+            return this.names;
+        }
+
+        @Override
+        public int hashCode() {
+            return Arrays.hashCode(this.names);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request request = (Request) o;
+            return Arrays.equals(this.names, request.names);
+        }
+    }
+
+    public static class Response extends ActionResponse implements ToXContentObject {
+        private final List<AnalyticsCollection> collections;
+
+        public static final ParseField EVENT_DATA_STREAM_FIELD = new ParseField("event_data_stream");
+        public static final ParseField EVENT_DATA_STREAM_NAME_FIELD = new ParseField("name");
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            this.collections = in.readList(AnalyticsCollection::new);
+        }
+
+        public Response(List<AnalyticsCollection> collections) {
+            this.collections = collections;
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException {
+            builder.startObject();
+            for (AnalyticsCollection collection : collections) {
+                builder.startObject(collection.getName());
+                {
+                    builder.startObject(EVENT_DATA_STREAM_FIELD.getPreferredName());
+                    builder.field(EVENT_DATA_STREAM_NAME_FIELD.getPreferredName(), collection.getEventDataStream());
+                    builder.endObject();
+                }
+                builder.endObject();
+            }
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeList(collections);
+        }
+
+        public List<AnalyticsCollection> getAnalyticsCollections() {
+            return collections;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(this.collections);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response response = (Response) o;
+
+            return Objects.equals(this.collections, response.collections);
+        }
+    }
+}

+ 131 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/PutAnalyticsCollectionAction.java

@@ -0,0 +1,131 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.action.support.master.MasterNodeRequest;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.StatusToXContentObject;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class PutAnalyticsCollectionAction extends ActionType<PutAnalyticsCollectionAction.Response> {
+
+    public static final PutAnalyticsCollectionAction INSTANCE = new PutAnalyticsCollectionAction();
+    public static final String NAME = "cluster:admin/xpack/application/analytics/put";
+
+    public PutAnalyticsCollectionAction() {
+        super(NAME, PutAnalyticsCollectionAction.Response::new);
+    }
+
+    public static class Request extends MasterNodeRequest<Request> {
+        private final String name;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.name = in.readString();
+        }
+
+        public Request(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (name == null || name.isEmpty()) {
+                validationException = addValidationError("Analytics collection name is missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(name);
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request that = (Request) o;
+            return Objects.equals(name, that.name);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name);
+        }
+    }
+
+    public static class Response extends AcknowledgedResponse implements StatusToXContentObject {
+
+        public static final ParseField COLLECTION_NAME_FIELD = new ParseField("name");
+
+        private final String name;
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            this.name = in.readString();
+        }
+
+        public Response(boolean acknowledged, String name) {
+            super(acknowledged);
+            this.name = name;
+        }
+
+        @Override
+        public RestStatus status() {
+            return RestStatus.CREATED;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(name);
+        }
+
+        @Override
+        public int hashCode() {
+            return 31 * super.hashCode() + name.hashCode();
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response response = (Response) o;
+            return isAcknowledged() == response.isAcknowledged() && Objects.equals(name, response.name);
+        }
+
+        @Override
+        protected void addCustomFields(XContentBuilder builder, Params params) throws IOException {
+            builder.field(COLLECTION_NAME_FIELD.getPreferredName(), name);
+        }
+    }
+}

+ 38 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestDeleteAnalyticsCollectionAction.java

@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.DELETE;
+
+public class RestDeleteAnalyticsCollectionAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "behavioral_analytics_delete_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(DELETE, "/" + EnterpriseSearch.BEHAVIORAL_ANALYTICS_API_ENDPOINT + "/{collection_name}"));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) throws IOException {
+        DeleteAnalyticsCollectionAction.Request request = new DeleteAnalyticsCollectionAction.Request(restRequest.param("collection_name"));
+        return channel -> client.execute(DeleteAnalyticsCollectionAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 43 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestGetAnalyticsCollectionAction.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+
+public class RestGetAnalyticsCollectionAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "get_analytics_collection_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(
+            new Route(GET, "/" + EnterpriseSearch.BEHAVIORAL_ANALYTICS_API_ENDPOINT + "/{collection_name}"),
+            new Route(GET, "/" + EnterpriseSearch.BEHAVIORAL_ANALYTICS_API_ENDPOINT)
+        );
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) {
+        GetAnalyticsCollectionAction.Request request = new GetAnalyticsCollectionAction.Request(
+            Strings.splitStringByCommaToArray(restRequest.param("collection_name"))
+        );
+        return channel -> client.execute(GetAnalyticsCollectionAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 43 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/RestPutAnalyticsCollectionAction.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestStatusToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.PUT;
+
+public class RestPutAnalyticsCollectionAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "analytics_post_action";
+    }
+
+    @Override
+    public List<RestHandler.Route> routes() {
+        return List.of(new RestHandler.Route(PUT, "/" + EnterpriseSearch.BEHAVIORAL_ANALYTICS_API_ENDPOINT + "/{collection_name}"));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) {
+        PutAnalyticsCollectionAction.Request request = new PutAnalyticsCollectionAction.Request(restRequest.param("collection_name"));
+        String location = routes().get(0).getPath().replace("{collection_name}", request.getName());
+        return channel -> client.execute(
+            PutAnalyticsCollectionAction.INSTANCE,
+            request,
+            new RestStatusToXContentListener<>(channel, _r -> location)
+        );
+    }
+}

+ 76 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/TransportDeleteAnalyticsCollectionAction.java

@@ -0,0 +1,76 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.action.support.master.AcknowledgedTransportMasterNodeAction;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.block.ClusterBlockException;
+import org.elasticsearch.cluster.block.ClusterBlockLevel;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollectionService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+public class TransportDeleteAnalyticsCollectionAction extends AcknowledgedTransportMasterNodeAction<
+    DeleteAnalyticsCollectionAction.Request> {
+
+    private final AnalyticsCollectionService analyticsCollectionService;
+
+    private final XPackLicenseState licenseState;
+
+    @Inject
+    public TransportDeleteAnalyticsCollectionAction(
+        TransportService transportService,
+        ClusterService clusterService,
+        ThreadPool threadPool,
+        ActionFilters actionFilters,
+        IndexNameExpressionResolver indexNameExpressionResolver,
+        AnalyticsCollectionService analyticsCollectionService,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            DeleteAnalyticsCollectionAction.NAME,
+            transportService,
+            clusterService,
+            threadPool,
+            actionFilters,
+            DeleteAnalyticsCollectionAction.Request::new,
+            indexNameExpressionResolver,
+            ThreadPool.Names.SAME
+        );
+        this.analyticsCollectionService = analyticsCollectionService;
+        this.licenseState = licenseState;
+    }
+
+    @Override
+    protected ClusterBlockException checkBlock(DeleteAnalyticsCollectionAction.Request request, ClusterState state) {
+        return state.blocks().globalBlockedException(ClusterBlockLevel.METADATA_WRITE);
+    }
+
+    @Override
+    protected void masterOperation(
+        Task task,
+        DeleteAnalyticsCollectionAction.Request request,
+        ClusterState state,
+        ActionListener<AcknowledgedResponse> listener
+    ) {
+        LicenseUtils.runIfSupportedLicense(
+            licenseState,
+            () -> analyticsCollectionService.deleteAnalyticsCollection(state, request, listener),
+            listener::onFailure
+        );
+    }
+}

+ 77 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/TransportGetAnalyticsCollectionAction.java

@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.TransportMasterNodeReadAction;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.block.ClusterBlockException;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollectionService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+public class TransportGetAnalyticsCollectionAction extends TransportMasterNodeReadAction<
+    GetAnalyticsCollectionAction.Request,
+    GetAnalyticsCollectionAction.Response> {
+
+    private final AnalyticsCollectionService analyticsCollectionService;
+
+    private final XPackLicenseState licenseState;
+
+    @Inject
+    public TransportGetAnalyticsCollectionAction(
+        TransportService transportService,
+        ClusterService clusterService,
+        ThreadPool threadPool,
+        ActionFilters actionFilters,
+        IndexNameExpressionResolver indexNameExpressionResolver,
+        AnalyticsCollectionService analyticsCollectionService,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            GetAnalyticsCollectionAction.NAME,
+            transportService,
+            clusterService,
+            threadPool,
+            actionFilters,
+            GetAnalyticsCollectionAction.Request::new,
+            indexNameExpressionResolver,
+            GetAnalyticsCollectionAction.Response::new,
+            ThreadPool.Names.SAME
+        );
+        this.analyticsCollectionService = analyticsCollectionService;
+        this.licenseState = licenseState;
+    }
+
+    @Override
+    protected void masterOperation(
+        Task task,
+        GetAnalyticsCollectionAction.Request request,
+        ClusterState state,
+        ActionListener<GetAnalyticsCollectionAction.Response> listener
+    ) {
+        LicenseUtils.runIfSupportedLicense(
+            licenseState,
+            () -> analyticsCollectionService.getAnalyticsCollection(state, request, listener),
+            listener::onFailure
+        );
+    }
+
+    @Override
+    protected ClusterBlockException checkBlock(GetAnalyticsCollectionAction.Request request, ClusterState state) {
+        return null;
+    }
+
+}

+ 78 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/analytics/action/TransportPutAnalyticsCollectionAction.java

@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.TransportMasterNodeAction;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.block.ClusterBlockException;
+import org.elasticsearch.cluster.block.ClusterBlockLevel;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollectionService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+public class TransportPutAnalyticsCollectionAction extends TransportMasterNodeAction<
+    PutAnalyticsCollectionAction.Request,
+    PutAnalyticsCollectionAction.Response> {
+
+    private final AnalyticsCollectionService analyticsCollectionService;
+
+    private final XPackLicenseState licenseState;
+
+    @Inject
+    public TransportPutAnalyticsCollectionAction(
+        TransportService transportService,
+        ClusterService clusterService,
+        ThreadPool threadPool,
+        ActionFilters actionFilters,
+        IndexNameExpressionResolver indexNameExpressionResolver,
+        AnalyticsCollectionService analyticsCollectionService,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            PutAnalyticsCollectionAction.NAME,
+            transportService,
+            clusterService,
+            threadPool,
+            actionFilters,
+            PutAnalyticsCollectionAction.Request::new,
+            indexNameExpressionResolver,
+            PutAnalyticsCollectionAction.Response::new,
+            ThreadPool.Names.SAME
+        );
+        this.analyticsCollectionService = analyticsCollectionService;
+        this.licenseState = licenseState;
+    }
+
+    @Override
+    protected ClusterBlockException checkBlock(PutAnalyticsCollectionAction.Request request, ClusterState state) {
+        return state.blocks().globalBlockedException(ClusterBlockLevel.METADATA_WRITE);
+    }
+
+    @Override
+    protected void masterOperation(
+        Task task,
+        PutAnalyticsCollectionAction.Request request,
+        ClusterState state,
+        ActionListener<PutAnalyticsCollectionAction.Response> listener
+    ) {
+        LicenseUtils.runIfSupportedLicense(
+            licenseState,
+            () -> analyticsCollectionService.putAnalyticsCollection(state, request, listener),
+            listener::onFailure
+        );
+    }
+
+}

+ 260 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/SearchApplication.java

@@ -0,0 +1,260 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.ReleasableBytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.elasticsearch.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+public class SearchApplication implements Writeable, ToXContentObject {
+
+    private final String name;
+    private final String[] indices;
+    private static final ConstructingObjectParser<SearchApplication, String> PARSER = new ConstructingObjectParser<>(
+        "search_application",
+        false,
+        (params, resourceName) -> {
+            final String name = (String) params[0];
+            // If name is provided, check that it matches the resource name. We don't want it to be updatable
+            if (name != null && name.equals(resourceName) == false) {
+                throw new IllegalArgumentException(
+                    "Search Application name [" + name + "] does not match the resource name: [" + resourceName + "]"
+                );
+            }
+            @SuppressWarnings("unchecked")
+            final String[] indices = ((List<String>) params[1]).toArray(String[]::new);
+            final String analyticsCollectionName = (String) params[2];
+            final Long maybeUpdatedAtMillis = (Long) params[3];
+            long updatedAtMillis = (maybeUpdatedAtMillis != null ? maybeUpdatedAtMillis : System.currentTimeMillis());
+
+            SearchApplication newApp = new SearchApplication(resourceName, indices, analyticsCollectionName, updatedAtMillis);
+            return newApp;
+        }
+    );
+    private final String analyticsCollectionName;
+    private final long updatedAtMillis;
+
+    public SearchApplication(StreamInput in) throws IOException {
+        this.name = in.readString();
+        this.indices = in.readStringArray();
+        this.analyticsCollectionName = in.readOptionalString();
+        this.updatedAtMillis = in.readLong();
+    }
+
+    /**
+     * Public constructor.
+     *
+     * @param name                    The name of the search application.
+     * @param indices                 The list of indices targeted by this search application.
+     * @param analyticsCollectionName The name of the associated analytics collection.
+     * @param updatedAtMillis         Last updated time in milliseconds for the search application.
+     */
+    public SearchApplication(String name, String[] indices, @Nullable String analyticsCollectionName, long updatedAtMillis) {
+        if (Strings.isNullOrEmpty(name)) {
+            throw new IllegalArgumentException("Search Application name cannot be null or blank");
+        }
+        this.name = name;
+
+        Objects.requireNonNull(indices, "Search Application indices cannot be null");
+        this.indices = Arrays.copyOf(indices, indices.length);
+        // Indices are sorted for equality between Search Applications with the same indices
+        Arrays.sort(this.indices);
+
+        this.analyticsCollectionName = analyticsCollectionName;
+        this.updatedAtMillis = updatedAtMillis;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeString(name);
+        out.writeStringArray(indices);
+        out.writeOptionalString(analyticsCollectionName);
+        out.writeLong(updatedAtMillis);
+    }
+
+    public static final ParseField NAME_FIELD = new ParseField("name");
+    public static final ParseField INDICES_FIELD = new ParseField("indices");
+    public static final ParseField ANALYTICS_COLLECTION_NAME_FIELD = new ParseField("analytics_collection_name");
+    public static final ParseField UPDATED_AT_MILLIS_FIELD = new ParseField("updated_at_millis");
+    public static final ParseField BINARY_CONTENT_FIELD = new ParseField("binary_content");
+
+    static {
+        PARSER.declareStringOrNull(optionalConstructorArg(), NAME_FIELD);
+        PARSER.declareStringArray(constructorArg(), INDICES_FIELD);
+        PARSER.declareStringOrNull(optionalConstructorArg(), ANALYTICS_COLLECTION_NAME_FIELD);
+        PARSER.declareLong(optionalConstructorArg(), UPDATED_AT_MILLIS_FIELD);
+    }
+
+    /**
+     * Parses an {@link SearchApplication} from its {@param xContentType} representation in bytes.
+     *
+     * @param resourceName The name of the resource (must match the {@link SearchApplication} name).
+     * @param source The bytes that represents the {@link SearchApplication}.
+     * @param xContentType The format of the representation.
+     *
+     * @return The parsed {@link SearchApplication}.
+     */
+    public static SearchApplication fromXContentBytes(String resourceName, BytesReference source, XContentType xContentType) {
+        try (XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, source, xContentType)) {
+            return SearchApplication.fromXContent(resourceName, parser);
+        } catch (IOException e) {
+            throw new ElasticsearchParseException("Failed to parse: " + source.utf8ToString(), e);
+        }
+    }
+
+    /**
+     * Parses an {@link SearchApplication} through the provided {@param parser}.
+     *
+     * @param resourceName The name of the resource (must match the {@link SearchApplication} name).
+     * @param parser The {@link XContentType} parser.
+     *
+     * @return The parsed {@link SearchApplication}.
+     */
+    public static SearchApplication fromXContent(String resourceName, XContentParser parser) throws IOException {
+        return PARSER.parse(parser, resourceName);
+    }
+
+    /**
+     * Converts the {@link SearchApplication} to XContent.
+     *
+     * @return The {@link XContentBuilder} containing the serialized {@link SearchApplication}.
+     */
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(NAME_FIELD.getPreferredName(), name);
+        builder.field(INDICES_FIELD.getPreferredName(), indices);
+        if (analyticsCollectionName != null) {
+            builder.field(ANALYTICS_COLLECTION_NAME_FIELD.getPreferredName(), analyticsCollectionName);
+        }
+        builder.field(UPDATED_AT_MILLIS_FIELD.getPreferredName(), updatedAtMillis);
+        builder.endObject();
+        return builder;
+    }
+
+    /**
+     * Returns the name of the {@link SearchApplication}.
+     *
+     * @return The name of the {@link SearchApplication}.
+     */
+    public String name() {
+        return name;
+    }
+
+    /**
+     * Returns the list of indices targeted by the {@link SearchApplication}.
+     *
+     * @return The list of indices targeted by the {@link SearchApplication}.
+     */
+    public String[] indices() {
+        return indices;
+    }
+
+    /**
+     * Returns the name of the analytics collection linked with this {@link SearchApplication}.
+     *
+     * @return The analytics collection name.
+     */
+    public @Nullable String analyticsCollectionName() {
+        return analyticsCollectionName;
+    }
+
+    /**
+     * Returns the timestamp in milliseconds that this {@link SearchApplication} was last modified.
+     *
+     * @return The last updated timestamp in milliseconds.
+     */
+    public long updatedAtMillis() {
+        return updatedAtMillis;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SearchApplication app = (SearchApplication) o;
+        return name.equals(app.name)
+            && Arrays.equals(indices, app.indices)
+            && Objects.equals(analyticsCollectionName, app.analyticsCollectionName)
+            && updatedAtMillis == app.updatedAtMillis();
+    }
+
+    @Override
+    public int hashCode() {
+        int result = Objects.hash(name, analyticsCollectionName, updatedAtMillis);
+        result = 31 * result + Arrays.hashCode(indices);
+        return result;
+    }
+
+    @Override
+    public String toString() {
+        return Strings.toString(this);
+    }
+
+    /**
+     * Returns the merged {@link SearchApplication} from the current state and the provided {@param update}.
+     * This function returns the current instance if the update is a noop.
+     *
+     * @param update The source of the update represented in bytes.
+     * @param xContentType The format of the bytes.
+     * @param bigArrays The {@link BigArrays} to use to recycle bytes array.
+     *
+     * @return The merged {@link SearchApplication}.
+     */
+    SearchApplication merge(BytesReference update, XContentType xContentType, BigArrays bigArrays) throws IOException {
+        final Tuple<XContentType, Map<String, Object>> sourceAndContent;
+        try (ReleasableBytesStreamOutput sourceBuffer = new ReleasableBytesStreamOutput(0, bigArrays.withCircuitBreaking())) {
+            try (XContentBuilder builder = XContentFactory.jsonBuilder(sourceBuffer)) {
+                toXContent(builder, EMPTY_PARAMS);
+            }
+            sourceAndContent = XContentHelper.convertToMap(sourceBuffer.bytes(), true, XContentType.JSON);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+        final Tuple<XContentType, Map<String, Object>> updateAndContent = XContentHelper.convertToMap(update, true, xContentType);
+        final Map<String, Object> newSourceAsMap = new HashMap<>(sourceAndContent.v2());
+        final boolean noop = XContentHelper.update(newSourceAsMap, updateAndContent.v2(), true) == false;
+        if (noop) {
+            return this;
+        }
+
+        try (ReleasableBytesStreamOutput newSourceBuffer = new ReleasableBytesStreamOutput(0, bigArrays.withCircuitBreaking())) {
+            try (XContentBuilder builder = XContentFactory.jsonBuilder(newSourceBuffer)) {
+                builder.value(newSourceAsMap);
+            }
+            return SearchApplication.fromXContentBytes(name, newSourceBuffer.bytes(), XContentType.JSON);
+        }
+    }
+}

+ 503 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/SearchApplicationIndexService.java

@@ -0,0 +1,503 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.ResourceNotFoundException;
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.Version;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.DelegatingActionListener;
+import org.elasticsearch.action.DocWriteRequest;
+import org.elasticsearch.action.DocWriteResponse;
+import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest;
+import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequestBuilder;
+import org.elasticsearch.action.admin.indices.alias.get.GetAliasesRequest;
+import org.elasticsearch.action.admin.indices.alias.get.GetAliasesResponse;
+import org.elasticsearch.action.delete.DeleteRequest;
+import org.elasticsearch.action.delete.DeleteResponse;
+import org.elasticsearch.action.get.GetRequest;
+import org.elasticsearch.action.index.IndexRequest;
+import org.elasticsearch.action.index.IndexResponse;
+import org.elasticsearch.action.search.SearchRequest;
+import org.elasticsearch.action.search.SearchResponse;
+import org.elasticsearch.action.support.WriteRequest;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.client.internal.OriginSettingClient;
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.cluster.metadata.Metadata;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.document.DocumentField;
+import org.elasticsearch.common.io.stream.InputStreamStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.OutputStreamStreamOutput;
+import org.elasticsearch.common.io.stream.ReleasableBytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.common.xcontent.XContentParserUtils;
+import org.elasticsearch.core.Streams;
+import org.elasticsearch.index.Index;
+import org.elasticsearch.index.IndexNotFoundException;
+import org.elasticsearch.index.query.QueryStringQueryBuilder;
+import org.elasticsearch.indices.ExecutorNames;
+import org.elasticsearch.indices.SystemIndexDescriptor;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.rest.action.admin.indices.AliasesNotFoundException;
+import org.elasticsearch.search.SearchHit;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.search.sort.SortOrder;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.CharBuffer;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
+import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder;
+import static org.elasticsearch.xpack.core.ClientHelper.ENT_SEARCH_ORIGIN;
+
+/**
+ * A service that manages the persistent {@link SearchApplication} configurations.
+ *
+ * TODO: Revise the internal format (mappings). Should we use rest or transport versioning for BWC?
+ */
+public class SearchApplicationIndexService {
+    private static final Logger logger = LogManager.getLogger(SearchApplicationIndexService.class);
+    public static final String SEARCH_APPLICATION_ALIAS_NAME = ".search-app";
+    public static final String SEARCH_APPLICATION_CONCRETE_INDEX_NAME = ".search-app-1";
+    public static final String SEARCH_APPLICATION_INDEX_NAME_PATTERN = ".search-app-*";
+
+    // The client to perform any operations on user indices (alias, ...).
+    private final Client client;
+    // The client to interact with the system index (internal user).
+    private final Client clientWithOrigin;
+    private final ClusterService clusterService;
+    public final NamedWriteableRegistry namedWriteableRegistry;
+    private final BigArrays bigArrays;
+
+    public SearchApplicationIndexService(
+        Client client,
+        ClusterService clusterService,
+        NamedWriteableRegistry namedWriteableRegistry,
+        BigArrays bigArrays
+    ) {
+        this.client = client;
+        this.clientWithOrigin = new OriginSettingClient(client, ENT_SEARCH_ORIGIN);
+        this.clusterService = clusterService;
+        this.namedWriteableRegistry = namedWriteableRegistry;
+        this.bigArrays = bigArrays;
+    }
+
+    /**
+     * Returns the {@link SystemIndexDescriptor} for the {@link SearchApplication} system index.
+     *
+     * @return The {@link SystemIndexDescriptor} for the {@link SearchApplication} system index.
+     */
+    public static SystemIndexDescriptor getSystemIndexDescriptor() {
+        return SystemIndexDescriptor.builder()
+            .setIndexPattern(SEARCH_APPLICATION_INDEX_NAME_PATTERN)
+            .setPrimaryIndex(SEARCH_APPLICATION_CONCRETE_INDEX_NAME)
+            .setDescription("Contains Search Application configuration")
+            .setMappings(getIndexMappings())
+            .setSettings(getIndexSettings())
+            .setAliasName(SEARCH_APPLICATION_ALIAS_NAME)
+            .setVersionMetaKey("version")
+            .setOrigin(ENT_SEARCH_ORIGIN)
+            .setThreadPools(ExecutorNames.DEFAULT_SYSTEM_INDEX_THREAD_POOLS)
+            .build();
+    }
+
+    private static Settings getIndexSettings() {
+        return Settings.builder()
+            .put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
+            .put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0)
+            .put(IndexMetadata.SETTING_AUTO_EXPAND_REPLICAS, "0-1")
+            .put(IndexMetadata.SETTING_PRIORITY, 100)
+            .put("index.refresh_interval", "1s")
+            .build();
+    }
+
+    private static XContentBuilder getIndexMappings() {
+        try {
+            final XContentBuilder builder = jsonBuilder();
+            builder.startObject();
+            {
+                builder.startObject("_meta");
+                builder.field("version", Version.CURRENT.toString());
+                builder.endObject();
+
+                builder.field("dynamic", "strict");
+                builder.startObject("properties");
+                {
+                    builder.startObject(SearchApplication.NAME_FIELD.getPreferredName());
+                    builder.field("type", "keyword");
+                    builder.endObject();
+
+                    builder.startObject(SearchApplication.INDICES_FIELD.getPreferredName());
+                    builder.field("type", "keyword");
+                    builder.endObject();
+
+                    builder.startObject(SearchApplication.ANALYTICS_COLLECTION_NAME_FIELD.getPreferredName());
+                    builder.field("type", "keyword");
+                    builder.endObject();
+
+                    builder.startObject(SearchApplication.UPDATED_AT_MILLIS_FIELD.getPreferredName());
+                    builder.field("type", "long");
+                    builder.endObject();
+
+                    builder.startObject(SearchApplication.BINARY_CONTENT_FIELD.getPreferredName());
+                    builder.field("type", "object");
+                    builder.field("enabled", "false");
+                    builder.endObject();
+                }
+                builder.endObject();
+            }
+            builder.endObject();
+            return builder;
+        } catch (IOException e) {
+            logger.fatal("Failed to build " + SEARCH_APPLICATION_CONCRETE_INDEX_NAME + " index mappings", e);
+            throw new UncheckedIOException("Failed to build " + SEARCH_APPLICATION_CONCRETE_INDEX_NAME + " index mappings", e);
+        }
+    }
+
+    /**
+     * Gets the {@link SearchApplication} from the index if present, or delegate a {@link ResourceNotFoundException} failure to the provided
+     * listener if not.
+     *
+     * @param resourceName The resource name.
+     * @param listener The action listener to invoke on response/failure.
+     */
+    public void getSearchApplication(String resourceName, ActionListener<SearchApplication> listener) {
+        final GetRequest getRequest = new GetRequest(SEARCH_APPLICATION_ALIAS_NAME).id(resourceName).realtime(true);
+        clientWithOrigin.get(getRequest, new DelegatingIndexNotFoundActionListener<>(resourceName, listener, (l, getResponse) -> {
+            if (getResponse.isExists() == false) {
+                l.onFailure(new ResourceNotFoundException(resourceName));
+                return;
+            }
+            final BytesReference source = getResponse.getSourceInternal();
+            final SearchApplication res = parseSearchApplicationBinaryFromSource(source);
+            l.onResponse(res);
+        }));
+    }
+
+    private static String getSearchAliasName(SearchApplication app) {
+        return app.name();
+    }
+
+    /**
+     * Creates or updates the {@link SearchApplication} in the underlying index.
+     *
+     * @param app The search application object.
+     * @param create If true, the search application must not already exist
+     * @param listener The action listener to invoke on response/failure.
+     */
+    public void putSearchApplication(SearchApplication app, boolean create, ActionListener<IndexResponse> listener) {
+        createOrUpdateAlias(app, new ActionListener<>() {
+            @Override
+            public void onResponse(AcknowledgedResponse acknowledgedResponse) {
+                updateSearchApplication(app, create, listener);
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                // Convert index not found failure from the alias API into an illegal argument
+                Exception failException = e;
+                if (e instanceof IndexNotFoundException) {
+                    failException = new IllegalArgumentException(e.getMessage(), e);
+                }
+                listener.onFailure(failException);
+            }
+        });
+    }
+
+    private void createOrUpdateAlias(SearchApplication app, ActionListener<AcknowledgedResponse> listener) {
+
+        final Metadata metadata = clusterService.state().metadata();
+        final String searchAliasName = getSearchAliasName(app);
+
+        IndicesAliasesRequestBuilder requestBuilder = null;
+        if (metadata.hasAlias(searchAliasName)) {
+            Set<String> currentAliases = metadata.aliasedIndices(searchAliasName).stream().map(Index::getName).collect(Collectors.toSet());
+            Set<String> targetAliases = Set.of(app.indices());
+
+            requestBuilder = updateAliasIndices(currentAliases, targetAliases, searchAliasName);
+
+        } else {
+            requestBuilder = client.admin().indices().prepareAliases().addAlias(app.indices(), searchAliasName);
+        }
+
+        requestBuilder.execute(listener);
+    }
+
+    private IndicesAliasesRequestBuilder updateAliasIndices(Set<String> currentAliases, Set<String> targetAliases, String searchAliasName) {
+
+        Set<String> deleteIndices = new HashSet<>(currentAliases);
+        deleteIndices.removeAll(targetAliases);
+
+        IndicesAliasesRequestBuilder aliasesRequestBuilder = client.admin().indices().prepareAliases();
+
+        // Always re-add aliases, as an index could have been removed manually and it must be restored
+        for (String newIndex : targetAliases) {
+            aliasesRequestBuilder.addAliasAction(IndicesAliasesRequest.AliasActions.add().index(newIndex).alias(searchAliasName));
+        }
+        for (String deleteIndex : deleteIndices) {
+            aliasesRequestBuilder.addAliasAction(IndicesAliasesRequest.AliasActions.remove().index(deleteIndex).alias(searchAliasName));
+        }
+
+        return aliasesRequestBuilder;
+    }
+
+    private void updateSearchApplication(SearchApplication app, boolean create, ActionListener<IndexResponse> listener) {
+        try (ReleasableBytesStreamOutput buffer = new ReleasableBytesStreamOutput(0, bigArrays.withCircuitBreaking())) {
+            try (XContentBuilder source = XContentFactory.jsonBuilder(buffer)) {
+                source.startObject()
+                    .field(SearchApplication.NAME_FIELD.getPreferredName(), app.name())
+                    .field(SearchApplication.INDICES_FIELD.getPreferredName(), app.indices())
+                    .field(SearchApplication.ANALYTICS_COLLECTION_NAME_FIELD.getPreferredName(), app.analyticsCollectionName())
+                    .field(SearchApplication.UPDATED_AT_MILLIS_FIELD.getPreferredName(), app.updatedAtMillis())
+                    .directFieldAsBase64(
+                        SearchApplication.BINARY_CONTENT_FIELD.getPreferredName(),
+                        os -> writeSearchApplicationBinaryWithVersion(app, os, clusterService.state().nodes().getMinNodeVersion())
+                    )
+                    .endObject();
+            }
+            DocWriteRequest.OpType opType = (create ? DocWriteRequest.OpType.CREATE : DocWriteRequest.OpType.INDEX);
+            final IndexRequest indexRequest = new IndexRequest(SEARCH_APPLICATION_ALIAS_NAME).opType(DocWriteRequest.OpType.INDEX)
+                .id(app.name())
+                .opType(opType)
+                .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE)
+                .source(buffer.bytes(), XContentType.JSON);
+            clientWithOrigin.index(indexRequest, listener);
+        } catch (Exception e) {
+            listener.onFailure(e);
+        }
+    }
+
+    private void deleteSearchApplication(String resourceName, ActionListener<DeleteResponse> listener) {
+
+        try {
+            final DeleteRequest deleteRequest = new DeleteRequest(SEARCH_APPLICATION_ALIAS_NAME).id(resourceName)
+                .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE);
+            clientWithOrigin.delete(
+                deleteRequest,
+                new DelegatingIndexNotFoundActionListener<>(resourceName, listener, (l, deleteResponse) -> {
+                    if (deleteResponse.getResult() == DocWriteResponse.Result.NOT_FOUND) {
+                        l.onFailure(new ResourceNotFoundException(resourceName));
+                        return;
+                    }
+                    l.onResponse(deleteResponse);
+                })
+            );
+        } catch (Exception e) {
+            listener.onFailure(e);
+        }
+    }
+
+    GetAliasesResponse getAlias(String searchAliasName) {
+        return client.admin().indices().getAliases(new GetAliasesRequest(searchAliasName)).actionGet();
+    }
+
+    private void removeAlias(String searchAliasName, ActionListener<AcknowledgedResponse> listener) {
+        IndicesAliasesRequest aliasesRequest = new IndicesAliasesRequest().addAliasAction(
+            IndicesAliasesRequest.AliasActions.remove().aliases(searchAliasName).indices("*")
+        );
+        client.admin()
+            .indices()
+            .aliases(
+                aliasesRequest,
+                new DelegatingIndexNotFoundActionListener<>(
+                    searchAliasName,
+                    listener,
+                    (l, acknowledgedResponse) -> l.onResponse(AcknowledgedResponse.TRUE)
+                )
+            );
+    }
+
+    /**
+     * Deletes both the provided {@param resourceName} in the underlying index as well as the associated alias,
+     * or delegate a failure to the provided listener if the resource does not exist or failed to delete.
+     *
+     * @param resourceName The name of the {@link SearchApplication} to delete.
+     * @param listener The action listener to invoke on response/failure.
+     *
+     */
+    public void deleteSearchApplicationAndAlias(String resourceName, ActionListener<DeleteResponse> listener) {
+        removeAlias(resourceName, new ActionListener<>() {
+            @Override
+            public void onResponse(AcknowledgedResponse acknowledgedResponse) {
+                deleteSearchApplication(resourceName, listener);
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                if (e instanceof AliasesNotFoundException) {
+                    deleteSearchApplication(resourceName, listener);
+                } else {
+                    listener.onFailure(e);
+                }
+            }
+        });
+    }
+
+    /**
+     * List the {@link SearchApplication} in ascending order of their names.
+     *
+     * @param queryString The query string to filter the results.
+     * @param from From index to start the search from.
+     * @param size The maximum number of {@link SearchApplication} to return.
+     * @param listener The action listener to invoke on response/failure.
+     */
+    public void listSearchApplication(String queryString, int from, int size, ActionListener<SearchApplicationResult> listener) {
+        try {
+            final SearchSourceBuilder source = new SearchSourceBuilder().from(from)
+                .size(size)
+                .query(new QueryStringQueryBuilder(queryString))
+                .docValueField(SearchApplication.NAME_FIELD.getPreferredName())
+                .docValueField(SearchApplication.INDICES_FIELD.getPreferredName())
+                .docValueField(SearchApplication.ANALYTICS_COLLECTION_NAME_FIELD.getPreferredName())
+                .docValueField(SearchApplication.UPDATED_AT_MILLIS_FIELD.getPreferredName())
+                .storedFields(Collections.singletonList("_none_"))
+                .sort(SearchApplication.NAME_FIELD.getPreferredName(), SortOrder.ASC);
+            final SearchRequest req = new SearchRequest(SEARCH_APPLICATION_ALIAS_NAME).source(source);
+            clientWithOrigin.search(req, new ActionListener<>() {
+                @Override
+                public void onResponse(SearchResponse searchResponse) {
+                    listener.onResponse(mapSearchResponse(searchResponse));
+                }
+
+                @Override
+                public void onFailure(Exception e) {
+                    if (e instanceof IndexNotFoundException) {
+                        listener.onResponse(new SearchApplicationResult(Collections.emptyList(), 0L));
+                        return;
+                    }
+                    listener.onFailure(e);
+                }
+            });
+        } catch (Exception e) {
+            listener.onFailure(e);
+        }
+    }
+
+    private static SearchApplicationResult mapSearchResponse(SearchResponse response) {
+        final List<SearchApplicationListItem> apps = Arrays.stream(response.getHits().getHits())
+            .map(SearchApplicationIndexService::hitToSearchApplicationListItem)
+            .toList();
+        return new SearchApplicationResult(apps, (int) response.getHits().getTotalHits().value);
+    }
+
+    private static SearchApplicationListItem hitToSearchApplicationListItem(SearchHit searchHit) {
+        final Map<String, DocumentField> documentFields = searchHit.getDocumentFields();
+        final String resourceName = documentFields.get(SearchApplication.NAME_FIELD.getPreferredName()).getValue();
+        return new SearchApplicationListItem(
+            resourceName,
+            documentFields.get(SearchApplication.INDICES_FIELD.getPreferredName()).getValues().toArray(String[]::new),
+            documentFields.get(SearchApplication.ANALYTICS_COLLECTION_NAME_FIELD.getPreferredName()).getValue(),
+            documentFields.get(SearchApplication.UPDATED_AT_MILLIS_FIELD.getPreferredName()).getValue()
+        );
+    }
+
+    private SearchApplication parseSearchApplicationBinaryFromSource(BytesReference source) {
+        try (XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, source, XContentType.JSON)) {
+            ensureExpectedToken(parser.nextToken(), XContentParser.Token.START_OBJECT, parser);
+            while (parser.nextToken() != XContentParser.Token.END_OBJECT) {
+                ensureExpectedToken(XContentParser.Token.FIELD_NAME, parser.currentToken(), parser);
+                parser.nextToken();
+                if (SearchApplication.BINARY_CONTENT_FIELD.getPreferredName().equals(parser.currentName())) {
+                    final CharBuffer encodedBuffer = parser.charBuffer();
+                    InputStream encodedIn = Base64.getDecoder().wrap(new InputStream() {
+                        @Override
+                        public int read() {
+                            if (encodedBuffer.hasRemaining()) {
+                                return encodedBuffer.get();
+                            } else {
+                                return -1; // end of stream
+                            }
+                        }
+                    });
+                    try (
+                        StreamInput in = new NamedWriteableAwareStreamInput(new InputStreamStreamInput(encodedIn), namedWriteableRegistry)
+                    ) {
+                        return parseSearchApplicationBinaryWithVersion(in);
+                    }
+                } else {
+                    XContentParserUtils.parseFieldsValue(parser); // consume and discard unknown fields
+                }
+            }
+            throw new ElasticsearchParseException("[" + SearchApplication.BINARY_CONTENT_FIELD.getPreferredName() + "] field is missing");
+        } catch (IOException e) {
+            throw new ElasticsearchParseException("Failed to parse: " + source.utf8ToString(), e);
+        }
+    }
+
+    static SearchApplication parseSearchApplicationBinaryWithVersion(StreamInput in) throws IOException {
+        TransportVersion version = TransportVersion.readVersion(in);
+        assert version.onOrBefore(TransportVersion.CURRENT) : version + " >= " + TransportVersion.CURRENT;
+        in.setTransportVersion(version);
+        return new SearchApplication(in);
+    }
+
+    static void writeSearchApplicationBinaryWithVersion(SearchApplication app, OutputStream os, Version minNodeVersion) throws IOException {
+        // do not close the output
+        os = Streams.noCloseStream(os);
+        TransportVersion.writeVersion(minNodeVersion.transportVersion, new OutputStreamStreamOutput(os));
+        try (OutputStreamStreamOutput out = new OutputStreamStreamOutput(os)) {
+            out.setTransportVersion(minNodeVersion.transportVersion);
+            app.writeTo(out);
+        }
+    }
+
+    static class DelegatingIndexNotFoundActionListener<T, R> extends DelegatingActionListener<T, R> {
+
+        private final BiConsumer<ActionListener<R>, T> bc;
+        private final String resourceName;
+
+        DelegatingIndexNotFoundActionListener(String resourceName, ActionListener<R> delegate, BiConsumer<ActionListener<R>, T> bc) {
+            super(delegate);
+            this.bc = bc;
+            this.resourceName = resourceName;
+        }
+
+        @Override
+        public void onResponse(T t) {
+            bc.accept(delegate, t);
+        }
+
+        @Override
+        public void onFailure(Exception e) {
+            if (e instanceof IndexNotFoundException) {
+                delegate.onFailure(new ResourceNotFoundException(resourceName, e));
+                return;
+            }
+            delegate.onFailure(e);
+        }
+    }
+
+    public record SearchApplicationResult(List<SearchApplicationListItem> items, long totalResults) {}
+}

+ 139 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/SearchApplicationListItem.java

@@ -0,0 +1,139 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search;
+
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * This class is used for returning information for lists of search applications, to avoid including all
+ * {@link SearchApplication} information which can be retrieved using subsequent Get Search Application requests.
+ */
+public class SearchApplicationListItem implements Writeable, ToXContentObject {
+
+    public static final ParseField NAME_FIELD = new ParseField("name");
+    public static final ParseField INDICES_FIELD = new ParseField("indices");
+    public static final ParseField ANALYTICS_COLLECTION_NAME_FIELD = new ParseField("analytics_collection_name");
+
+    public static final ParseField UPDATED_AT_MILLIS_FIELD = new ParseField("updated_at_millis");
+    private final String name;
+    private final String[] indices;
+    private final String analyticsCollectionName;
+
+    private final long updatedAtMillis;
+
+    /**
+     * Constructs a SearchApplicationListItem.
+     *
+     * @param name The name of the search application
+     * @param indices The indices associated with the search application
+     * @param analyticsCollectionName The analytics collection associated with this application if one exists
+     * @param updatedAtMillis The timestamp in milliseconds when this search application was last updated.
+     */
+    public SearchApplicationListItem(String name, String[] indices, @Nullable String analyticsCollectionName, long updatedAtMillis) {
+        Objects.requireNonNull(name, "Name cannot be null on a SearchApplicationListItem");
+        this.name = name;
+
+        Objects.requireNonNull(name, "Indices cannot be null on a SearchApplicationListItem");
+        this.indices = indices;
+
+        this.analyticsCollectionName = analyticsCollectionName;
+        this.updatedAtMillis = updatedAtMillis;
+    }
+
+    public SearchApplicationListItem(StreamInput in) throws IOException {
+        this.name = in.readString();
+        this.indices = in.readStringArray();
+        this.analyticsCollectionName = in.readOptionalString();
+        this.updatedAtMillis = in.readLong();
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(NAME_FIELD.getPreferredName(), name);
+        builder.field(INDICES_FIELD.getPreferredName(), indices);
+        if (analyticsCollectionName != null) {
+            builder.field(ANALYTICS_COLLECTION_NAME_FIELD.getPreferredName(), analyticsCollectionName);
+        }
+        builder.field(UPDATED_AT_MILLIS_FIELD.getPreferredName(), updatedAtMillis);
+        builder.endObject();
+        return builder;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeString(name);
+        out.writeStringArray(indices);
+        out.writeOptionalString(analyticsCollectionName);
+        out.writeLong(updatedAtMillis);
+    }
+
+    /**
+     * Returns the name of the {@link SearchApplicationListItem}.
+     *
+     * @return the name.
+     */
+    public String name() {
+        return name;
+    }
+
+    /**
+     * Returns the indices associated with the {@link SearchApplicationListItem}.
+     *
+     * @return the indices.
+     */
+    public String[] indices() {
+        return indices;
+    }
+
+    /**
+     * Returns the analytics collection associated with the {@link SearchApplicationListItem} if one exists.
+     *
+     * @return the analytics collection.
+     */
+    public String analyticsCollectionName() {
+        return analyticsCollectionName;
+    }
+
+    /**
+     * Returns the timestamp in milliseconds when the {@link SearchApplicationListItem} was last modified.
+     *
+     * @return the last updated timestamp in milliseconds.
+     */
+    public long updatedAtMillis() {
+        return updatedAtMillis;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        SearchApplicationListItem item = (SearchApplicationListItem) o;
+        return name.equals(item.name)
+            && Arrays.equals(indices, item.indices)
+            && Objects.equals(analyticsCollectionName, item.analyticsCollectionName)
+            && updatedAtMillis == item.updatedAtMillis;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = Objects.hash(name, analyticsCollectionName, updatedAtMillis);
+        result = 31 * result + Arrays.hashCode(indices);
+        return result;
+    }
+}

+ 78 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/DeleteSearchApplicationAction.java

@@ -0,0 +1,78 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class DeleteSearchApplicationAction extends ActionType<AcknowledgedResponse> {
+
+    public static final DeleteSearchApplicationAction INSTANCE = new DeleteSearchApplicationAction();
+    public static final String NAME = "cluster:admin/xpack/application/search_application/delete";
+
+    private DeleteSearchApplicationAction() {
+        super(NAME, AcknowledgedResponse::readFrom);
+    }
+
+    public static class Request extends ActionRequest {
+        private final String name;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.name = in.readString();
+        }
+
+        public Request(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (name == null || name.isEmpty()) {
+                validationException = addValidationError("Name missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(name);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request that = (Request) o;
+            return Objects.equals(name, that.name);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name);
+        }
+    }
+
+}

+ 121 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/GetSearchApplicationAction.java

@@ -0,0 +1,121 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.application.search.SearchApplication;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class GetSearchApplicationAction extends ActionType<GetSearchApplicationAction.Response> {
+
+    public static final GetSearchApplicationAction INSTANCE = new GetSearchApplicationAction();
+    public static final String NAME = "cluster:admin/xpack/application/search_application/get";
+
+    private GetSearchApplicationAction() {
+        super(NAME, GetSearchApplicationAction.Response::new);
+    }
+
+    public static class Request extends ActionRequest {
+        private final String name;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.name = in.readString();
+        }
+
+        public Request(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (name == null || name.isEmpty()) {
+                validationException = addValidationError("name missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        public String getName() {
+            return name;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(name);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request request = (Request) o;
+            return Objects.equals(name, request.name);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name);
+        }
+    }
+
+    public static class Response extends ActionResponse implements ToXContentObject {
+
+        private final SearchApplication searchApp;
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            this.searchApp = new SearchApplication(in);
+        }
+
+        public Response(SearchApplication app) {
+            this.searchApp = app;
+        }
+
+        public Response(String name, String[] indices, String analyticsCollectionName, long updatedAtMillis) {
+            this.searchApp = new SearchApplication(name, indices, analyticsCollectionName, updatedAtMillis);
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            searchApp.writeTo(out);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            return searchApp.toXContent(builder, params);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response response = (Response) o;
+            return Objects.equals(searchApp, response.searchApp);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(searchApp);
+        }
+    }
+}

+ 139 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/ListSearchApplicationAction.java

@@ -0,0 +1,139 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.ValidateActions;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.StatusToXContentObject;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.application.search.SearchApplicationListItem;
+import org.elasticsearch.xpack.core.action.util.PageParams;
+import org.elasticsearch.xpack.core.action.util.QueryPage;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+public class ListSearchApplicationAction extends ActionType<ListSearchApplicationAction.Response> {
+
+    public static final ListSearchApplicationAction INSTANCE = new ListSearchApplicationAction();
+    public static final String NAME = "cluster:admin/xpack/application/search_application/list";
+
+    public ListSearchApplicationAction() {
+        super(NAME, ListSearchApplicationAction.Response::new);
+    }
+
+    public static class Request extends ActionRequest {
+
+        private static final String DEFAULT_QUERY = "*";
+        private final String query;
+        private final PageParams pageParams;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.query = in.readString();
+            this.pageParams = new PageParams(in);
+        }
+
+        public Request(@Nullable String query, PageParams pageParams) {
+            this.query = Objects.requireNonNullElse(query, DEFAULT_QUERY);
+            this.pageParams = pageParams;
+        }
+
+        public String query() {
+            return query;
+        }
+
+        public PageParams pageParams() {
+            return pageParams;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            // Pagination validation is done as part of PageParams constructor
+            ActionRequestValidationException validationException = null;
+            if (Strings.isEmpty(query())) {
+                validationException = ValidateActions.addValidationError("Search Application query is missing", validationException);
+            }
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(query);
+            pageParams.writeTo(out);
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request that = (Request) o;
+            return Objects.equals(query, that.query) && Objects.equals(pageParams, that.pageParams);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(query, pageParams);
+        }
+    }
+
+    public static class Response extends ActionResponse implements StatusToXContentObject {
+
+        public static final ParseField RESULT_FIELD = new ParseField("results");
+
+        final QueryPage<SearchApplicationListItem> queryPage;
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            this.queryPage = new QueryPage<>(in, SearchApplicationListItem::new);
+        }
+
+        public Response(List<SearchApplicationListItem> items, Long totalResults) {
+            this.queryPage = new QueryPage<>(items, totalResults, RESULT_FIELD);
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            queryPage.writeTo(out);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            return queryPage.toXContent(builder, params);
+        }
+
+        @Override
+        public RestStatus status() {
+            return RestStatus.OK;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response that = (Response) o;
+            return queryPage.equals(that.queryPage);
+        }
+
+        @Override
+        public int hashCode() {
+            return queryPage.hashCode();
+        }
+    }
+}

+ 149 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/PutSearchApplicationAction.java

@@ -0,0 +1,149 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.DocWriteResponse;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.xcontent.StatusToXContentObject;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.application.search.SearchApplication;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class PutSearchApplicationAction extends ActionType<PutSearchApplicationAction.Response> {
+
+    public static final PutSearchApplicationAction INSTANCE = new PutSearchApplicationAction();
+    public static final String NAME = "cluster:admin/xpack/application/search_application/put";
+
+    public PutSearchApplicationAction() {
+        super(NAME, PutSearchApplicationAction.Response::new);
+    }
+
+    public static class Request extends ActionRequest {
+
+        private final SearchApplication searchApp;
+        private final boolean create;
+
+        public Request(StreamInput in) throws IOException {
+            super(in);
+            this.searchApp = new SearchApplication(in);
+            this.create = in.readBoolean();
+        }
+
+        public Request(String name, boolean create, BytesReference content, XContentType contentType) {
+            this.searchApp = SearchApplication.fromXContentBytes(name, content, contentType);
+            this.create = create;
+        }
+
+        public Request(SearchApplication app, boolean create) {
+            this.searchApp = app;
+            this.create = create;
+        }
+
+        @Override
+        public ActionRequestValidationException validate() {
+            ActionRequestValidationException validationException = null;
+
+            if (searchApp.indices().length == 0) {
+                validationException = addValidationError("indices are missing", validationException);
+            }
+
+            return validationException;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            searchApp.writeTo(out);
+            out.writeBoolean(create);
+        }
+
+        public SearchApplication getSearchApplication() {
+            return searchApp;
+        }
+
+        public boolean create() {
+            return create;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Request that = (Request) o;
+            return Objects.equals(searchApp, that.searchApp) && create == that.create;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(searchApp, create);
+        }
+    }
+
+    public static class Response extends ActionResponse implements StatusToXContentObject {
+
+        final DocWriteResponse.Result result;
+
+        public Response(StreamInput in) throws IOException {
+            super(in);
+            result = DocWriteResponse.Result.readFrom(in);
+        }
+
+        public Response(DocWriteResponse.Result result) {
+            this.result = result;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            this.result.writeTo(out);
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            builder.field("result", this.result.getLowercase());
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public RestStatus status() {
+            return switch (result) {
+                case CREATED -> RestStatus.CREATED;
+                case NOT_FOUND -> RestStatus.NOT_FOUND;
+                default -> RestStatus.OK;
+            };
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            Response that = (Response) o;
+            return Objects.equals(result, that.result);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(result);
+        }
+
+    }
+
+}

+ 37 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestDeleteSearchApplicationAction.java

@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.DELETE;
+
+public class RestDeleteSearchApplicationAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "search_application_delete_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(DELETE, "/" + EnterpriseSearch.SEARCH_APPLICATION_API_ENDPOINT + "/{name}"));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) {
+        DeleteSearchApplicationAction.Request request = new DeleteSearchApplicationAction.Request(restRequest.param("name"));
+        return channel -> client.execute(DeleteSearchApplicationAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 37 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestGetSearchApplicationAction.java

@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+
+public class RestGetSearchApplicationAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "search_application_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(GET, "/" + EnterpriseSearch.SEARCH_APPLICATION_API_ENDPOINT + "/{name}"));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) {
+        GetSearchApplicationAction.Request request = new GetSearchApplicationAction.Request(restRequest.param("name"));
+        return channel -> client.execute(GetSearchApplicationAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 45 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestListSearchApplicationAction.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+import org.elasticsearch.xpack.core.action.util.PageParams;
+
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+
+public class RestListSearchApplicationAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "search_application_list_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(GET, "/" + EnterpriseSearch.SEARCH_APPLICATION_API_ENDPOINT));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) {
+
+        int from = restRequest.paramAsInt("from", PageParams.DEFAULT_FROM);
+        int size = restRequest.paramAsInt("size", PageParams.DEFAULT_SIZE);
+        ListSearchApplicationAction.Request request = new ListSearchApplicationAction.Request(
+            restRequest.param("q"),
+            new PageParams(from, size)
+        );
+
+        return channel -> client.execute(ListSearchApplicationAction.INSTANCE, request, new RestToXContentListener<>(channel));
+    }
+}

+ 49 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/RestPutSearchApplicationAction.java

@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.rest.BaseRestHandler;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.application.EnterpriseSearch;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.rest.RestRequest.Method.PUT;
+
+public class RestPutSearchApplicationAction extends BaseRestHandler {
+
+    @Override
+    public String getName() {
+        return "search_application_put_action";
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(PUT, "/" + EnterpriseSearch.SEARCH_APPLICATION_API_ENDPOINT + "/{name}"));
+    }
+
+    @Override
+    protected RestChannelConsumer prepareRequest(RestRequest restRequest, NodeClient client) throws IOException {
+        PutSearchApplicationAction.Request request = new PutSearchApplicationAction.Request(
+            restRequest.param("name"),
+            restRequest.paramAsBoolean("create", false),
+            restRequest.content(),
+            restRequest.getXContentType()
+        );
+        return channel -> client.execute(PutSearchApplicationAction.INSTANCE, request, new RestToXContentListener<>(channel) {
+            @Override
+            protected RestStatus getStatus(PutSearchApplicationAction.Response response) {
+                return response.status();
+            }
+        });
+    }
+}

+ 56 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/SearchApplicationTransportAction.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.search.SearchApplicationIndexService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+public abstract class SearchApplicationTransportAction<Request extends ActionRequest, Response extends ActionResponse> extends
+    HandledTransportAction<Request, Response> {
+
+    protected final XPackLicenseState licenseState;
+
+    protected final SearchApplicationIndexService systemIndexService;
+
+    public SearchApplicationTransportAction(
+        String actionName,
+        TransportService transportService,
+        ActionFilters actionFilters,
+        Writeable.Reader<Request> request,
+        Client client,
+        ClusterService clusterService,
+        NamedWriteableRegistry namedWriteableRegistry,
+        BigArrays bigArrays,
+        XPackLicenseState licenseState
+    ) {
+        super(actionName, transportService, actionFilters, request);
+        this.licenseState = licenseState;
+        this.systemIndexService = new SearchApplicationIndexService(client, clusterService, namedWriteableRegistry, bigArrays);
+
+    }
+
+    @Override
+    public final void doExecute(Task task, final Request request, ActionListener<Response> listener) {
+        LicenseUtils.runIfSupportedLicense(licenseState, () -> doExecute(request, listener), listener::onFailure);
+    }
+
+    protected abstract void doExecute(Request request, ActionListener<Response> listener);
+}

+ 53 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportDeleteSearchApplicationAction.java

@@ -0,0 +1,53 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.transport.TransportService;
+
+public class TransportDeleteSearchApplicationAction extends SearchApplicationTransportAction<
+    DeleteSearchApplicationAction.Request,
+    AcknowledgedResponse> {
+
+    @Inject
+    public TransportDeleteSearchApplicationAction(
+        TransportService transportService,
+        ActionFilters actionFilters,
+        Client client,
+        ClusterService clusterService,
+        NamedWriteableRegistry namedWriteableRegistry,
+        BigArrays bigArrays,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            DeleteSearchApplicationAction.NAME,
+            transportService,
+            actionFilters,
+            DeleteSearchApplicationAction.Request::new,
+            client,
+            clusterService,
+            namedWriteableRegistry,
+            bigArrays,
+            licenseState
+        );
+    }
+
+    @Override
+    protected void doExecute(DeleteSearchApplicationAction.Request request, ActionListener<AcknowledgedResponse> listener) {
+        String name = request.getName();
+        systemIndexService.deleteSearchApplicationAndAlias(name, listener.map(v -> AcknowledgedResponse.TRUE));
+    }
+}

+ 52 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportGetSearchApplicationAction.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.transport.TransportService;
+
+public class TransportGetSearchApplicationAction extends SearchApplicationTransportAction<
+    GetSearchApplicationAction.Request,
+    GetSearchApplicationAction.Response> {
+
+    @Inject
+    public TransportGetSearchApplicationAction(
+        TransportService transportService,
+        ActionFilters actionFilters,
+        Client client,
+        ClusterService clusterService,
+        NamedWriteableRegistry namedWriteableRegistry,
+        BigArrays bigArrays,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            GetSearchApplicationAction.NAME,
+            transportService,
+            actionFilters,
+            GetSearchApplicationAction.Request::new,
+            client,
+            clusterService,
+            namedWriteableRegistry,
+            bigArrays,
+            licenseState
+        );
+    }
+
+    @Override
+    protected void doExecute(GetSearchApplicationAction.Request request, ActionListener<GetSearchApplicationAction.Response> listener) {
+        systemIndexService.getSearchApplication(request.getName(), listener.map(GetSearchApplicationAction.Response::new));
+    }
+
+}

+ 58 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportListSearchApplicationAction.java

@@ -0,0 +1,58 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.action.util.PageParams;
+
+public class TransportListSearchApplicationAction extends SearchApplicationTransportAction<
+    ListSearchApplicationAction.Request,
+    ListSearchApplicationAction.Response> {
+
+    @Inject
+    public TransportListSearchApplicationAction(
+        TransportService transportService,
+        ActionFilters actionFilters,
+        Client client,
+        ClusterService clusterService,
+        NamedWriteableRegistry namedWriteableRegistry,
+        BigArrays bigArrays,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            ListSearchApplicationAction.NAME,
+            transportService,
+            actionFilters,
+            ListSearchApplicationAction.Request::new,
+            client,
+            clusterService,
+            namedWriteableRegistry,
+            bigArrays,
+            licenseState
+        );
+    }
+
+    @Override
+    protected void doExecute(ListSearchApplicationAction.Request request, ActionListener<ListSearchApplicationAction.Response> listener) {
+        final PageParams pageParams = request.pageParams();
+        systemIndexService.listSearchApplication(
+            request.query(),
+            pageParams.getFrom(),
+            pageParams.getSize(),
+            listener.map(r -> new ListSearchApplicationAction.Response(r.items(), r.totalResults()))
+        );
+    }
+}

+ 54 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/search/action/TransportPutSearchApplicationAction.java

@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.search.SearchApplication;
+
+public class TransportPutSearchApplicationAction extends SearchApplicationTransportAction<
+    PutSearchApplicationAction.Request,
+    PutSearchApplicationAction.Response> {
+
+    @Inject
+    public TransportPutSearchApplicationAction(
+        TransportService transportService,
+        ActionFilters actionFilters,
+        Client client,
+        ClusterService clusterService,
+        NamedWriteableRegistry namedWriteableRegistry,
+        BigArrays bigArrays,
+        XPackLicenseState licenseState
+    ) {
+        super(
+            PutSearchApplicationAction.NAME,
+            transportService,
+            actionFilters,
+            PutSearchApplicationAction.Request::new,
+            client,
+            clusterService,
+            namedWriteableRegistry,
+            bigArrays,
+            licenseState
+        );
+    }
+
+    @Override
+    protected void doExecute(PutSearchApplicationAction.Request request, ActionListener<PutSearchApplicationAction.Response> listener) {
+        SearchApplication app = request.getSearchApplication();
+        boolean create = request.create();
+        systemIndexService.putSearchApplication(app, create, listener.map(r -> new PutSearchApplicationAction.Response(r.getResult())));
+    }
+}

+ 49 - 0
x-pack/plugin/ent-search/src/main/java/org/elasticsearch/xpack/application/utils/LicenseUtils.java

@@ -0,0 +1,49 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.utils;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.license.License;
+import org.elasticsearch.license.LicensedFeature;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.xpack.core.XPackField;
+
+import java.util.function.Consumer;
+
+public final class LicenseUtils {
+    public static final LicensedFeature.Momentary LICENSED_ENT_SEARCH_FEATURE = LicensedFeature.momentary(
+        null,
+        XPackField.ENTERPRISE_SEARCH,
+        License.OperationMode.PLATINUM
+    );
+
+    public static boolean supportedLicense(XPackLicenseState licenseState) {
+        return LICENSED_ENT_SEARCH_FEATURE.check(licenseState);
+    }
+
+    public static ElasticsearchSecurityException newComplianceException(XPackLicenseState licenseState) {
+        String licenseStatus = licenseState.statusDescription();
+
+        ElasticsearchSecurityException e = new ElasticsearchSecurityException(
+            "Current license is non-compliant for search application and behavioral analytics. Current license is {}. "
+                + "Search Applications and behavioral analytics require an active trial, platinum or enterprise license.",
+            RestStatus.FORBIDDEN,
+            licenseStatus
+        );
+        return e;
+    }
+
+    public static void runIfSupportedLicense(XPackLicenseState licenseState, Runnable onSuccess, Consumer<Exception> onFailure) {
+        if (supportedLicense(licenseState)) {
+            onSuccess.run();
+        } else {
+            onFailure.accept(newComplianceException(licenseState));
+        }
+    }
+}

+ 172 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionResolverTests.java

@@ -0,0 +1,172 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.ResourceNotFoundException;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.metadata.DataStream;
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.metadata.Metadata;
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.elasticsearch.cluster.metadata.DataStreamTestHelper.createBackingIndex;
+import static org.elasticsearch.cluster.metadata.DataStreamTestHelper.newInstance;
+import static org.hamcrest.Matchers.hasSize;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class AnalyticsCollectionResolverTests extends ESTestCase {
+
+    public void testResolveExistingAnalyticsCollection() {
+        String collectionName = randomIdentifier();
+        String dataStreamName = AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collectionName;
+
+        ClusterState state = createClusterState(dataStreamName);
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(mock(IndexNameExpressionResolver.class));
+
+        AnalyticsCollection collection = resolver.collection(state, collectionName);
+
+        assertEquals(collection.getName(), collectionName);
+        assertEquals(collection.getEventDataStream(), dataStreamName);
+    }
+
+    public void testResolveMissingAnalyticsCollection() {
+        String collectionName = randomIdentifier();
+
+        ClusterState state = createClusterState();
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(mock(IndexNameExpressionResolver.class));
+
+        expectThrows(
+            ResourceNotFoundException.class,
+            "no such analytics collection [" + collectionName + "]",
+            () -> resolver.collection(state, collectionName)
+        );
+    }
+
+    public void testResolveMatchAllCollections() {
+        String collectionName1 = randomIdentifier();
+        String collectionName2 = randomIdentifier();
+
+        ClusterState state = createClusterState();
+        IndexNameExpressionResolver indexNameExpressionResolver = mock(IndexNameExpressionResolver.class);
+        when(indexNameExpressionResolver.dataStreamNames(eq(state), any(), any())).thenReturn(
+            Arrays.asList(
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collectionName1,
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collectionName2
+            )
+        );
+
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(indexNameExpressionResolver);
+        List<AnalyticsCollection> collections = resolver.collections(state, "*");
+
+        assertThat(collections, hasSize(2));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals(collectionName1)));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals(collectionName2)));
+    }
+
+    public void testResolveEmptyExpressionCollections() {
+        String collectionName = randomIdentifier();
+        String dataStreamName = AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collectionName;
+        ClusterState state = createClusterState();
+        IndexNameExpressionResolver indexNameExpressionResolver = mock(IndexNameExpressionResolver.class);
+        when(indexNameExpressionResolver.dataStreamNames(eq(state), any(), any())).thenReturn(Collections.singletonList(dataStreamName));
+
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(indexNameExpressionResolver);
+        List<AnalyticsCollection> collections = resolver.collections(state);
+
+        assertThat(collections, hasSize(1));
+        assertEquals(collections.get(0).getName(), collectionName);
+    }
+
+    public void testResolveWildcardExpressionCollections() {
+        ClusterState state = createClusterState();
+        IndexNameExpressionResolver indexNameExpressionResolver = mock(IndexNameExpressionResolver.class);
+        when(indexNameExpressionResolver.dataStreamNames(eq(state), any(), any())).thenReturn(
+            Arrays.asList(
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "foo",
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "bar",
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "baz",
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "buz"
+            )
+        );
+
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(indexNameExpressionResolver);
+        List<AnalyticsCollection> collections = resolver.collections(state, "ba*");
+        assertThat(collections, hasSize(2));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals("bar")));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals("baz")));
+    }
+
+    public void testResolveMultipleExpressionsCollections() {
+        ClusterState state = createClusterState();
+        IndexNameExpressionResolver indexNameExpressionResolver = mock(IndexNameExpressionResolver.class);
+        when(indexNameExpressionResolver.dataStreamNames(eq(state), any(), any())).thenReturn(
+            Arrays.asList(
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "foo",
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "bar",
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "baz",
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "buz"
+            )
+        );
+
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(indexNameExpressionResolver);
+        List<AnalyticsCollection> collections = resolver.collections(state, "foo", "ba*");
+        assertThat(collections, hasSize(3));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals("foo")));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals("bar")));
+        assertTrue(collections.stream().anyMatch(collection -> collection.getName().equals("baz")));
+    }
+
+    public void testResolveMultipleExpressionsWithMissingCollection() {
+        ClusterState state = createClusterState();
+        IndexNameExpressionResolver indexNameExpressionResolver = mock(IndexNameExpressionResolver.class);
+        when(indexNameExpressionResolver.dataStreamNames(eq(state), any(), any())).thenReturn(
+            Collections.singletonList(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "foo")
+        );
+
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(indexNameExpressionResolver);
+        expectThrows(
+            ResourceNotFoundException.class,
+            "no such analytics collection [bar]",
+            () -> resolver.collections(state, "foo", "bar")
+        );
+    }
+
+    public void testResolveWildcardWithNoMatchCollection() {
+        ClusterState state = createClusterState();
+        IndexNameExpressionResolver indexNameExpressionResolver = mock(IndexNameExpressionResolver.class);
+        when(indexNameExpressionResolver.dataStreamNames(eq(state), any(), any())).thenReturn(
+            Collections.singletonList(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + "foo")
+        );
+        AnalyticsCollectionResolver resolver = new AnalyticsCollectionResolver(indexNameExpressionResolver);
+        assertTrue(resolver.collections(state, "ba*").isEmpty());
+    }
+
+    private ClusterState createClusterState(String... dataStreams) {
+        ClusterState state = mock(ClusterState.class);
+
+        Metadata.Builder metaDataBuilder = Metadata.builder();
+
+        for (String dataStreamName : dataStreams) {
+            IndexMetadata backingIndex = createBackingIndex(dataStreamName, 1).build();
+            DataStream dataStream = newInstance(dataStreamName, List.of(backingIndex.getIndex()));
+            metaDataBuilder.put(backingIndex, false).put(dataStream);
+        }
+
+        when(state.metadata()).thenReturn(metaDataBuilder.build());
+
+        return state;
+    }
+}

+ 471 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionServiceTests.java

@@ -0,0 +1,471 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.apache.logging.log4j.util.TriConsumer;
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.ElasticsearchStatusException;
+import org.elasticsearch.ResourceAlreadyExistsException;
+import org.elasticsearch.ResourceNotFoundException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.datastreams.CreateDataStreamAction;
+import org.elasticsearch.action.datastreams.DeleteDataStreamAction;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.node.DiscoveryNodes;
+import org.elasticsearch.common.TriFunction;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.client.NoOpClient;
+import org.elasticsearch.threadpool.TestThreadPool;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xpack.application.analytics.action.DeleteAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.GetAnalyticsCollectionAction;
+import org.elasticsearch.xpack.application.analytics.action.PutAnalyticsCollectionAction;
+import org.junit.After;
+import org.junit.Before;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class AnalyticsCollectionServiceTests extends ESTestCase {
+
+    private ThreadPool threadPool;
+
+    private VerifyingClient client;
+
+    @Before
+    public void createClient() {
+        threadPool = new TestThreadPool(this.getClass().getName());
+        client = new VerifyingClient(threadPool);
+    }
+
+    @After
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        threadPool.shutdownNow();
+    }
+
+    public void testGetExistingAnalyticsCollection() throws Exception {
+        String collectionName = randomIdentifier();
+
+        ClusterState clusterState = createClusterState();
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+        when(analyticsCollectionResolver.collections(eq(clusterState), eq(collectionName))).thenReturn(
+            Collections.singletonList(new AnalyticsCollection(collectionName))
+        );
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(mock(Client.class), analyticsCollectionResolver);
+
+        List<AnalyticsCollection> collections = awaitGetAnalyticsCollections(analyticsService, clusterState, collectionName);
+        assertThat(collections.get(0).getName(), equalTo(collectionName));
+        assertThat(collections, hasSize(1));
+    }
+
+    public void testGetMissingAnalyticsCollection() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+        when(analyticsCollectionResolver.collections(eq(clusterState), eq(collectionName))).thenThrow(
+            new ResourceNotFoundException(collectionName)
+        );
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(mock(Client.class), analyticsCollectionResolver);
+
+        expectThrows(
+            ResourceNotFoundException.class,
+            collectionName,
+            () -> awaitGetAnalyticsCollections(analyticsService, clusterState, collectionName)
+        );
+    }
+
+    public void testGetAnalyticsCollectionOnNonMasterNode() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState(false);
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(mock(Client.class), analyticsCollectionResolver);
+
+        expectThrows(AssertionError.class, () -> awaitGetAnalyticsCollections(analyticsService, clusterState, collectionName));
+    }
+
+    public void testCreateAnalyticsCollection() throws Exception {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+        when(analyticsCollectionResolver.collection(eq(clusterState), eq(collectionName))).thenThrow(
+            new ResourceNotFoundException(collectionName)
+        );
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof CreateDataStreamAction) {
+                CreateDataStreamAction.Request createDataStreamRequest = (CreateDataStreamAction.Request) request;
+                assertThat(
+                    createDataStreamRequest.getName(),
+                    equalTo(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collectionName)
+                );
+                calledTimes.incrementAndGet();
+                return AcknowledgedResponse.TRUE;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        PutAnalyticsCollectionAction.Response response = awaitPutAnalyticsCollection(analyticsService, clusterState, collectionName);
+
+        // Assert the response is acknowledged.
+        assertTrue(response.isAcknowledged());
+        assertThat(response.getName(), equalTo(collectionName));
+
+        // Assert the data stream has been created.
+        assertEquals(calledTimes.get(), 1);
+    }
+
+    public void testCreateAlreadyExistingAnalyticsCollection() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof CreateDataStreamAction) {
+                CreateDataStreamAction.Request createDataStreamRequest = (CreateDataStreamAction.Request) request;
+                throw new ResourceAlreadyExistsException(createDataStreamRequest.getName());
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        expectThrows(
+            ResourceAlreadyExistsException.class,
+            "analytics collection [" + collectionName + "] already exists",
+            () -> awaitPutAnalyticsCollection(analyticsService, clusterState, collectionName)
+        );
+    }
+
+    public void testCreateAnalyticsCollectionESException() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+
+        ElasticsearchStatusException dataStreamCreateException = new ElasticsearchStatusException(
+            "message",
+            randomFrom(RestStatus.values())
+        );
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof CreateDataStreamAction) {
+                throw dataStreamCreateException;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        ElasticsearchException createCollectionException = expectThrows(
+            ElasticsearchException.class,
+            "error while creating analytics collection [" + collectionName + "]",
+            () -> awaitPutAnalyticsCollection(analyticsService, clusterState, collectionName)
+        );
+
+        assertNotNull(createCollectionException.getCause());
+        assertEquals(dataStreamCreateException.status(), createCollectionException.status());
+        assertEquals(dataStreamCreateException, createCollectionException.getCause());
+    }
+
+    public void testCreateAnalyticsCollectionGenericException() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+
+        RuntimeException dataStreamCreateException = new RuntimeException("message");
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof CreateDataStreamAction) {
+                throw dataStreamCreateException;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        ElasticsearchException createCollectionException = expectThrows(
+            ElasticsearchException.class,
+            "error while creating analytics collection [" + collectionName + "]",
+            () -> awaitPutAnalyticsCollection(analyticsService, clusterState, collectionName)
+        );
+
+        assertNotNull(createCollectionException.getCause());
+        assertEquals(RestStatus.INTERNAL_SERVER_ERROR, createCollectionException.status());
+        assertEquals(dataStreamCreateException, createCollectionException.getCause());
+    }
+
+    public void testCreateAnalyticsCollectionOnNonMasterNode() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState(false);
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(mock(Client.class), analyticsCollectionResolver);
+
+        expectThrows(AssertionError.class, () -> awaitPutAnalyticsCollection(analyticsService, clusterState, collectionName));
+    }
+
+    public void testDeleteAnalyticsCollection() throws Exception {
+        String collectionName = randomIdentifier();
+        String dataStreamName = AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collectionName;
+        ClusterState clusterState = createClusterState();
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof DeleteDataStreamAction) {
+                DeleteDataStreamAction.Request deleteDataStreamRequest = (DeleteDataStreamAction.Request) request;
+
+                assertEquals(deleteDataStreamRequest.getNames().length, 1);
+                assertEquals(deleteDataStreamRequest.getNames()[0], dataStreamName);
+                calledTimes.incrementAndGet();
+                return AcknowledgedResponse.TRUE;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        AcknowledgedResponse response = awaitDeleteAnalyticsCollection(analyticsService, clusterState, collectionName);
+
+        // Assert the response is acknowledged.
+        assertThat(response.isAcknowledged(), equalTo(true));
+
+        // Assert the data stream has been deleted.
+        assertEquals(calledTimes.get(), 1);
+    }
+
+    public void testDeleteMissingAnalyticsCollection() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof DeleteDataStreamAction) {
+                DeleteDataStreamAction.Request deleteDataStreamRequest = (DeleteDataStreamAction.Request) request;
+                throw new ResourceNotFoundException(deleteDataStreamRequest.getNames()[0]);
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        expectThrows(
+            ResourceNotFoundException.class,
+            "analytics collection [" + collectionName + "] does not exists",
+            () -> awaitDeleteAnalyticsCollection(analyticsService, clusterState, collectionName)
+        );
+    }
+
+    public void testDeleteMissingAnalyticsCollectionESException() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+        ElasticsearchStatusException deleteDataStreamException = new ElasticsearchStatusException(
+            "message",
+            randomFrom(RestStatus.values())
+        );
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof DeleteDataStreamAction) {
+                throw deleteDataStreamException;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        ElasticsearchException deleteCollectionException = expectThrows(
+            ElasticsearchException.class,
+            "error while deleting analytics collection [" + collectionName + "]",
+            () -> awaitDeleteAnalyticsCollection(analyticsService, clusterState, collectionName)
+        );
+
+        assertNotNull(deleteCollectionException.getCause());
+        assertEquals(deleteDataStreamException.status(), deleteCollectionException.status());
+        assertEquals(deleteDataStreamException, deleteCollectionException.getCause());
+    }
+
+    public void testDeleteMissingAnalyticsCollectionGenericException() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState();
+        RuntimeException deleteDataStreamException = new RuntimeException("message");
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(client, analyticsCollectionResolver);
+
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof DeleteDataStreamAction) {
+                throw deleteDataStreamException;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        ElasticsearchException deleteCollectionException = expectThrows(
+            ElasticsearchException.class,
+            "error while deleting analytics collection [" + collectionName + "]",
+            () -> awaitDeleteAnalyticsCollection(analyticsService, clusterState, collectionName)
+        );
+
+        assertNotNull(deleteCollectionException.getCause());
+        assertEquals(RestStatus.INTERNAL_SERVER_ERROR, deleteCollectionException.status());
+        assertEquals(deleteDataStreamException, deleteCollectionException.getCause());
+    }
+
+    public void testDeleteAnalyticsCollectionOnNonMasterNode() {
+        String collectionName = randomIdentifier();
+        ClusterState clusterState = createClusterState(false);
+        AnalyticsCollectionResolver analyticsCollectionResolver = mock(AnalyticsCollectionResolver.class);
+
+        AnalyticsCollectionService analyticsService = new AnalyticsCollectionService(mock(Client.class), analyticsCollectionResolver);
+
+        expectThrows(AssertionError.class, () -> awaitDeleteAnalyticsCollection(analyticsService, clusterState, collectionName));
+    }
+
+    public static class VerifyingClient extends NoOpClient {
+        private TriFunction<ActionType<?>, ActionRequest, ActionListener<?>, ActionResponse> verifier = (a, r, l) -> {
+            fail("verifier not set");
+            return null;
+        };
+
+        VerifyingClient(ThreadPool threadPool) {
+            super(threadPool);
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        protected <Request extends ActionRequest, Response extends ActionResponse> void doExecute(
+            ActionType<Response> action,
+            Request request,
+            ActionListener<Response> listener
+        ) {
+            try {
+                listener.onResponse((Response) verifier.apply(action, request, listener));
+            } catch (Exception e) {
+                listener.onFailure(e);
+            }
+        }
+
+        public void setVerifier(TriFunction<ActionType<?>, ActionRequest, ActionListener<?>, ActionResponse> verifier) {
+            this.verifier = verifier;
+        }
+    }
+
+    private List<AnalyticsCollection> awaitGetAnalyticsCollections(
+        AnalyticsCollectionService analyticsCollectionService,
+        ClusterState clusterState,
+        String... collectionName
+    ) throws Exception {
+        GetAnalyticsCollectionAction.Request request = new GetAnalyticsCollectionAction.Request(collectionName);
+        return new Executor<>(clusterState, analyticsCollectionService::getAnalyticsCollection).execute(request).getAnalyticsCollections();
+    }
+
+    private PutAnalyticsCollectionAction.Response awaitPutAnalyticsCollection(
+        AnalyticsCollectionService analyticsCollectionService,
+        ClusterState clusterState,
+        String collectionName
+    ) throws Exception {
+        PutAnalyticsCollectionAction.Request request = new PutAnalyticsCollectionAction.Request(collectionName);
+        return new Executor<>(clusterState, analyticsCollectionService::putAnalyticsCollection).execute(request);
+    }
+
+    private AcknowledgedResponse awaitDeleteAnalyticsCollection(
+        AnalyticsCollectionService analyticsCollectionService,
+        ClusterState clusterState,
+        String collectionName
+    ) throws Exception {
+        DeleteAnalyticsCollectionAction.Request request = new DeleteAnalyticsCollectionAction.Request(collectionName);
+        return new Executor<>(clusterState, analyticsCollectionService::deleteAnalyticsCollection).execute(request);
+    }
+
+    private static class Executor<T, R> {
+        private final ClusterState clusterState;
+
+        private final TriConsumer<ClusterState, T, ActionListener<R>> consumer;
+
+        Executor(ClusterState clusterState, TriConsumer<ClusterState, T, ActionListener<R>> consumer) {
+            this.clusterState = clusterState;
+            this.consumer = consumer;
+
+        }
+
+        public R execute(T param) throws Exception {
+            CountDownLatch latch = new CountDownLatch(1);
+            final AtomicReference<R> resp = new AtomicReference<>(null);
+            final AtomicReference<Exception> exc = new AtomicReference<>(null);
+
+            consumer.accept(clusterState, param, ActionListener.wrap(r -> {
+                resp.set(r);
+                latch.countDown();
+            }, e -> {
+                exc.set(e);
+                latch.countDown();
+            }));
+
+            if (exc.get() != null) {
+                throw exc.get();
+            }
+            assertNotNull(resp.get());
+
+            return resp.get();
+        }
+    }
+
+    private ClusterState createClusterState() {
+        return createClusterState(true);
+    }
+
+    private ClusterState createClusterState(boolean isLocaleNodeMaster) {
+        ClusterState clusterState = mock(ClusterState.class);
+        DiscoveryNodes nodes = mock(DiscoveryNodes.class);
+        when(nodes.isLocalNodeElectedMaster()).thenReturn(isLocaleNodeMaster);
+        when(clusterState.nodes()).thenReturn(nodes);
+        return clusterState;
+    }
+}

+ 95 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsCollectionTests.java

@@ -0,0 +1,95 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentType;
+import org.junit.Before;
+
+import java.io.IOException;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+import static org.elasticsearch.common.xcontent.XContentHelper.toXContent;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertToXContentEquivalent;
+import static org.hamcrest.CoreMatchers.equalTo;
+
+public class AnalyticsCollectionTests extends ESTestCase {
+
+    private NamedWriteableRegistry namedWriteableRegistry;
+
+    @Before
+    public void registerNamedObjects() {
+        SearchModule searchModule = new SearchModule(Settings.EMPTY, emptyList());
+
+        List<NamedWriteableRegistry.Entry> namedWriteables = searchModule.getNamedWriteables();
+        namedWriteableRegistry = new NamedWriteableRegistry(namedWriteables);
+    }
+
+    public void testDataStreamName() {
+        AnalyticsCollection collection = randomAnalyticsCollection();
+        String expectedDataStreamName = AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PREFIX + collection.getName();
+        assertEquals(expectedDataStreamName, collection.getEventDataStream());
+    }
+
+    public final void testRandomSerialization() throws IOException {
+        for (int runs = 0; runs < 10; runs++) {
+            AnalyticsCollection collection = randomAnalyticsCollection();
+            assertTransportSerialization(collection, Version.CURRENT);
+            assertXContent(collection, randomBoolean());
+        }
+    }
+
+    public void testToXContent() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            { }
+            """);
+        AnalyticsCollection collection = AnalyticsCollection.fromXContentBytes("my_collection", new BytesArray(content), XContentType.JSON);
+        boolean humanReadable = true;
+        BytesReference originalBytes = toShuffledXContent(collection, XContentType.JSON, ToXContent.EMPTY_PARAMS, humanReadable);
+        AnalyticsCollection parsed;
+        try (XContentParser parser = createParser(XContentType.JSON.xContent(), originalBytes)) {
+            parsed = AnalyticsCollection.fromXContent(collection.getName(), parser);
+        }
+        assertToXContentEquivalent(originalBytes, toXContent(parsed, XContentType.JSON, humanReadable), XContentType.JSON);
+    }
+
+    private AnalyticsCollection assertXContent(AnalyticsCollection collection, boolean humanReadable) throws IOException {
+        BytesReference originalBytes = toShuffledXContent(collection, XContentType.JSON, ToXContent.EMPTY_PARAMS, humanReadable);
+        AnalyticsCollection parsed;
+        try (XContentParser parser = createParser(XContentType.JSON.xContent(), originalBytes)) {
+            parsed = AnalyticsCollection.fromXContent(collection.getName(), parser);
+        }
+        assertToXContentEquivalent(originalBytes, toXContent(parsed, XContentType.JSON, humanReadable), XContentType.JSON);
+        return parsed;
+    }
+
+    private AnalyticsCollection assertTransportSerialization(AnalyticsCollection testInstance, Version version) throws IOException {
+        AnalyticsCollection deserializedInstance = copyInstance(testInstance, version);
+        assertNotSame(testInstance, deserializedInstance);
+        assertThat(testInstance, equalTo(deserializedInstance));
+        return deserializedInstance;
+    }
+
+    private AnalyticsCollection copyInstance(AnalyticsCollection instance, Version version) throws IOException {
+        return copyWriteable(instance, namedWriteableRegistry, AnalyticsCollection::new, version.transportVersion);
+    }
+
+    private static AnalyticsCollection randomAnalyticsCollection() {
+        return new AnalyticsCollection(randomIdentifier());
+    }
+}

+ 495 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/AnalyticsTemplateRegistryTests.java

@@ -0,0 +1,495 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.admin.indices.template.put.PutComponentTemplateAction;
+import org.elasticsearch.action.admin.indices.template.put.PutComposableIndexTemplateAction;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.cluster.ClusterChangedEvent;
+import org.elasticsearch.cluster.ClusterName;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.block.ClusterBlocks;
+import org.elasticsearch.cluster.metadata.ComponentTemplate;
+import org.elasticsearch.cluster.metadata.ComposableIndexTemplate;
+import org.elasticsearch.cluster.metadata.Metadata;
+import org.elasticsearch.cluster.node.DiscoveryNode;
+import org.elasticsearch.cluster.node.DiscoveryNodes;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.TriFunction;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.test.ClusterServiceUtils;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.client.NoOpClient;
+import org.elasticsearch.test.junit.annotations.TestLogging;
+import org.elasticsearch.threadpool.TestThreadPool;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.ilm.DeleteAction;
+import org.elasticsearch.xpack.core.ilm.IndexLifecycleMetadata;
+import org.elasticsearch.xpack.core.ilm.LifecycleAction;
+import org.elasticsearch.xpack.core.ilm.LifecyclePolicy;
+import org.elasticsearch.xpack.core.ilm.LifecyclePolicyMetadata;
+import org.elasticsearch.xpack.core.ilm.OperationMode;
+import org.elasticsearch.xpack.core.ilm.action.PutLifecycleAction;
+import org.junit.After;
+import org.junit.Before;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+public class AnalyticsTemplateRegistryTests extends ESTestCase {
+    private AnalyticsTemplateRegistry registry;
+    private ThreadPool threadPool;
+    private VerifyingClient client;
+
+    @Before
+    public void createRegistryAndClient() {
+        threadPool = new TestThreadPool(this.getClass().getName());
+        client = new VerifyingClient(threadPool);
+        ClusterService clusterService = ClusterServiceUtils.createClusterService(threadPool);
+        registry = new AnalyticsTemplateRegistry(clusterService, threadPool, client, NamedXContentRegistry.EMPTY);
+    }
+
+    @After
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        threadPool.shutdownNow();
+    }
+
+    public void testThatNonExistingComposableTemplatesAreAddedImmediately() throws Exception {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+        Map<String, Integer> existingComponentTemplates = Map.of(
+            AnalyticsTemplateRegistry.EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME,
+            AnalyticsTemplateRegistry.REGISTRY_VERSION,
+            AnalyticsTemplateRegistry.EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME,
+            AnalyticsTemplateRegistry.REGISTRY_VERSION
+        );
+
+        ClusterChangedEvent event = createClusterChangedEvent(Collections.emptyMap(), existingComponentTemplates, nodes);
+
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> verifyComposableTemplateInstalled(calledTimes, action, request, listener));
+        registry.clusterChanged(event);
+        assertBusy(() -> assertThat(calledTimes.get(), equalTo(registry.getComposableTemplateConfigs().size())));
+
+        calledTimes.set(0);
+
+        // attempting to register the event multiple times as a race condition can yield this test flaky, namely:
+        // when calling registry.clusterChanged(newEvent) the templateCreationsInProgress state that the IndexTemplateRegistry maintains
+        // might've not yet been updated to reflect that the first template registration was complete, so a second template registration
+        // will not be issued anymore, leaving calledTimes to 0
+        assertBusy(() -> {
+            // now delete one template from the cluster state and let's retry
+            ClusterChangedEvent newEvent = createClusterChangedEvent(Collections.emptyMap(), existingComponentTemplates, nodes);
+            registry.clusterChanged(newEvent);
+            assertThat(calledTimes.get(), greaterThan(1));
+        });
+    }
+
+    public void testThatNonExistingComponentTemplatesAreAddedImmediately() throws Exception {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        ClusterChangedEvent event = createClusterChangedEvent(Collections.emptyMap(), Collections.emptyMap(), nodes);
+
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> verifyComponentTemplateInstalled(calledTimes, action, request, listener));
+        registry.clusterChanged(event);
+        assertBusy(() -> assertThat(calledTimes.get(), equalTo(registry.getComponentTemplateConfigs().size())));
+
+        calledTimes.set(0);
+
+        // attempting to register the event multiple times as a race condition can yield this test flaky, namely:
+        // when calling registry.clusterChanged(newEvent) the templateCreationsInProgress state that the IndexTemplateRegistry maintains
+        // might've not yet been updated to reflect that the first template registration was complete, so a second template registration
+        // will not be issued anymore, leaving calledTimes to 0
+        assertBusy(() -> {
+            // now delete one template from the cluster state and let's retry
+            ClusterChangedEvent newEvent = createClusterChangedEvent(Collections.emptyMap(), Collections.emptyMap(), nodes);
+            registry.clusterChanged(newEvent);
+            assertThat(calledTimes.get(), greaterThan(1));
+        });
+    }
+
+    public void testThatNonExistingPoliciesAreAddedImmediately() throws Exception {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof PutLifecycleAction) {
+                calledTimes.incrementAndGet();
+                assertThat(action, instanceOf(PutLifecycleAction.class));
+                assertThat(request, instanceOf(PutLifecycleAction.Request.class));
+                final PutLifecycleAction.Request putRequest = (PutLifecycleAction.Request) request;
+                assertThat(putRequest.getPolicy().getName(), equalTo(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_ILM_POLICY_NAME));
+                assertNotNull(listener);
+                return AcknowledgedResponse.TRUE;
+            } else if (action instanceof PutComponentTemplateAction) {
+                // Ignore this, it's verified in another test
+                return new AnalyticsTemplateRegistryTests.TestPutIndexTemplateResponse(true);
+            } else if (action instanceof PutComposableIndexTemplateAction) {
+                // Ignore this, it's verified in another test
+                return AcknowledgedResponse.TRUE;
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+                return null;
+            }
+        });
+
+        ClusterChangedEvent event = createClusterChangedEvent(Collections.emptyMap(), Collections.emptyMap(), nodes);
+        registry.clusterChanged(event);
+        assertBusy(() -> assertThat(calledTimes.get(), equalTo(1)));
+    }
+
+    public void testPolicyAlreadyExists() {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        Map<String, LifecyclePolicy> policyMap = new HashMap<>();
+        List<LifecyclePolicy> policies = registry.getPolicyConfigs();
+        assertThat(policies, hasSize(1));
+        policies.forEach(p -> policyMap.put(p.getName(), p));
+
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof PutComponentTemplateAction) {
+                // Ignore this, it's verified in another test
+                return AcknowledgedResponse.TRUE;
+            } else if (action instanceof PutLifecycleAction) {
+                fail("if the policy already exists it should not be re-put");
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        ClusterChangedEvent event = createClusterChangedEvent(Collections.emptyMap(), Collections.emptyMap(), policyMap, nodes);
+        registry.clusterChanged(event);
+    }
+
+    public void testPolicyAlreadyExistsButDiffers() throws IOException {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        Map<String, LifecyclePolicy> policyMap = new HashMap<>();
+        String policyStr = "{\"phases\":{\"delete\":{\"min_age\":\"1m\",\"actions\":{\"delete\":{}}}}}";
+        List<LifecyclePolicy> policies = registry.getPolicyConfigs();
+        assertThat(policies, hasSize(1));
+        policies.forEach(p -> policyMap.put(p.getName(), p));
+
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof PutComponentTemplateAction) {
+                // Ignore this, it's verified in another test
+                return AcknowledgedResponse.TRUE;
+            } else if (action instanceof PutLifecycleAction) {
+                fail("if the policy already exists it should not be re-put");
+            } else {
+                fail("client called with unexpected request: " + request.toString());
+            }
+            return null;
+        });
+
+        try (
+            XContentParser parser = XContentType.JSON.xContent()
+                .createParser(
+                    XContentParserConfiguration.EMPTY.withRegistry(
+                        new NamedXContentRegistry(
+                            List.of(
+                                new NamedXContentRegistry.Entry(
+                                    LifecycleAction.class,
+                                    new ParseField(DeleteAction.NAME),
+                                    DeleteAction::parse
+                                )
+                            )
+                        )
+                    ),
+                    policyStr
+                )
+        ) {
+            LifecyclePolicy different = LifecyclePolicy.parse(parser, policies.get(0).getName());
+            policyMap.put(policies.get(0).getName(), different);
+            ClusterChangedEvent event = createClusterChangedEvent(Collections.emptyMap(), Collections.emptyMap(), policyMap, nodes);
+            registry.clusterChanged(event);
+        }
+    }
+
+    public void testThatVersionedOldComponentTemplatesAreUpgraded() throws Exception {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        ClusterChangedEvent event = createClusterChangedEvent(
+            Collections.emptyMap(),
+            Collections.singletonMap(
+                AnalyticsTemplateRegistry.EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME,
+                AnalyticsTemplateRegistry.REGISTRY_VERSION - 1
+            ),
+            nodes
+        );
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> verifyComponentTemplateInstalled(calledTimes, action, request, listener));
+        registry.clusterChanged(event);
+        assertBusy(() -> assertThat(calledTimes.get(), equalTo(registry.getComponentTemplateConfigs().size())));
+    }
+
+    public void testThatUnversionedOldComponentTemplatesAreUpgraded() throws Exception {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        ClusterChangedEvent event = createClusterChangedEvent(
+            Collections.emptyMap(),
+            Collections.singletonMap(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME, null),
+            nodes
+        );
+        AtomicInteger calledTimes = new AtomicInteger(0);
+        client.setVerifier((action, request, listener) -> verifyComponentTemplateInstalled(calledTimes, action, request, listener));
+        registry.clusterChanged(event);
+        assertBusy(() -> assertThat(calledTimes.get(), equalTo(registry.getComponentTemplateConfigs().size())));
+    }
+
+    @TestLogging(value = "org.elasticsearch.xpack.core.template:DEBUG", reason = "test")
+    public void testSameOrHigherVersionComponentTemplateNotUpgraded() {
+        DiscoveryNode node = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").masterNodeId("node").add(node).build();
+
+        Map<String, Integer> versions = new HashMap<>();
+        versions.put(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME, AnalyticsTemplateRegistry.REGISTRY_VERSION);
+        versions.put(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME, AnalyticsTemplateRegistry.REGISTRY_VERSION);
+        ClusterChangedEvent sameVersionEvent = createClusterChangedEvent(Collections.emptyMap(), versions, nodes);
+        client.setVerifier((action, request, listener) -> {
+            if (action instanceof PutComponentTemplateAction) {
+                fail("template should not have been re-installed");
+                return null;
+            } else if (action instanceof PutLifecycleAction) {
+                // Ignore this, it's verified in another test
+                return AcknowledgedResponse.TRUE;
+            } else if (action instanceof PutComposableIndexTemplateAction) {
+                // Ignore this, it's verified in another test
+                return AcknowledgedResponse.TRUE;
+            } else {
+                fail("client called with unexpected request:" + request.toString());
+                return null;
+            }
+        });
+        registry.clusterChanged(sameVersionEvent);
+
+        versions.clear();
+        versions.put(
+            AnalyticsTemplateRegistry.EVENT_DATA_STREAM_MAPPINGS_COMPONENT_NAME,
+            AnalyticsTemplateRegistry.REGISTRY_VERSION + randomIntBetween(1, 1000)
+        );
+        versions.put(
+            AnalyticsTemplateRegistry.EVENT_DATA_STREAM_SETTINGS_COMPONENT_NAME,
+            AnalyticsTemplateRegistry.REGISTRY_VERSION + randomIntBetween(1, 1000)
+        );
+        ClusterChangedEvent higherVersionEvent = createClusterChangedEvent(Collections.emptyMap(), versions, nodes);
+        registry.clusterChanged(higherVersionEvent);
+    }
+
+    public void testThatMissingMasterNodeDoesNothing() {
+        DiscoveryNode localNode = new DiscoveryNode("node", ESTestCase.buildNewFakeTransportAddress(), Version.CURRENT);
+        DiscoveryNodes nodes = DiscoveryNodes.builder().localNodeId("node").add(localNode).build();
+
+        client.setVerifier((a, r, l) -> {
+            fail("if the master is missing nothing should happen");
+            return null;
+        });
+
+        ClusterChangedEvent event = createClusterChangedEvent(
+            Collections.singletonMap(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_TEMPLATE_NAME, null),
+            Collections.emptyMap(),
+            nodes
+        );
+        registry.clusterChanged(event);
+    }
+
+    // -------------
+
+    /**
+     * A client that delegates to a verifying function for action/request/listener
+     */
+    public static class VerifyingClient extends NoOpClient {
+
+        private TriFunction<ActionType<?>, ActionRequest, ActionListener<?>, ActionResponse> verifier = (a, r, l) -> {
+            fail("verifier not set");
+            return null;
+        };
+
+        VerifyingClient(ThreadPool threadPool) {
+            super(threadPool);
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        protected <Request extends ActionRequest, Response extends ActionResponse> void doExecute(
+            ActionType<Response> action,
+            Request request,
+            ActionListener<Response> listener
+        ) {
+            try {
+                listener.onResponse((Response) verifier.apply(action, request, listener));
+            } catch (Exception e) {
+                listener.onFailure(e);
+            }
+        }
+
+        public VerifyingClient setVerifier(TriFunction<ActionType<?>, ActionRequest, ActionListener<?>, ActionResponse> verifier) {
+            this.verifier = verifier;
+            return this;
+        }
+    }
+
+    private ActionResponse verifyComposableTemplateInstalled(
+        AtomicInteger calledTimes,
+        ActionType<?> action,
+        ActionRequest request,
+        ActionListener<?> listener
+    ) {
+        if (action instanceof PutComponentTemplateAction) {
+            // Ignore this, it's verified in another test
+            return AcknowledgedResponse.TRUE;
+        } else if (action instanceof PutLifecycleAction) {
+            // Ignore this, it's verified in another test
+            return AcknowledgedResponse.TRUE;
+        } else if (action instanceof PutComposableIndexTemplateAction) {
+            calledTimes.incrementAndGet();
+            assertThat(action, instanceOf(PutComposableIndexTemplateAction.class));
+            assertThat(request, instanceOf(PutComposableIndexTemplateAction.Request.class));
+            final PutComposableIndexTemplateAction.Request putRequest = (PutComposableIndexTemplateAction.Request) request;
+            assertThat(putRequest.indexTemplate().version(), equalTo((long) AnalyticsTemplateRegistry.REGISTRY_VERSION));
+            final List<String> indexPatterns = putRequest.indexTemplate().indexPatterns();
+            assertThat(indexPatterns, hasSize(1));
+            assertThat(indexPatterns.get(0), equalTo(AnalyticsTemplateRegistry.EVENT_DATA_STREAM_INDEX_PATTERN));
+            assertNotNull(listener);
+            return new TestPutIndexTemplateResponse(true);
+        } else {
+            fail("client called with unexpected request:" + request.toString());
+            return null;
+        }
+    }
+
+    private ActionResponse verifyComponentTemplateInstalled(
+        AtomicInteger calledTimes,
+        ActionType<?> action,
+        ActionRequest request,
+        ActionListener<?> listener
+    ) {
+        if (action instanceof PutComponentTemplateAction) {
+            calledTimes.incrementAndGet();
+            assertThat(action, instanceOf(PutComponentTemplateAction.class));
+            assertThat(request, instanceOf(PutComponentTemplateAction.Request.class));
+            final PutComponentTemplateAction.Request putRequest = (PutComponentTemplateAction.Request) request;
+            assertThat(putRequest.componentTemplate().version(), equalTo((long) AnalyticsTemplateRegistry.REGISTRY_VERSION));
+            assertNotNull(listener);
+            return new TestPutIndexTemplateResponse(true);
+        } else if (action instanceof PutLifecycleAction) {
+            // Ignore this, it's verified in another test
+            return AcknowledgedResponse.TRUE;
+        } else if (action instanceof PutComposableIndexTemplateAction) {
+            // Ignore this, it's verified in another test
+            return AcknowledgedResponse.TRUE;
+        } else {
+            fail("client called with unexpected request:" + request.toString());
+            return null;
+        }
+    }
+
+    private ClusterChangedEvent createClusterChangedEvent(
+        Map<String, Integer> existingComposableTemplates,
+        Map<String, Integer> existingComponentTemplates,
+        DiscoveryNodes nodes
+    ) {
+        return createClusterChangedEvent(existingComposableTemplates, existingComponentTemplates, Collections.emptyMap(), nodes);
+    }
+
+    private ClusterChangedEvent createClusterChangedEvent(
+        Map<String, Integer> existingComposableTemplates,
+        Map<String, Integer> existingComponentTemplates,
+        Map<String, LifecyclePolicy> existingPolicies,
+        DiscoveryNodes nodes
+    ) {
+        ClusterState cs = createClusterState(existingComposableTemplates, existingComponentTemplates, existingPolicies, nodes);
+        ClusterChangedEvent realEvent = new ClusterChangedEvent(
+            "created-from-test",
+            cs,
+            ClusterState.builder(new ClusterName("test")).build()
+        );
+        ClusterChangedEvent event = spy(realEvent);
+        when(event.localNodeMaster()).thenReturn(nodes.isLocalNodeElectedMaster());
+
+        return event;
+    }
+
+    private ClusterState createClusterState(
+        Map<String, Integer> existingComposableTemplates,
+        Map<String, Integer> existingComponentTemplates,
+        Map<String, LifecyclePolicy> existingPolicies,
+        DiscoveryNodes nodes
+    ) {
+        Map<String, ComposableIndexTemplate> composableTemplates = new HashMap<>();
+        for (Map.Entry<String, Integer> template : existingComposableTemplates.entrySet()) {
+            ComposableIndexTemplate mockTemplate = mock(ComposableIndexTemplate.class);
+            when(mockTemplate.version()).thenReturn(template.getValue() == null ? null : (long) template.getValue());
+            composableTemplates.put(template.getKey(), mockTemplate);
+        }
+
+        Map<String, ComponentTemplate> componentTemplates = new HashMap<>();
+        for (Map.Entry<String, Integer> template : existingComponentTemplates.entrySet()) {
+            ComponentTemplate mockTemplate = mock(ComponentTemplate.class);
+            when(mockTemplate.version()).thenReturn(template.getValue() == null ? null : (long) template.getValue());
+            componentTemplates.put(template.getKey(), mockTemplate);
+        }
+
+        Map<String, LifecyclePolicyMetadata> existingILMMeta = existingPolicies.entrySet()
+            .stream()
+            .collect(Collectors.toMap(Map.Entry::getKey, e -> new LifecyclePolicyMetadata(e.getValue(), Collections.emptyMap(), 1, 1)));
+        IndexLifecycleMetadata ilmMeta = new IndexLifecycleMetadata(existingILMMeta, OperationMode.RUNNING);
+
+        return ClusterState.builder(new ClusterName("test"))
+            .metadata(
+                Metadata.builder()
+                    .indexTemplates(composableTemplates)
+                    .componentTemplates(componentTemplates)
+                    .transientSettings(Settings.EMPTY)
+                    .putCustom(IndexLifecycleMetadata.TYPE, ilmMeta)
+                    .build()
+            )
+            .blocks(new ClusterBlocks.Builder().build())
+            .nodes(nodes)
+            .build();
+    }
+
+    private static class TestPutIndexTemplateResponse extends AcknowledgedResponse {
+        TestPutIndexTemplateResponse(boolean acknowledged) {
+            super(acknowledged);
+        }
+    }
+}

+ 32 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/DeleteAnalyticsCollectionRequestSerializingTests.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+
+public class DeleteAnalyticsCollectionRequestSerializingTests extends AbstractWireSerializingTestCase<
+    DeleteAnalyticsCollectionAction.Request> {
+
+    @Override
+    protected Writeable.Reader<DeleteAnalyticsCollectionAction.Request> instanceReader() {
+        return DeleteAnalyticsCollectionAction.Request::new;
+    }
+
+    @Override
+    protected DeleteAnalyticsCollectionAction.Request createTestInstance() {
+        return new DeleteAnalyticsCollectionAction.Request(randomIdentifier());
+    }
+
+    @Override
+    protected DeleteAnalyticsCollectionAction.Request mutateInstance(DeleteAnalyticsCollectionAction.Request instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 31 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/GetAnalyticsCollectionRequestSerializingTests.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+
+public class GetAnalyticsCollectionRequestSerializingTests extends AbstractWireSerializingTestCase<GetAnalyticsCollectionAction.Request> {
+
+    @Override
+    protected Writeable.Reader<GetAnalyticsCollectionAction.Request> instanceReader() {
+        return GetAnalyticsCollectionAction.Request::new;
+    }
+
+    @Override
+    protected GetAnalyticsCollectionAction.Request createTestInstance() {
+        return new GetAnalyticsCollectionAction.Request(new String[] { randomIdentifier() });
+    }
+
+    @Override
+    protected GetAnalyticsCollectionAction.Request mutateInstance(GetAnalyticsCollectionAction.Request instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 34 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/GetAnalyticsCollectionResponseSerializingTests.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollection;
+
+import java.io.IOException;
+import java.util.List;
+
+public class GetAnalyticsCollectionResponseSerializingTests extends AbstractWireSerializingTestCase<GetAnalyticsCollectionAction.Response> {
+
+    @Override
+    protected Writeable.Reader<GetAnalyticsCollectionAction.Response> instanceReader() {
+        return GetAnalyticsCollectionAction.Response::new;
+    }
+
+    @Override
+    protected GetAnalyticsCollectionAction.Response createTestInstance() {
+        List<AnalyticsCollection> collections = randomList(randomIntBetween(1, 10), () -> new AnalyticsCollection(randomIdentifier()));
+        return new GetAnalyticsCollectionAction.Response(collections);
+    }
+
+    @Override
+    protected GetAnalyticsCollectionAction.Response mutateInstance(GetAnalyticsCollectionAction.Response instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 31 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/PutAnalyticsCollectionRequestSerializingTests.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+
+public class PutAnalyticsCollectionRequestSerializingTests extends AbstractWireSerializingTestCase<PutAnalyticsCollectionAction.Request> {
+
+    @Override
+    protected Writeable.Reader<PutAnalyticsCollectionAction.Request> instanceReader() {
+        return PutAnalyticsCollectionAction.Request::new;
+    }
+
+    @Override
+    protected PutAnalyticsCollectionAction.Request createTestInstance() {
+        return new PutAnalyticsCollectionAction.Request(randomIdentifier());
+    }
+
+    @Override
+    protected PutAnalyticsCollectionAction.Request mutateInstance(PutAnalyticsCollectionAction.Request instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 31 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/PutAnalyticsCollectionResponseSerializingTests.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+
+public class PutAnalyticsCollectionResponseSerializingTests extends AbstractWireSerializingTestCase<PutAnalyticsCollectionAction.Response> {
+
+    @Override
+    protected Writeable.Reader<PutAnalyticsCollectionAction.Response> instanceReader() {
+        return PutAnalyticsCollectionAction.Response::new;
+    }
+
+    @Override
+    protected PutAnalyticsCollectionAction.Response createTestInstance() {
+        return new PutAnalyticsCollectionAction.Response(randomBoolean(), randomIdentifier());
+    }
+
+    @Override
+    protected PutAnalyticsCollectionAction.Response mutateInstance(PutAnalyticsCollectionAction.Response instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 110 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/TransportDeleteAnalyticsCollectionActionTests.java

@@ -0,0 +1,110 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.license.MockLicenseState;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollectionService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TransportDeleteAnalyticsCollectionActionTests extends ESTestCase {
+    @SuppressWarnings("unchecked")
+    public void testWithSupportedLicense() {
+        AnalyticsCollectionService analyticsCollectionService = mock(AnalyticsCollectionService.class);
+
+        TransportDeleteAnalyticsCollectionAction transportAction = createTransportAction(
+            mockLicenseState(true),
+            analyticsCollectionService
+        );
+        DeleteAnalyticsCollectionAction.Request request = mock(DeleteAnalyticsCollectionAction.Request.class);
+
+        ClusterState clusterState = mock(ClusterState.class);
+
+        ActionListener<AcknowledgedResponse> listener = mock(ActionListener.class);
+
+        transportAction.masterOperation(mock(Task.class), request, clusterState, listener);
+        verify(analyticsCollectionService, times(1)).deleteAnalyticsCollection(clusterState, request, listener);
+        verify(listener, never()).onFailure(any());
+    }
+
+    public void testWithUnsupportedLicense() {
+        AnalyticsCollectionService analyticsCollectionService = mock(AnalyticsCollectionService.class);
+
+        TransportDeleteAnalyticsCollectionAction transportAction = createTransportAction(
+            mockLicenseState(false),
+            analyticsCollectionService
+        );
+        DeleteAnalyticsCollectionAction.Request request = mock(DeleteAnalyticsCollectionAction.Request.class);
+
+        ClusterState clusterState = mock(ClusterState.class);
+
+        final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
+        final AtomicReference<AcknowledgedResponse> responseRef = new AtomicReference<>();
+        ActionListener<AcknowledgedResponse> listener = ActionListener.wrap(r -> responseRef.set(r), e -> throwableRef.set(e));
+
+        transportAction.masterOperation(mock(Task.class), request, clusterState, listener);
+
+        assertThat(responseRef.get(), is(nullValue()));
+        assertThat(throwableRef.get(), instanceOf(ElasticsearchSecurityException.class));
+        assertThat(
+            throwableRef.get().getMessage(),
+            containsString("Search Applications and behavioral analytics require an active trial, platinum or enterprise license.")
+        );
+
+        verify(analyticsCollectionService, never()).deleteAnalyticsCollection(any(), any(), any());
+    }
+
+    private MockLicenseState mockLicenseState(boolean supported) {
+        MockLicenseState licenseState = mock(MockLicenseState.class);
+
+        when(licenseState.isAllowed(LicenseUtils.LICENSED_ENT_SEARCH_FEATURE)).thenReturn(supported);
+        when(licenseState.isActive()).thenReturn(supported);
+        when(licenseState.statusDescription()).thenReturn("invalid license");
+
+        return licenseState;
+    }
+
+    private TransportDeleteAnalyticsCollectionAction createTransportAction(
+        XPackLicenseState licenseState,
+        AnalyticsCollectionService analyticsCollectionService
+    ) {
+        return new TransportDeleteAnalyticsCollectionAction(
+            mock(TransportService.class),
+            mock(ClusterService.class),
+            mock(ThreadPool.class),
+            mock(ActionFilters.class),
+            mock(IndexNameExpressionResolver.class),
+            analyticsCollectionService,
+            licenseState
+        );
+    }
+}

+ 106 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/TransportGetAnalyticsCollectionActionTests.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.license.MockLicenseState;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollectionService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TransportGetAnalyticsCollectionActionTests extends ESTestCase {
+    @SuppressWarnings("unchecked")
+    public void testWithSupportedLicense() {
+        AnalyticsCollectionService analyticsCollectionService = mock(AnalyticsCollectionService.class);
+
+        TransportGetAnalyticsCollectionAction transportAction = createTransportAction(mockLicenseState(true), analyticsCollectionService);
+        GetAnalyticsCollectionAction.Request request = mock(GetAnalyticsCollectionAction.Request.class);
+
+        ClusterState clusterState = mock(ClusterState.class);
+
+        ActionListener<GetAnalyticsCollectionAction.Response> listener = mock(ActionListener.class);
+
+        transportAction.masterOperation(mock(Task.class), request, clusterState, listener);
+        verify(analyticsCollectionService, times(1)).getAnalyticsCollection(clusterState, request, listener);
+        verify(listener, never()).onFailure(any());
+    }
+
+    public void testWithUnsupportedLicense() {
+        AnalyticsCollectionService analyticsCollectionService = mock(AnalyticsCollectionService.class);
+
+        TransportGetAnalyticsCollectionAction transportAction = createTransportAction(mockLicenseState(false), analyticsCollectionService);
+        GetAnalyticsCollectionAction.Request request = mock(GetAnalyticsCollectionAction.Request.class);
+
+        ClusterState clusterState = mock(ClusterState.class);
+
+        final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
+        final AtomicReference<GetAnalyticsCollectionAction.Response> responseRef = new AtomicReference<>();
+        ActionListener<GetAnalyticsCollectionAction.Response> listener = ActionListener.wrap(
+            r -> responseRef.set(r),
+            e -> throwableRef.set(e)
+        );
+
+        transportAction.masterOperation(mock(Task.class), request, clusterState, listener);
+
+        assertThat(responseRef.get(), is(nullValue()));
+        assertThat(throwableRef.get(), instanceOf(ElasticsearchSecurityException.class));
+        assertThat(
+            throwableRef.get().getMessage(),
+            containsString("Search Applications and behavioral analytics require an active trial, platinum or enterprise license.")
+        );
+
+        verify(analyticsCollectionService, never()).getAnalyticsCollection(any(), any(), any());
+    }
+
+    private MockLicenseState mockLicenseState(boolean supported) {
+        MockLicenseState licenseState = mock(MockLicenseState.class);
+
+        when(licenseState.isAllowed(LicenseUtils.LICENSED_ENT_SEARCH_FEATURE)).thenReturn(supported);
+        when(licenseState.isActive()).thenReturn(supported);
+        when(licenseState.statusDescription()).thenReturn("invalid license");
+
+        return licenseState;
+    }
+
+    private TransportGetAnalyticsCollectionAction createTransportAction(
+        XPackLicenseState licenseState,
+        AnalyticsCollectionService analyticsCollectionService
+    ) {
+        return new TransportGetAnalyticsCollectionAction(
+            mock(TransportService.class),
+            mock(ClusterService.class),
+            mock(ThreadPool.class),
+            mock(ActionFilters.class),
+            mock(IndexNameExpressionResolver.class),
+            analyticsCollectionService,
+            licenseState
+        );
+    }
+}

+ 106 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/analytics/action/TransportPutAnalyticsCollectionActionTests.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.analytics.action;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.license.MockLicenseState;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.analytics.AnalyticsCollectionService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TransportPutAnalyticsCollectionActionTests extends ESTestCase {
+    @SuppressWarnings("unchecked")
+    public void testWithSupportedLicense() {
+        AnalyticsCollectionService analyticsCollectionService = mock(AnalyticsCollectionService.class);
+
+        TransportPutAnalyticsCollectionAction transportAction = createTransportAction(mockLicenseState(true), analyticsCollectionService);
+        PutAnalyticsCollectionAction.Request request = mock(PutAnalyticsCollectionAction.Request.class);
+
+        ClusterState clusterState = mock(ClusterState.class);
+
+        ActionListener<PutAnalyticsCollectionAction.Response> listener = mock(ActionListener.class);
+
+        transportAction.masterOperation(mock(Task.class), request, clusterState, listener);
+        verify(analyticsCollectionService, times(1)).putAnalyticsCollection(clusterState, request, listener);
+        verify(listener, never()).onFailure(any());
+    }
+
+    public void testWithUnsupportedLicense() {
+        AnalyticsCollectionService analyticsCollectionService = mock(AnalyticsCollectionService.class);
+
+        TransportPutAnalyticsCollectionAction transportAction = createTransportAction(mockLicenseState(false), analyticsCollectionService);
+        PutAnalyticsCollectionAction.Request request = mock(PutAnalyticsCollectionAction.Request.class);
+
+        ClusterState clusterState = mock(ClusterState.class);
+
+        final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
+        final AtomicReference<PutAnalyticsCollectionAction.Response> responseRef = new AtomicReference<>();
+        ActionListener<PutAnalyticsCollectionAction.Response> listener = ActionListener.wrap(
+            r -> responseRef.set(r),
+            e -> throwableRef.set(e)
+        );
+
+        transportAction.masterOperation(mock(Task.class), request, clusterState, listener);
+
+        assertThat(responseRef.get(), is(nullValue()));
+        assertThat(throwableRef.get(), instanceOf(ElasticsearchSecurityException.class));
+        assertThat(
+            throwableRef.get().getMessage(),
+            containsString("Search Applications and behavioral analytics require an active trial, platinum or enterprise license.")
+        );
+
+        verify(analyticsCollectionService, never()).putAnalyticsCollection(any(), any(), any());
+    }
+
+    private MockLicenseState mockLicenseState(boolean supported) {
+        MockLicenseState licenseState = mock(MockLicenseState.class);
+
+        when(licenseState.isAllowed(LicenseUtils.LICENSED_ENT_SEARCH_FEATURE)).thenReturn(supported);
+        when(licenseState.isActive()).thenReturn(supported);
+        when(licenseState.statusDescription()).thenReturn("invalid license");
+
+        return licenseState;
+    }
+
+    private TransportPutAnalyticsCollectionAction createTransportAction(
+        XPackLicenseState licenseState,
+        AnalyticsCollectionService analyticsCollectionService
+    ) {
+        return new TransportPutAnalyticsCollectionAction(
+            mock(TransportService.class),
+            mock(ClusterService.class),
+            mock(ThreadPool.class),
+            mock(ActionFilters.class),
+            mock(IndexNameExpressionResolver.class),
+            analyticsCollectionService,
+            licenseState
+        );
+    }
+}

+ 365 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/SearchApplicationIndexServiceTests.java

@@ -0,0 +1,365 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search;
+
+import org.elasticsearch.ResourceNotFoundException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.admin.indices.alias.get.GetAliasesResponse;
+import org.elasticsearch.action.delete.DeleteResponse;
+import org.elasticsearch.action.index.IndexResponse;
+import org.elasticsearch.cluster.metadata.Metadata;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.index.engine.VersionConflictEngineException;
+import org.elasticsearch.indices.SystemIndexDescriptor;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.SystemIndexPlugin;
+import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.ESSingleNodeTestCase;
+import org.junit.Before;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.application.search.SearchApplicationIndexService.SEARCH_APPLICATION_CONCRETE_INDEX_NAME;
+import static org.hamcrest.CoreMatchers.equalTo;
+
+public class SearchApplicationIndexServiceTests extends ESSingleNodeTestCase {
+    private static final int NUM_INDICES = 10;
+    private static final long UPDATED_AT = System.currentTimeMillis();
+
+    private SearchApplicationIndexService searchAppService;
+    private ClusterService clusterService;
+
+    @Before
+    public void setup() throws Exception {
+        clusterService = getInstanceFromNode(ClusterService.class);
+        BigArrays bigArrays = getInstanceFromNode(BigArrays.class);
+        this.searchAppService = new SearchApplicationIndexService(client(), clusterService, writableRegistry(), bigArrays);
+        for (int i = 0; i < NUM_INDICES; i++) {
+            client().admin().indices().prepareCreate("index_" + i).execute().get();
+        }
+    }
+
+    @Override
+    protected Collection<Class<? extends Plugin>> getPlugins() {
+        List<Class<? extends Plugin>> plugins = new ArrayList<>(super.getPlugins());
+        plugins.add(TestPlugin.class);
+        return plugins;
+    }
+
+    public void testEmptyState() throws Exception {
+        expectThrows(ResourceNotFoundException.class, () -> awaitGetSearchApplication("i-dont-exist"));
+        expectThrows(ResourceNotFoundException.class, () -> awaitDeleteSearchApplication("i-dont-exist"));
+
+        SearchApplicationIndexService.SearchApplicationResult listResults = awaitListSearchApplication("*", 0, 10);
+        assertThat(listResults.totalResults(), equalTo(0L));
+    }
+
+    public void testCreateSearchApplication() throws Exception {
+        final SearchApplication searchApp = new SearchApplication(
+            "my_search_app",
+            new String[] { "index_1" },
+            null,
+            System.currentTimeMillis()
+        );
+
+        IndexResponse resp = awaitPutSearchApplication(searchApp, true);
+        assertThat(resp.status(), equalTo(RestStatus.CREATED));
+        assertThat(resp.getIndex(), equalTo(SEARCH_APPLICATION_CONCRETE_INDEX_NAME));
+
+        SearchApplication getSearchApp = awaitGetSearchApplication(searchApp.name());
+        assertThat(getSearchApp, equalTo(searchApp));
+        checkAliases(searchApp);
+
+        expectThrows(VersionConflictEngineException.class, () -> awaitPutSearchApplication(searchApp, true));
+    }
+
+    private void checkAliases(SearchApplication searchApp) {
+        Metadata metadata = clusterService.state().metadata();
+        final String aliasName = searchApp.name();
+        assertTrue(metadata.hasAlias(aliasName));
+        final Set<String> aliasedIndices = metadata.aliasedIndices(aliasName)
+            .stream()
+            .map(index -> index.getName())
+            .collect(Collectors.toSet());
+        assertThat(aliasedIndices, equalTo(Set.of(searchApp.indices())));
+    }
+
+    public void testUpdateSearchApplication() throws Exception {
+        {
+            final SearchApplication searchApp = new SearchApplication(
+                "my_search_app",
+                new String[] { "index_1", "index_2" },
+                null,
+                System.currentTimeMillis()
+            );
+            IndexResponse resp = awaitPutSearchApplication(searchApp, false);
+            assertThat(resp.status(), equalTo(RestStatus.CREATED));
+            assertThat(resp.getIndex(), equalTo(SEARCH_APPLICATION_CONCRETE_INDEX_NAME));
+
+            SearchApplication getSearchApp = awaitGetSearchApplication(searchApp.name());
+            assertThat(getSearchApp, equalTo(searchApp));
+        }
+
+        final SearchApplication searchApp = new SearchApplication(
+            "my_search_app",
+            new String[] { "index_3", "index_4" },
+            "my_search_app_analytics_collection",
+            System.currentTimeMillis()
+        );
+        IndexResponse newResp = awaitPutSearchApplication(searchApp, false);
+        assertThat(newResp.status(), equalTo(RestStatus.OK));
+        assertThat(newResp.getIndex(), equalTo(SEARCH_APPLICATION_CONCRETE_INDEX_NAME));
+        SearchApplication getNewSearchApp = awaitGetSearchApplication(searchApp.name());
+        assertThat(searchApp, equalTo(getNewSearchApp));
+        checkAliases(searchApp);
+    }
+
+    public void testListSearchApplication() throws Exception {
+        for (int i = 0; i < NUM_INDICES; i++) {
+            final SearchApplication searchApp = new SearchApplication(
+                "my_search_app_" + i,
+                new String[] { "index_" + i },
+                null,
+                System.currentTimeMillis()
+            );
+            IndexResponse resp = awaitPutSearchApplication(searchApp, false);
+            assertThat(resp.status(), equalTo(RestStatus.CREATED));
+            assertThat(resp.getIndex(), equalTo(SEARCH_APPLICATION_CONCRETE_INDEX_NAME));
+        }
+
+        {
+            SearchApplicationIndexService.SearchApplicationResult searchResponse = awaitListSearchApplication("*:*", 0, 10);
+            final List<SearchApplicationListItem> apps = searchResponse.items();
+            assertNotNull(apps);
+            assertThat(apps.size(), equalTo(10));
+            assertThat(searchResponse.totalResults(), equalTo(10L));
+
+            for (int i = 0; i < NUM_INDICES; i++) {
+                SearchApplicationListItem app = apps.get(i);
+                assertThat(app.name(), equalTo("my_search_app_" + i));
+                assertThat(app.indices(), equalTo(new String[] { "index_" + i }));
+            }
+        }
+
+        {
+            SearchApplicationIndexService.SearchApplicationResult searchResponse = awaitListSearchApplication("*:*", 5, 10);
+            final List<SearchApplicationListItem> apps = searchResponse.items();
+            assertNotNull(apps);
+            assertThat(apps.size(), equalTo(5));
+            assertThat(searchResponse.totalResults(), equalTo(10L));
+
+            for (int i = 0; i < 5; i++) {
+                int index = i + 5;
+                SearchApplicationListItem app = apps.get(i);
+                assertThat(app.name(), equalTo("my_search_app_" + index));
+                assertThat(app.indices(), equalTo(new String[] { "index_" + index }));
+            }
+        }
+    }
+
+    public void testListSearchApplicationWithQuery() throws Exception {
+        for (int i = 0; i < 10; i++) {
+            final SearchApplication app = new SearchApplication(
+                "my_search_app_" + i,
+                new String[] { "index_" + i },
+                null,
+                System.currentTimeMillis()
+            );
+            IndexResponse resp = awaitPutSearchApplication(app, false);
+            assertThat(resp.status(), equalTo(RestStatus.CREATED));
+            assertThat(resp.getIndex(), equalTo(SEARCH_APPLICATION_CONCRETE_INDEX_NAME));
+        }
+
+        {
+            for (String queryString : new String[] {
+                "*my_search_app_4*",
+                "name:my_search_app_4",
+                "my_search_app_4",
+                "*_4",
+                "indices:index_4",
+                "index_4",
+                "*_4" }) {
+
+                SearchApplicationIndexService.SearchApplicationResult searchResponse = awaitListSearchApplication(queryString, 0, 10);
+                final List<SearchApplicationListItem> apps = searchResponse.items();
+                assertNotNull(apps);
+                assertThat(apps.size(), equalTo(1));
+                assertThat(searchResponse.totalResults(), equalTo(1L));
+                assertThat(apps.get(0).name(), equalTo("my_search_app_4"));
+                assertThat(apps.get(0).indices(), equalTo(new String[] { "index_4" }));
+            }
+        }
+    }
+
+    public void testDeleteSearchApplication() throws Exception {
+        for (int i = 0; i < 5; i++) {
+            final SearchApplication app = new SearchApplication(
+                "my_search_app_" + i,
+                new String[] { "index_" + i },
+                null,
+                System.currentTimeMillis()
+            );
+            IndexResponse resp = awaitPutSearchApplication(app, false);
+            assertThat(resp.status(), equalTo(RestStatus.CREATED));
+            assertThat(resp.getIndex(), equalTo(SEARCH_APPLICATION_CONCRETE_INDEX_NAME));
+
+            SearchApplication getSearchApp = awaitGetSearchApplication(app.name());
+            assertThat(getSearchApp, equalTo(app));
+        }
+
+        DeleteResponse resp = awaitDeleteSearchApplication("my_search_app_4");
+        assertThat(resp.status(), equalTo(RestStatus.OK));
+        expectThrows(ResourceNotFoundException.class, () -> awaitGetSearchApplication("my_search_app_4"));
+        GetAliasesResponse response = searchAppService.getAlias("my_search_app_4");
+        assertTrue(response.getAliases().isEmpty());
+
+        {
+            SearchApplicationIndexService.SearchApplicationResult searchResponse = awaitListSearchApplication("*:*", 0, 10);
+            final List<SearchApplicationListItem> apps = searchResponse.items();
+            assertNotNull(apps);
+            assertThat(apps.size(), equalTo(4));
+            assertThat(searchResponse.totalResults(), equalTo(4L));
+
+            for (int i = 0; i < 4; i++) {
+                SearchApplicationListItem app = apps.get(i);
+                assertThat(app.name(), equalTo("my_search_app_" + i));
+                assertThat(app.indices(), equalTo(new String[] { "index_" + i }));
+            }
+        }
+    }
+
+    private IndexResponse awaitPutSearchApplication(SearchApplication app, boolean create) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<IndexResponse> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        searchAppService.putSearchApplication(app, create, new ActionListener<>() {
+            @Override
+            public void onResponse(IndexResponse indexResponse) {
+                resp.set(indexResponse);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue(latch.await(5, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull(resp.get());
+        return resp.get();
+    }
+
+    private SearchApplication awaitGetSearchApplication(String name) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<SearchApplication> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        searchAppService.getSearchApplication(name, new ActionListener<>() {
+            @Override
+            public void onResponse(SearchApplication app) {
+                resp.set(app);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue(latch.await(5, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull(resp.get());
+        return resp.get();
+    }
+
+    private DeleteResponse awaitDeleteSearchApplication(String name) throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<DeleteResponse> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        searchAppService.deleteSearchApplicationAndAlias(name, new ActionListener<>() {
+            @Override
+            public void onResponse(DeleteResponse deleteResponse) {
+                resp.set(deleteResponse);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue(latch.await(5, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull(resp.get());
+        return resp.get();
+    }
+
+    private SearchApplicationIndexService.SearchApplicationResult awaitListSearchApplication(String queryString, int from, int size)
+        throws Exception {
+        CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<SearchApplicationIndexService.SearchApplicationResult> resp = new AtomicReference<>(null);
+        final AtomicReference<Exception> exc = new AtomicReference<>(null);
+        searchAppService.listSearchApplication(queryString, from, size, new ActionListener<>() {
+            @Override
+            public void onResponse(SearchApplicationIndexService.SearchApplicationResult result) {
+                resp.set(result);
+                latch.countDown();
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                exc.set(e);
+                latch.countDown();
+            }
+        });
+        assertTrue(latch.await(5, TimeUnit.SECONDS));
+        if (exc.get() != null) {
+            throw exc.get();
+        }
+        assertNotNull(resp.get());
+        return resp.get();
+    }
+
+    /**
+     * Test plugin to register the {@link SearchApplicationIndexService} system index descriptor.
+     */
+    public static class TestPlugin extends Plugin implements SystemIndexPlugin {
+        @Override
+        public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings settings) {
+            return List.of(SearchApplicationIndexService.getSystemIndexDescriptor());
+        }
+
+        @Override
+        public String getFeatureName() {
+            return this.getClass().getSimpleName();
+        }
+
+        @Override
+        public String getFeatureDescription() {
+            return this.getClass().getCanonicalName();
+        }
+    }
+}

+ 40 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/SearchApplicationTestUtils.java

@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search;
+
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.action.util.PageParams;
+
+import static org.elasticsearch.test.ESTestCase.generateRandomStringArray;
+import static org.elasticsearch.test.ESTestCase.randomAlphaOfLengthBetween;
+import static org.elasticsearch.test.ESTestCase.randomFrom;
+import static org.elasticsearch.test.ESTestCase.randomIntBetween;
+import static org.elasticsearch.test.ESTestCase.randomLongBetween;
+
+public final class SearchApplicationTestUtils {
+
+    private SearchApplicationTestUtils() {
+        throw new UnsupportedOperationException("Don't instantiate this class!");
+    }
+
+    public static PageParams randomPageParams() {
+        int from = randomIntBetween(0, 10000);
+        int size = randomIntBetween(0, 10000);
+        PageParams pageParams = new PageParams(from, size);
+        return pageParams;
+    }
+
+    public static SearchApplication randomSearchApplication() {
+        return new SearchApplication(
+            ESTestCase.randomAlphaOfLengthBetween(1, 10),
+            generateRandomStringArray(10, 10, false, false),
+            randomFrom(new String[] { null, randomAlphaOfLengthBetween(1, 10) }),
+            randomLongBetween(0, Long.MAX_VALUE)
+        );
+    }
+}

+ 156 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/SearchApplicationTests.java

@@ -0,0 +1,156 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search;
+
+import org.elasticsearch.Version;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.InputStreamStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentType;
+import org.junit.Before;
+
+import java.io.IOException;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+import static org.elasticsearch.common.xcontent.XContentHelper.toXContent;
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertToXContentEquivalent;
+import static org.hamcrest.CoreMatchers.equalTo;
+
+public class SearchApplicationTests extends ESTestCase {
+    private NamedWriteableRegistry namedWriteableRegistry;
+
+    @Before
+    public void registerNamedObjects() {
+        SearchModule searchModule = new SearchModule(Settings.EMPTY, emptyList());
+
+        List<NamedWriteableRegistry.Entry> namedWriteables = searchModule.getNamedWriteables();
+        namedWriteableRegistry = new NamedWriteableRegistry(namedWriteables);
+    }
+
+    public final void testRandomSerialization() throws IOException {
+        for (int runs = 0; runs < 10; runs++) {
+            SearchApplication testInstance = SearchApplicationTestUtils.randomSearchApplication();
+            assertTransportSerialization(testInstance);
+            assertXContent(testInstance, randomBoolean());
+            assertIndexSerialization(testInstance, Version.CURRENT);
+        }
+    }
+
+    public void testToXContent() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "name": "my_search_app",
+              "indices": ["my_index"],
+              "analytics_collection_name": "my_search_app_analytics",
+              "updated_at_millis": 12345
+            }""");
+        SearchApplication app = SearchApplication.fromXContentBytes("my_search_app", new BytesArray(content), XContentType.JSON);
+        boolean humanReadable = true;
+        BytesReference originalBytes = toShuffledXContent(app, XContentType.JSON, ToXContent.EMPTY_PARAMS, humanReadable);
+        SearchApplication parsed;
+        try (XContentParser parser = createParser(XContentType.JSON.xContent(), originalBytes)) {
+            parsed = SearchApplication.fromXContent(app.name(), parser);
+        }
+        assertToXContentEquivalent(originalBytes, toXContent(parsed, XContentType.JSON, humanReadable), XContentType.JSON);
+    }
+
+    public void testToXContentInvalidSearchApplicationName() throws IOException {
+        String content = XContentHelper.stripWhitespace("""
+            {
+              "name": "different_search_app_name",
+              "indices": ["my_index"],
+              "analytics_collection_name": "my_search_app_analytics",
+              "updated_at_millis": 0
+            }""");
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> SearchApplication.fromXContentBytes("my_search_app", new BytesArray(content), XContentType.JSON)
+        );
+    }
+
+    public void testMerge() throws IOException {
+        String content = """
+            {
+              "indices": ["my_index", "my_index_2"],
+              "updated_at_millis": 0
+            }""";
+
+        String update = """
+            {
+              "indices": ["my_index_2", "my_index"],
+              "analytics_collection_name": "my_search_app_analytics",
+              "updated_at_millis": 12345
+            }""";
+        SearchApplication app = SearchApplication.fromXContentBytes("my_search_app", new BytesArray(content), XContentType.JSON);
+        SearchApplication updatedApp = app.merge(new BytesArray(update), XContentType.JSON, BigArrays.NON_RECYCLING_INSTANCE);
+        assertNotSame(app, updatedApp);
+        assertThat(updatedApp.indices(), equalTo(new String[] { "my_index", "my_index_2" }));
+        assertThat(updatedApp.analyticsCollectionName(), equalTo("my_search_app_analytics"));
+        assertThat(updatedApp.updatedAtMillis(), equalTo(12345L));
+    }
+
+    private SearchApplication assertXContent(SearchApplication app, boolean humanReadable) throws IOException {
+        BytesReference originalBytes = toShuffledXContent(app, XContentType.JSON, ToXContent.EMPTY_PARAMS, humanReadable);
+        SearchApplication parsed;
+        try (XContentParser parser = createParser(XContentType.JSON.xContent(), originalBytes)) {
+            parsed = SearchApplication.fromXContent(app.name(), parser);
+        }
+        assertToXContentEquivalent(originalBytes, toXContent(parsed, XContentType.JSON, humanReadable), XContentType.JSON);
+        return parsed;
+    }
+
+    private SearchApplication assertTransportSerialization(SearchApplication testInstance) throws IOException {
+        return assertTransportSerialization(testInstance, Version.CURRENT);
+    }
+
+    private SearchApplication assertTransportSerialization(SearchApplication testInstance, Version version) throws IOException {
+        SearchApplication deserializedInstance = copyInstance(testInstance, version);
+        assertNotSame(testInstance, deserializedInstance);
+        assertThat(testInstance, equalTo(deserializedInstance));
+        return deserializedInstance;
+    }
+
+    private SearchApplication assertIndexSerialization(SearchApplication testInstance, Version version) throws IOException {
+        final SearchApplication deserializedInstance;
+        try (BytesStreamOutput output = new BytesStreamOutput()) {
+            output.setTransportVersion(version.transportVersion);
+            SearchApplicationIndexService.writeSearchApplicationBinaryWithVersion(
+                testInstance,
+                output,
+                version.minimumCompatibilityVersion()
+            );
+            try (
+                StreamInput in = new NamedWriteableAwareStreamInput(
+                    new InputStreamStreamInput(output.bytes().streamInput()),
+                    namedWriteableRegistry
+                )
+            ) {
+                deserializedInstance = SearchApplicationIndexService.parseSearchApplicationBinaryWithVersion(in);
+            }
+        }
+        assertNotSame(testInstance, deserializedInstance);
+        assertThat(testInstance, equalTo(deserializedInstance));
+        return deserializedInstance;
+    }
+
+    private SearchApplication copyInstance(SearchApplication instance, Version version) throws IOException {
+        return copyWriteable(instance, namedWriteableRegistry, SearchApplication::new, version.transportVersion);
+    }
+}

+ 30 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/DeleteSearchApplicationActionRequestSerializingTests.java

@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+public class DeleteSearchApplicationActionRequestSerializingTests extends AbstractWireSerializingTestCase<
+    DeleteSearchApplicationAction.Request> {
+
+    @Override
+    protected Writeable.Reader<DeleteSearchApplicationAction.Request> instanceReader() {
+        return DeleteSearchApplicationAction.Request::new;
+    }
+
+    @Override
+    protected DeleteSearchApplicationAction.Request createTestInstance() {
+        return new DeleteSearchApplicationAction.Request(randomAlphaOfLengthBetween(1, 10));
+    }
+
+    @Override
+    protected DeleteSearchApplicationAction.Request mutateInstance(DeleteSearchApplicationAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 29 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/GetSearchApplicationActionRequestSerializingTests.java

@@ -0,0 +1,29 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+public class GetSearchApplicationActionRequestSerializingTests extends AbstractWireSerializingTestCase<GetSearchApplicationAction.Request> {
+
+    @Override
+    protected Writeable.Reader<GetSearchApplicationAction.Request> instanceReader() {
+        return GetSearchApplicationAction.Request::new;
+    }
+
+    @Override
+    protected GetSearchApplicationAction.Request createTestInstance() {
+        return new GetSearchApplicationAction.Request(randomAlphaOfLengthBetween(1, 10));
+    }
+
+    @Override
+    protected GetSearchApplicationAction.Request mutateInstance(GetSearchApplicationAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 31 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/GetSearchApplicationActionResponseSerializingTests.java

@@ -0,0 +1,31 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.application.search.SearchApplicationTestUtils;
+
+public class GetSearchApplicationActionResponseSerializingTests extends AbstractWireSerializingTestCase<
+    GetSearchApplicationAction.Response> {
+
+    @Override
+    protected Writeable.Reader<GetSearchApplicationAction.Response> instanceReader() {
+        return GetSearchApplicationAction.Response::new;
+    }
+
+    @Override
+    protected GetSearchApplicationAction.Response createTestInstance() {
+        return new GetSearchApplicationAction.Response(SearchApplicationTestUtils.randomSearchApplication());
+    }
+
+    @Override
+    protected GetSearchApplicationAction.Response mutateInstance(GetSearchApplicationAction.Response instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 35 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/ListSearchApplicationActionRequestSerializingTests.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.application.search.SearchApplicationTestUtils;
+import org.elasticsearch.xpack.core.action.util.PageParams;
+
+public class ListSearchApplicationActionRequestSerializingTests extends AbstractWireSerializingTestCase<
+    ListSearchApplicationAction.Request> {
+
+    @Override
+    protected Writeable.Reader<ListSearchApplicationAction.Request> instanceReader() {
+        return ListSearchApplicationAction.Request::new;
+    }
+
+    @Override
+    protected ListSearchApplicationAction.Request createTestInstance() {
+
+        PageParams pageParams = SearchApplicationTestUtils.randomPageParams();
+        String query = randomFrom(new String[] { null, randomAlphaOfLengthBetween(1, 10) });
+        return new ListSearchApplicationAction.Request(query, pageParams);
+    }
+
+    @Override
+    protected ListSearchApplicationAction.Request mutateInstance(ListSearchApplicationAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 40 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/ListSearchApplicationActionResponseSerializingTests.java

@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.application.search.SearchApplication;
+import org.elasticsearch.xpack.application.search.SearchApplicationListItem;
+import org.elasticsearch.xpack.application.search.SearchApplicationTestUtils;
+
+public class ListSearchApplicationActionResponseSerializingTests extends AbstractWireSerializingTestCase<
+    ListSearchApplicationAction.Response> {
+
+    @Override
+    protected Writeable.Reader<ListSearchApplicationAction.Response> instanceReader() {
+        return ListSearchApplicationAction.Response::new;
+    }
+
+    private static ListSearchApplicationAction.Response randomSearchApplicationListItem() {
+        return new ListSearchApplicationAction.Response(randomList(10, () -> {
+            SearchApplication app = SearchApplicationTestUtils.randomSearchApplication();
+            return new SearchApplicationListItem(app.name(), app.indices(), app.analyticsCollectionName(), app.updatedAtMillis());
+        }), randomLongBetween(0, 1000));
+    }
+
+    @Override
+    protected ListSearchApplicationAction.Response mutateInstance(ListSearchApplicationAction.Response instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+
+    @Override
+    protected ListSearchApplicationAction.Response createTestInstance() {
+        return randomSearchApplicationListItem();
+    }
+}

+ 30 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/PutSearchApplicationActionRequestSerializingTests.java

@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.application.search.SearchApplicationTestUtils;
+
+public class PutSearchApplicationActionRequestSerializingTests extends AbstractWireSerializingTestCase<PutSearchApplicationAction.Request> {
+
+    @Override
+    protected Writeable.Reader<PutSearchApplicationAction.Request> instanceReader() {
+        return PutSearchApplicationAction.Request::new;
+    }
+
+    @Override
+    protected PutSearchApplicationAction.Request createTestInstance() {
+        return new PutSearchApplicationAction.Request(SearchApplicationTestUtils.randomSearchApplication(), randomBoolean());
+    }
+
+    @Override
+    protected PutSearchApplicationAction.Request mutateInstance(PutSearchApplicationAction.Request instance) {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 33 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/PutSearchApplicationActionResponseSerializingTests.java

@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.action.DocWriteResponse;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+
+import java.io.IOException;
+
+public class PutSearchApplicationActionResponseSerializingTests extends AbstractWireSerializingTestCase<
+    PutSearchApplicationAction.Response> {
+
+    @Override
+    protected Writeable.Reader<PutSearchApplicationAction.Response> instanceReader() {
+        return PutSearchApplicationAction.Response::new;
+    }
+
+    @Override
+    protected PutSearchApplicationAction.Response createTestInstance() {
+        return new PutSearchApplicationAction.Response(randomFrom(DocWriteResponse.Result.values()));
+    }
+
+    @Override
+    protected PutSearchApplicationAction.Response mutateInstance(PutSearchApplicationAction.Response instance) throws IOException {
+        return randomValueOtherThan(instance, this::createTestInstance);
+    }
+}

+ 75 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/TransportDeleteSearchApplicationActionTests.java

@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.MockLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TransportDeleteSearchApplicationActionTests extends ESTestCase {
+    public void testWithUnsupportedLicense() {
+        MockLicenseState licenseState = mock(MockLicenseState.class);
+
+        when(licenseState.isAllowed(LicenseUtils.LICENSED_ENT_SEARCH_FEATURE)).thenReturn(false);
+        when(licenseState.isActive()).thenReturn(false);
+        when(licenseState.statusDescription()).thenReturn("invalid license");
+
+        TransportDeleteSearchApplicationAction transportAction = new TransportDeleteSearchApplicationAction(
+            mock(TransportService.class),
+            mock(ActionFilters.class),
+            mock(Client.class),
+            mock(ClusterService.class),
+            mock(NamedWriteableRegistry.class),
+            mock(BigArrays.class),
+            licenseState
+        );
+
+        DeleteSearchApplicationAction.Request request = new DeleteSearchApplicationAction.Request("my-search-app");
+
+        final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
+        final AtomicReference<AcknowledgedResponse> responseRef = new AtomicReference<>();
+
+        transportAction.doExecute(mock(Task.class), request, new ActionListener<>() {
+            @Override
+            public void onResponse(AcknowledgedResponse response) {
+                responseRef.set(response);
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                throwableRef.set(e);
+            }
+        });
+
+        assertThat(responseRef.get(), is(nullValue()));
+        assertThat(throwableRef.get(), instanceOf(ElasticsearchSecurityException.class));
+        assertThat(
+            throwableRef.get().getMessage(),
+            containsString("Search Applications and behavioral analytics require an active trial, platinum or enterprise license.")
+        );
+    }
+}

+ 75 - 0
x-pack/plugin/ent-search/src/test/java/org/elasticsearch/xpack/application/search/action/TransportGetSearchApplicationActionTests.java

@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.application.search.action;
+
+import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.client.internal.Client;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.util.BigArrays;
+import org.elasticsearch.license.MockLicenseState;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.application.utils.LicenseUtils;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TransportGetSearchApplicationActionTests extends ESTestCase {
+    public void testWithUnsupportedLicense() {
+        MockLicenseState licenseState = mock(MockLicenseState.class);
+
+        when(licenseState.isAllowed(LicenseUtils.LICENSED_ENT_SEARCH_FEATURE)).thenReturn(false);
+        when(licenseState.isActive()).thenReturn(false);
+        when(licenseState.statusDescription()).thenReturn("invalid license");
+
+        TransportGetSearchApplicationAction transportAction = new TransportGetSearchApplicationAction(
+            mock(TransportService.class),
+            mock(ActionFilters.class),
+            mock(Client.class),
+            mock(ClusterService.class),
+            mock(NamedWriteableRegistry.class),
+            mock(BigArrays.class),
+            licenseState
+        );
+
+        String appId = randomFrom(new String[] { null, randomAlphaOfLengthBetween(1, 10) });
+        GetSearchApplicationAction.Request request = new GetSearchApplicationAction.Request(appId);
+
+        final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
+        final AtomicReference<GetSearchApplicationAction.Response> responseRef = new AtomicReference<>();
+
+        transportAction.doExecute(mock(Task.class), request, new ActionListener<>() {
+            @Override
+            public void onResponse(GetSearchApplicationAction.Response response) {
+                responseRef.set(response);
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                throwableRef.set(e);
+            }
+        });
+
+        assertThat(responseRef.get(), is(nullValue()));
+        assertThat(throwableRef.get(), instanceOf(ElasticsearchSecurityException.class));
+        assertThat(
+            throwableRef.get().getMessage(),
+            containsString("Search Applications and behavioral analytics require an active trial, platinum or enterprise license.")
+        );
+    }
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно