From 36f5a55019337e084900644ff4ffecaa95e18cb0 Mon Sep 17 00:00:00 2001 From: Michael Peterson Date: Wed, 22 Jan 2025 13:27:50 -0500 Subject: [PATCH] Resolve/cluster allows querying for cluster info only (no index expression required) (#119898) (#120650) Resolve/cluster allows querying for cluster-info-only (no index expression required) This enhancement provides users with the ability to query the _resolve/cluster API endpoint without specifying an index expression to match against. This allows users to quickly test what remote clusters are configured on a cluster and whether they are available for querying. The new endpoint takes no index expression: ``` GET _resolve/cluster ``` and returns the same information as before except for the "matching_indices" field. Example response: ``` { "remote1": { "connected": false, "skip_unavailable": true }, "remote2": { "connected": true, "skip_unavailable": false, "version": { "number": "8.17.0", "build_flavor": "default", "minimum_wire_compatibility_version": "7.17.0", "minimum_index_compatibility_version": "7.0.0" } } } ``` For backwards compatibility, this new endpoint works with clusters from older versions by querying with the index expression `dummy*` on those older clusters and ignoring the matching_indices value in the response they return. --- docs/changelog/119898.yaml | 6 + .../indices/resolve-cluster.asciidoc | 37 ++++- .../indices/cluster/ResolveClusterIT.java | 107 +++++++++++++- .../org/elasticsearch/TransportVersions.java | 1 + .../resolve/ResolveClusterActionRequest.java | 62 ++++++-- .../indices/resolve/ResolveClusterInfo.java | 20 +-- .../TransportResolveClusterAction.java | 139 ++++++++++++++---- .../indices/RestResolveClusterAction.java | 37 ++++- .../transport/RemoteClusterService.java | 29 +++- .../ResolveClusterActionRequestTests.java | 37 +++-- .../ResolveClusterActionResponseTests.java | 3 +- .../TransportResolveClusterActionTests.java | 8 +- ...teClusterSecurityRCS1ResolveClusterIT.java | 81 +++++++++- ...teClusterSecurityRCS2ResolveClusterIT.java | 83 ++++++++++- 14 files changed, 560 insertions(+), 90 deletions(-) create mode 100644 docs/changelog/119898.yaml diff --git a/docs/changelog/119898.yaml b/docs/changelog/119898.yaml new file mode 100644 index 0000000000000..43a73778aeed2 --- /dev/null +++ b/docs/changelog/119898.yaml @@ -0,0 +1,6 @@ +pr: 119898 +summary: Resolve/cluster allows querying for cluster info only (no index expression + required) +area: CCS +type: enhancement +issues: [] diff --git a/docs/reference/indices/resolve-cluster.asciidoc b/docs/reference/indices/resolve-cluster.asciidoc index a0583f4d7beaf..b1d379e50557c 100644 --- a/docs/reference/indices/resolve-cluster.asciidoc +++ b/docs/reference/indices/resolve-cluster.asciidoc @@ -11,7 +11,9 @@ For the most up-to-date API details, refer to {api-es}/group/endpoint-indices[In -- Resolves the specified index expressions to return information about -each cluster, including the local cluster, if included. +each cluster, including the local "querying" cluster, if included. If no index expression +is provided, this endpoint will return information about all the remote +clusters that are configured on the querying cluster. This endpoint is useful before doing a <> in order to determine which remote clusters should be included in a search. @@ -20,14 +22,13 @@ You use the same index expression with this endpoint as you would for cross-clus search. Index and <> are also supported with this endpoint. -For each cluster in the index expression, information is returned about: +For each cluster in scope, information is returned about: -1. whether the querying ("local") cluster is currently connected to each remote cluster - in the index expression scope +1. whether the querying ("local") cluster is currently connected to it 2. whether each remote cluster is configured with `skip_unavailable` as `true` or `false` 3. whether there are any indices, aliases or data streams on that cluster that match - the index expression -4. whether the search is likely to have errors returned when you do the {ccs} (including any + the index expression (if one provided) +4. whether the search is likely to have errors returned when you do a {ccs} (including any authorization errors if your user does not have permission to query a remote cluster or the indices on that cluster) 5. (in some cases) cluster version information, including the Elasticsearch server version @@ -41,6 +42,11 @@ Once the proper security permissions are obtained, then you can rely on the `con in the response to determine whether the remote cluster is available and ready for querying. ==== +NOTE: When querying older clusters that do not support the _resolve/cluster endpoint +without an index expression, the local cluster will send the index expression `dummy*` +to those remote clusters, so if an errors occur, you may see a reference to that index +expression even though you didn't request it. If it causes a problem, you can instead +include an index expression like `*:*` to this endpoint to bypass the issue. //// [source,console] @@ -71,14 +77,22 @@ PUT _cluster/settings // TEST[s/35.238.149.\d+:930\d+/\${transport_host}/] //// +[source,console] +---- +GET /_resolve/cluster +---- +// TEST[continued] + +Returns information about all remote clusters configured on the local cluster. + [source,console] ---- GET /_resolve/cluster/my-index-*,cluster*:my-index-* ---- // TEST[continued] -This will return information about the local cluster and all remotely configured -clusters that start with the alias `cluster*`. Each cluster will return information +Returns information about the local cluster and all remote clusters that +start with the alias `cluster*`. Each cluster will return information about whether it has any indices, aliases or data streams that match `my-index-*`. [[resolve-cluster-api-request]] @@ -126,6 +140,13 @@ ignored when frozen. Defaults to `false`. + deprecated:[7.16.0] +[TIP] +==== +The index options above are only allowed when specifying an index expression. +You will get an error if you specify index options to the _resolve/cluster API +that takes no index expression. +==== + [discrete] [[usecases-for-resolve-cluster]] diff --git a/server/src/internalClusterTest/java/org/elasticsearch/indices/cluster/ResolveClusterIT.java b/server/src/internalClusterTest/java/org/elasticsearch/indices/cluster/ResolveClusterIT.java index 1a6674edc5147..99ba5cf073ce5 100644 --- a/server/src/internalClusterTest/java/org/elasticsearch/indices/cluster/ResolveClusterIT.java +++ b/server/src/internalClusterTest/java/org/elasticsearch/indices/cluster/ResolveClusterIT.java @@ -16,6 +16,7 @@ import org.elasticsearch.action.admin.indices.resolve.ResolveClusterActionResponse; import org.elasticsearch.action.admin.indices.resolve.ResolveClusterInfo; import org.elasticsearch.action.admin.indices.resolve.TransportResolveClusterAction; +import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.client.internal.Client; import org.elasticsearch.cluster.metadata.IndexMetadata; import org.elasticsearch.common.Strings; @@ -406,6 +407,52 @@ public void testClusterResolveWithIndices() throws IOException { } } + // corresponds to the GET _resolve/cluster endpoint with no index expression specified + public void testClusterResolveWithNoIndexExpression() throws IOException { + Map testClusterInfo = setupThreeClusters(false); + boolean skipUnavailable1 = (Boolean) testClusterInfo.get("remote1.skip_unavailable"); + boolean skipUnavailable2 = true; + + { + String[] noIndexSpecified = new String[0]; + boolean clusterInfoOnly = true; + boolean runningOnQueryingCluster = true; + ResolveClusterActionRequest request = new ResolveClusterActionRequest( + noIndexSpecified, + IndicesOptions.DEFAULT, + clusterInfoOnly, + runningOnQueryingCluster + ); + + ActionFuture future = client(LOCAL_CLUSTER).admin() + .indices() + .execute(TransportResolveClusterAction.TYPE, request); + ResolveClusterActionResponse response = future.actionGet(30, TimeUnit.SECONDS); + assertNotNull(response); + + Map clusterInfo = response.getResolveClusterInfo(); + assertEquals(2, clusterInfo.size()); + + // only remote clusters should be present (not local) + Set expectedClusterNames = Set.of(REMOTE_CLUSTER_1, REMOTE_CLUSTER_2); + assertThat(clusterInfo.keySet(), equalTo(expectedClusterNames)); + + ResolveClusterInfo remote1 = clusterInfo.get(REMOTE_CLUSTER_1); + assertThat(remote1.isConnected(), equalTo(true)); + assertThat(remote1.getSkipUnavailable(), equalTo(skipUnavailable1)); + assertThat(remote1.getMatchingIndices(), equalTo(null)); // should not be set + assertNotNull(remote1.getBuild().version()); + assertNull(remote1.getError()); + + ResolveClusterInfo remote2 = clusterInfo.get(REMOTE_CLUSTER_2); + assertThat(remote2.isConnected(), equalTo(true)); + assertThat(remote2.getSkipUnavailable(), equalTo(skipUnavailable2)); + assertThat(remote2.getMatchingIndices(), equalTo(null)); // should not be set + assertNotNull(remote2.getBuild().version()); + assertNull(remote2.getError()); + } + } + public void testClusterResolveWithMatchingAliases() throws IOException { Map testClusterInfo = setupThreeClusters(true); String localAlias = (String) testClusterInfo.get("local.alias"); @@ -523,6 +570,24 @@ public void testClusterResolveWithMatchingAliases() throws IOException { } } + public void testClusterResolveWithNoMatchingClustersReturnsEmptyResult() throws Exception { + setupThreeClusters(false); + { + String[] indexExpressions = new String[] { "no_matching_cluster*:foo" }; + ResolveClusterActionRequest request = new ResolveClusterActionRequest(indexExpressions); + + ActionFuture future = client(LOCAL_CLUSTER).admin() + .indices() + .execute(TransportResolveClusterAction.TYPE, request); + ResolveClusterActionResponse response = future.actionGet(10, TimeUnit.SECONDS); + assertNotNull(response); + + Map clusterInfo = response.getResolveClusterInfo(); + assertEquals(0, clusterInfo.size()); + assertThat(Strings.toString(response), equalTo("{}")); + } + } + public void testClusterResolveDisconnectedAndErrorScenarios() throws Exception { Map testClusterInfo = setupThreeClusters(false); String localIndex = (String) testClusterInfo.get("local.index"); @@ -616,9 +681,49 @@ public void testClusterResolveDisconnectedAndErrorScenarios() throws Exception { assertNotNull(local.getBuild().version()); assertNull(local.getError()); } + + // cluster1 was stopped/disconnected, so it should return a connected:false response when querying with no index expression, + // corresponding to GET _resolve/cluster endpoint + { + String[] noIndexSpecified = new String[0]; + boolean clusterInfoOnly = true; + boolean runningOnQueryingCluster = true; + ResolveClusterActionRequest request = new ResolveClusterActionRequest( + noIndexSpecified, + IndicesOptions.DEFAULT, + clusterInfoOnly, + runningOnQueryingCluster + ); + + ActionFuture future = client(LOCAL_CLUSTER).admin() + .indices() + .execute(TransportResolveClusterAction.TYPE, request); + ResolveClusterActionResponse response = future.actionGet(30, TimeUnit.SECONDS); + assertNotNull(response); + + Map clusterInfo = response.getResolveClusterInfo(); + assertEquals(2, clusterInfo.size()); + // local cluster is not present when querying without an index expression + Set expectedClusterNames = Set.of(REMOTE_CLUSTER_1, REMOTE_CLUSTER_2); + assertThat(clusterInfo.keySet(), equalTo(expectedClusterNames)); + + ResolveClusterInfo remote1 = clusterInfo.get(REMOTE_CLUSTER_1); + assertThat(remote1.isConnected(), equalTo(false)); + assertThat(remote1.getSkipUnavailable(), equalTo(skipUnavailable1)); + assertNull(remote1.getMatchingIndices()); + assertNull(remote1.getBuild()); + assertNull(remote1.getError()); + + ResolveClusterInfo remote2 = clusterInfo.get(REMOTE_CLUSTER_2); + assertThat(remote2.isConnected(), equalTo(true)); + assertThat(remote2.getSkipUnavailable(), equalTo(skipUnavailable2)); + assertNull(remote2.getMatchingIndices()); // not present when no index expression specified + assertNotNull(remote2.getBuild().version()); + assertNull(remote2.getError()); + } } - private Map setupThreeClusters(boolean useAlias) throws IOException { + private Map setupThreeClusters(boolean useAlias) { String localAlias = randomAlphaOfLengthBetween(5, 25); String remoteAlias1 = randomAlphaOfLengthBetween(5, 25); String remoteAlias2 = randomAlphaOfLengthBetween(5, 25); diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index b37cabdad627b..b9055d56302be 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -166,6 +166,7 @@ static TransportVersion def(int id) { public static final TransportVersion REVERT_BYTE_SIZE_VALUE_ALWAYS_USES_BYTES_1 = def(8_826_00_0); public static final TransportVersion ESQL_SKIP_ES_INDEX_SERIALIZATION = def(8_827_00_0); public static final TransportVersion ADD_INDEX_BLOCK_TWO_PHASE = def(8_828_00_0); + public static final TransportVersion RESOLVE_CLUSTER_NO_INDEX_EXPRESSION = def(8_829_00_0); /* * STOP! READ THIS FIRST! No, really, diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequest.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequest.java index ebc9b0fea1be4..0fcc8e1115209 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequest.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequest.java @@ -14,7 +14,6 @@ import org.elasticsearch.action.ActionRequest; import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.IndicesRequest; -import org.elasticsearch.action.ValidateActions; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.common.Strings; import org.elasticsearch.common.io.stream.StreamInput; @@ -53,15 +52,25 @@ public class ResolveClusterActionRequest extends ActionRequest implements Indice private boolean localIndicesRequested = false; private IndicesOptions indicesOptions; + // true if the user did not provide any index expression - they only want cluster level info, not index matching + private final boolean clusterInfoOnly; + // Whether this request is being processed on the primary ("local") cluster being queried or on a remote. + // This is needed when clusterInfoOnly=true since we need to know whether to list out all possible remotes + // on a node. (We don't want cross-cluster chaining on remotes that might be configured with their own remotes.) + private final boolean isQueryingCluster; + public ResolveClusterActionRequest(String[] names) { - this(names, DEFAULT_INDICES_OPTIONS); + this(names, DEFAULT_INDICES_OPTIONS, false, true); + assert names != null && names.length > 0 : "One or more index expressions must be included with this constructor"; } @SuppressWarnings("this-escape") - public ResolveClusterActionRequest(String[] names, IndicesOptions indicesOptions) { + public ResolveClusterActionRequest(String[] names, IndicesOptions indicesOptions, boolean clusterInfoOnly, boolean queryingCluster) { this.names = names; this.localIndicesRequested = localIndicesPresent(names); this.indicesOptions = indicesOptions; + this.clusterInfoOnly = clusterInfoOnly; + this.isQueryingCluster = queryingCluster; } @SuppressWarnings("this-escape") @@ -73,6 +82,13 @@ public ResolveClusterActionRequest(StreamInput in) throws IOException { this.names = in.readStringArray(); this.indicesOptions = IndicesOptions.readIndicesOptions(in); this.localIndicesRequested = localIndicesPresent(names); + if (in.getTransportVersion().onOrAfter(TransportVersions.RESOLVE_CLUSTER_NO_INDEX_EXPRESSION)) { + this.clusterInfoOnly = in.readBoolean(); + this.isQueryingCluster = in.readBoolean(); + } else { + this.clusterInfoOnly = false; + this.isQueryingCluster = false; + } } @Override @@ -83,9 +99,13 @@ public void writeTo(StreamOutput out) throws IOException { } out.writeStringArray(names); indicesOptions.writeIndicesOptions(out); + if (out.getTransportVersion().onOrAfter(TransportVersions.RESOLVE_CLUSTER_NO_INDEX_EXPRESSION)) { + out.writeBoolean(clusterInfoOnly); + out.writeBoolean(isQueryingCluster); + } } - private String createVersionErrorMessage(TransportVersion versionFound) { + static String createVersionErrorMessage(TransportVersion versionFound) { return Strings.format( "%s %s but was %s", TRANSPORT_VERSION_ERROR_MESSAGE_PREFIX, @@ -96,11 +116,7 @@ private String createVersionErrorMessage(TransportVersion versionFound) { @Override public ActionRequestValidationException validate() { - ActionRequestValidationException validationException = null; - if (names == null || names.length == 0) { - validationException = ValidateActions.addValidationError("no index expressions specified", validationException); - } - return validationException; + return null; } @Override @@ -123,6 +139,14 @@ public String[] indices() { return names; } + public boolean clusterInfoOnly() { + return clusterInfoOnly; + } + + public boolean queryingCluster() { + return isQueryingCluster; + } + public boolean isLocalIndicesRequested() { return localIndicesRequested; } @@ -160,7 +184,11 @@ public Task createTask(long id, String type, String action, TaskId parentTaskId, return new CancellableTask(id, type, action, "", parentTaskId, headers) { @Override public String getDescription() { - return "resolve/cluster for " + Arrays.toString(indices()); + if (indices().length == 0) { + return "resolve/cluster"; + } else { + return "resolve/cluster for " + Arrays.toString(indices()); + } } }; } @@ -173,4 +201,18 @@ boolean localIndicesPresent(String[] indices) { } return false; } + + @Override + public String toString() { + return "ResolveClusterActionRequest{" + + "indices=" + + Arrays.toString(names) + + ", localIndicesRequested=" + + localIndicesRequested + + ", clusterInfoOnly=" + + clusterInfoOnly + + ", queryingCluster=" + + isQueryingCluster + + '}'; + } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterInfo.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterInfo.java index b539c87adc484..5c4a9c75f26d2 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterInfo.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterInfo.java @@ -22,7 +22,7 @@ public class ResolveClusterInfo implements Writeable { private final boolean connected; private final Boolean skipUnavailable; // remote clusters don't know their setting, so they put null and querying cluster fills in - private final Boolean matchingIndices; // null means 'unknown' when not connected + private final Boolean matchingIndices; // null means no index expression requested by user or remote cluster was not connected private final Build build; private final String error; @@ -38,8 +38,14 @@ public ResolveClusterInfo(boolean connected, Boolean skipUnavailable, Boolean ma this(connected, skipUnavailable, matchingIndices, build, null); } - public ResolveClusterInfo(ResolveClusterInfo copyFrom, boolean skipUnavailable) { - this(copyFrom.isConnected(), skipUnavailable, copyFrom.getMatchingIndices(), copyFrom.getBuild(), copyFrom.getError()); + public ResolveClusterInfo(ResolveClusterInfo copyFrom, boolean skipUnavailable, boolean clusterInfoOnly) { + this( + copyFrom.isConnected(), + skipUnavailable, + clusterInfoOnly ? null : copyFrom.getMatchingIndices(), + copyFrom.getBuild(), + clusterInfoOnly ? null : copyFrom.getError() + ); } private ResolveClusterInfo(boolean connected, Boolean skipUnavailable, Boolean matchingIndices, Build build, String error) { @@ -48,7 +54,6 @@ private ResolveClusterInfo(boolean connected, Boolean skipUnavailable, Boolean m this.matchingIndices = matchingIndices; this.build = build; this.error = error; - assert error != null || matchingIndices != null || connected == false : "If matchingIndices is null, connected must be false"; } public ResolveClusterInfo(StreamInput in) throws IOException { @@ -67,12 +72,7 @@ public ResolveClusterInfo(StreamInput in) throws IOException { @Override public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().before(TransportVersions.V_8_13_0)) { - throw new UnsupportedOperationException( - "ResolveClusterAction requires at least version " - + TransportVersions.V_8_13_0.toReleaseVersion() - + " but was " - + out.getTransportVersion().toReleaseVersion() - ); + throw new UnsupportedOperationException(ResolveClusterActionRequest.createVersionErrorMessage(out.getTransportVersion())); } out.writeBoolean(connected); out.writeOptionalBoolean(skipUnavailable); diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterAction.java index 50dbaf33d2e4f..9d82b1edff0a9 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterAction.java @@ -59,6 +59,8 @@ public class TransportResolveClusterAction extends HandledTransportAction remoteClusterIndices = remoteClusterService.groupIndices(request.indicesOptions(), request.indices()); - OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); Map clusterInfoMap = new ConcurrentHashMap<>(); + Map remoteClusterIndices; + if (request.clusterInfoOnly()) { + if (request.queryingCluster()) { + /* + * User does not want to check whether an index expression matches, so we use the "*:dummy*" index pattern to + * 1) determine all the local configured remote cluster and + * 2) for older clusters that do not understand the new clusterInfoOnly setting (or for even older clusters + * where we need to fall back to using _resolve/index), we have to provide an index expression so use dummy* + * and then ignore the matching_indices value that comes back from those remotes. This is preferable to sending + * just "*" since that could be an expensive operation on clusters with thousands of indices/aliases/datastreams + */ + String[] dummyIndexExpr = new String[] { DUMMY_INDEX_FOR_OLDER_CLUSTERS }; + remoteClusterIndices = remoteClusterService.groupIndices(IndicesOptions.DEFAULT, dummyIndexExpr, false); + if (remoteClusterIndices.isEmpty()) { + // no remote clusters are configured on the primary "querying" cluster + listener.onResponse(new ResolveClusterActionResponse(Map.of())); + return; + } + } else { + // on remote if clusterInfoOnly is requested, don't bother with index expression matching + ResolveClusterInfo resolveClusterInfo = new ResolveClusterInfo(true, false, null, Build.current()); + clusterInfoMap.put(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY, resolveClusterInfo); + listener.onResponse(new ResolveClusterActionResponse(clusterInfoMap)); + return; + } + } else { + remoteClusterIndices = remoteClusterService.groupIndices(request.indicesOptions(), request.indices(), false); + } + + OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); + // add local cluster info if in scope of the index-expression from user if (localIndices != null) { try { @@ -142,7 +173,12 @@ protected void doExecuteForked(Task task, ResolveClusterActionRequest request, A searchCoordinationExecutor, RemoteClusterService.DisconnectedStrategy.FAIL_IF_DISCONNECTED ); - var remoteRequest = new ResolveClusterActionRequest(originalIndices.indices(), request.indicesOptions()); + var remoteRequest = new ResolveClusterActionRequest( + originalIndices.indices(), + request.indicesOptions(), + request.clusterInfoOnly(), + false + ); // allow cancellation requests to propagate to remote clusters remoteRequest.setParentTask(clusterService.localNode().getId(), task.getId()); @@ -155,7 +191,7 @@ public void onResponse(ResolveClusterActionResponse response) { } ResolveClusterInfo info = response.getResolveClusterInfo().get(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); if (info != null) { - clusterInfoMap.put(clusterAlias, new ResolveClusterInfo(info, skipUnavailable)); + clusterInfoMap.put(clusterAlias, new ResolveClusterInfo(info, skipUnavailable, request.clusterInfoOnly())); } if (resolveClusterTask.isCancelled()) { releaseResourcesOnCancel(clusterInfoMap); @@ -196,28 +232,13 @@ public void onFailure(Exception failure) { originalIndices.indices(), originalIndices.indicesOptions() ); - ActionListener resolveIndexActionListener = new ActionListener<>() { - @Override - public void onResponse(ResolveIndexAction.Response response) { - boolean matchingIndices = response.getIndices().size() > 0 - || response.getAliases().size() > 0 - || response.getDataStreams().size() > 0; - clusterInfoMap.put( - clusterAlias, - new ResolveClusterInfo(true, skipUnavailable, matchingIndices, null) - ); - } - - @Override - public void onFailure(Exception e) { - Throwable cause = ExceptionsHelper.unwrapCause(e); - clusterInfoMap.put(clusterAlias, new ResolveClusterInfo(false, skipUnavailable, cause.toString())); - logger.warn( - () -> Strings.format("Failure from _resolve/cluster lookup against cluster %s: ", clusterAlias), - e - ); - } - }; + ActionListener resolveIndexActionListener = createResolveIndexActionListener( + clusterAlias, + request.clusterInfoOnly(), + skipUnavailable, + clusterInfoMap, + resolveClusterTask + ); remoteClusterClient.execute( ResolveIndexAction.REMOTE_TYPE, resolveIndexRequest, @@ -238,7 +259,73 @@ public void onFailure(Exception e) { releaseResourcesOnCancel(clusterInfoMap); } } + + /** + * Create an ActionListener to handle responses from calls when falling back to use the resolve/index + * endpoint from older clusters that don't have the resolve/cluster endpoint. + */ + private static ActionListener createResolveIndexActionListener( + String clusterAlias, + boolean clusterInfoOnly, + boolean skipUnavailable, + Map clusterInfoMap, + CancellableTask resolveClusterTask + ) { + return new ActionListener<>() { + @Override + public void onResponse(ResolveIndexAction.Response response) { + if (resolveClusterTask.isCancelled()) { + releaseResourcesOnCancel(clusterInfoMap); + return; + } + + Boolean matchingIndices = null; + if (clusterInfoOnly == false) { + matchingIndices = response.getIndices().size() > 0 + || response.getAliases().size() > 0 + || response.getDataStreams().size() > 0; + } + clusterInfoMap.put(clusterAlias, new ResolveClusterInfo(true, skipUnavailable, matchingIndices, null)); + } + + @Override + public void onFailure(Exception e) { + if (resolveClusterTask.isCancelled()) { + releaseResourcesOnCancel(clusterInfoMap); + return; + } + + ResolveClusterInfo resolveClusterInfo; + if (ExceptionsHelper.isRemoteUnavailableException((e))) { + resolveClusterInfo = new ResolveClusterInfo(false, skipUnavailable); + } else if (ExceptionsHelper.unwrap( + e, + ElasticsearchSecurityException.class + ) instanceof ElasticsearchSecurityException ese) { + /* + * some ElasticsearchSecurityExceptions come from the local cluster security interceptor after you've + * issued the client.execute call but before any call went to the remote cluster, so with an + * ElasticsearchSecurityException you can't tell whether the remote cluster is available or not, so mark + * it as connected=false + */ + resolveClusterInfo = new ResolveClusterInfo(false, skipUnavailable, ese.getMessage()); + } else if (ExceptionsHelper.unwrap(e, IndexNotFoundException.class) instanceof IndexNotFoundException ie) { + resolveClusterInfo = new ResolveClusterInfo(true, skipUnavailable, ie.getMessage()); + } else { + // not clear what the error is here, so be safe and mark the cluster as not connected + String errorMessage = ExceptionsHelper.unwrapCause(e).getMessage(); + resolveClusterInfo = new ResolveClusterInfo(false, skipUnavailable, errorMessage); + logger.warn( + () -> Strings.format("Failure from _resolve/index lookup against cluster %s: ", clusterAlias), + e + ); + } + clusterInfoMap.put(clusterAlias, resolveClusterInfo); + } + }; + } }; + remoteClusterClient.execute( TransportResolveClusterAction.REMOTE_TYPE, remoteRequest, diff --git a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveClusterAction.java b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveClusterAction.java index a4e8edc2bc801..886c4da47d974 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveClusterAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveClusterAction.java @@ -15,6 +15,7 @@ import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.client.internal.node.NodeClient; import org.elasticsearch.common.Strings; +import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.rest.BaseRestHandler; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.action.RestCancellableNodeClient; @@ -22,11 +23,19 @@ import java.io.IOException; import java.util.List; +import java.util.Set; import static org.elasticsearch.rest.RestRequest.Method.GET; public class RestResolveClusterAction extends BaseRestHandler { + private static Set INDEX_OPTIONS_PARAMS = Set.of( + "expand_wildcards", + "ignore_unavailable", + "allow_no_indices", + "ignore_throttled" + ); + @Override public String getName() { return "resolve_cluster_action"; @@ -34,18 +43,40 @@ public String getName() { @Override public List routes() { - return List.of(new Route(GET, "/_resolve/cluster/{name}")); + return List.of(new Route(GET, "/_resolve/cluster"), new Route(GET, "/_resolve/cluster/{name}")); } @Override protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { - String[] indexExpressions = Strings.splitStringByCommaToArray(request.param("name")); + String[] indexExpressions; + boolean clusterInfoOnly; + if (request.hasParam("name")) { + indexExpressions = Strings.splitStringByCommaToArray(request.param("name")); + clusterInfoOnly = false; + } else { + indexExpressions = new String[0]; + clusterInfoOnly = true; + Set indexOptions = requestIndexOptionsParams(request); + if (indexOptions.isEmpty() == false) { + // this restriction avoids problems with having to send wildcarded index expressions to older clusters + // when no index expression is provided by the user + throw new IllegalArgumentException( + "No index options are allowed on _resolve/cluster when no index expression is specified, but received: " + indexOptions + ); + } + } ResolveClusterActionRequest resolveRequest = new ResolveClusterActionRequest( indexExpressions, - IndicesOptions.fromRequest(request, ResolveIndexAction.Request.DEFAULT_INDICES_OPTIONS) + IndicesOptions.fromRequest(request, ResolveIndexAction.Request.DEFAULT_INDICES_OPTIONS), + clusterInfoOnly, + true ); return channel -> new RestCancellableNodeClient(client, request.getHttpChannel()).admin() .indices() .execute(TransportResolveClusterAction.TYPE, resolveRequest, new RestToXContentListener<>(channel)); } + + private static Set requestIndexOptionsParams(RestRequest request) { + return Sets.intersection(request.params().keySet(), INDEX_OPTIONS_PARAMS); + } } diff --git a/server/src/main/java/org/elasticsearch/transport/RemoteClusterService.java b/server/src/main/java/org/elasticsearch/transport/RemoteClusterService.java index 5e955539ee2ee..60c295ccb4f5a 100644 --- a/server/src/main/java/org/elasticsearch/transport/RemoteClusterService.java +++ b/server/src/main/java/org/elasticsearch/transport/RemoteClusterService.java @@ -183,12 +183,24 @@ boolean isRemoteNodeConnected(final String remoteCluster, final DiscoveryNode no return remoteClusters.get(remoteCluster).isNodeConnected(node); } - public Map groupIndices(IndicesOptions indicesOptions, String[] indices) { + /** + * Group indices by cluster alias mapped to OriginalIndices for that cluster. + * @param indicesOptions IndicesOptions to clarify how the index expressions should be parsed/applied + * @param indices Multiple index expressions as string[]. + * @param returnLocalAll whether to support the _all functionality needed by _search + * (See https://github.com/elastic/elasticsearch/pull/33899). If true, and no indices are specified, + * then a Map with one entry for the local cluster with an empty index array is returned. + * If false, an empty map is returned when no indices are specified. + * @return Map keyed by cluster alias having OriginalIndices as the map value parsed from the String[] indices argument + */ + public Map groupIndices(IndicesOptions indicesOptions, String[] indices, boolean returnLocalAll) { final Map originalIndicesMap = new HashMap<>(); final Map> groupedIndices = groupClusterIndices(getRemoteClusterNames(), indices); if (groupedIndices.isEmpty()) { - // search on _all in the local cluster if neither local indices nor remote indices were specified - originalIndicesMap.put(LOCAL_CLUSTER_GROUP_KEY, new OriginalIndices(Strings.EMPTY_ARRAY, indicesOptions)); + if (returnLocalAll) { + // search on _all in the local cluster if neither local indices nor remote indices were specified + originalIndicesMap.put(LOCAL_CLUSTER_GROUP_KEY, new OriginalIndices(Strings.EMPTY_ARRAY, indicesOptions)); + } } else { for (Map.Entry> entry : groupedIndices.entrySet()) { String clusterAlias = entry.getKey(); @@ -199,6 +211,17 @@ public Map groupIndices(IndicesOptions indicesOptions, return originalIndicesMap; } + /** + * If no indices are specified, then a Map with one entry for the local cluster with an empty index array is returned. + * For details see {@code groupIndices(IndicesOptions indicesOptions, String[] indices, boolean returnLocalAll)} + * @param indicesOptions IndicesOptions to clarify how the index expressions should be parsed/applied + * @param indices Multiple index expressions as string[]. + * @return Map keyed by cluster alias having OriginalIndices as the map value parsed from the String[] indices argument + */ + public Map groupIndices(IndicesOptions indicesOptions, String[] indices) { + return groupIndices(indicesOptions, indices, true); + } + /** * Returns true iff the given cluster is configured as a remote cluster. Otherwise false */ diff --git a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequestTests.java index fdfe643f96066..68db5d3673376 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequestTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionRequestTests.java @@ -9,7 +9,6 @@ package org.elasticsearch.action.admin.indices.resolve; -import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.util.ArrayUtils; @@ -31,19 +30,23 @@ protected Writeable.Reader instanceReader() { @Override protected ResolveClusterActionRequest createTestInstance() { - String[] names = generateRandomStringArray(1, 7, false); - IndicesOptions indicesOptions = IndicesOptions.fromOptions( - randomBoolean(), - randomBoolean(), - randomBoolean(), - randomBoolean(), - randomBoolean(), - randomBoolean(), - randomBoolean(), - randomBoolean(), - randomBoolean() - ); - return new ResolveClusterActionRequest(names, indicesOptions); + if (randomInt(5) == 3) { + return new ResolveClusterActionRequest(new String[0], IndicesOptions.DEFAULT, true, randomBoolean()); + } else { + String[] names = generateRandomStringArray(1, 7, false); + IndicesOptions indicesOptions = IndicesOptions.fromOptions( + randomBoolean(), + randomBoolean(), + randomBoolean(), + randomBoolean(), + randomBoolean(), + randomBoolean(), + randomBoolean(), + randomBoolean(), + randomBoolean() + ); + return new ResolveClusterActionRequest(names, indicesOptions, false, randomBoolean()); + } } @Override @@ -71,12 +74,6 @@ protected ResolveClusterActionRequest mutateInstance(ResolveClusterActionRequest return mutatedInstance; } - public void testValidation() { - ResolveClusterActionRequest request = new ResolveClusterActionRequest(new String[0]); - ActionRequestValidationException exception = request.validate(); - assertNotNull(exception); - } - public void testLocalIndicesPresent() { { String[] indicesOrig = new String[] { "*" }; diff --git a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionResponseTests.java b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionResponseTests.java index 1d4ddde0c75e4..1410beb1ac41a 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionResponseTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/ResolveClusterActionResponseTests.java @@ -54,11 +54,12 @@ private Map randomResolveClusterInfoMap(ResolveClust } static ResolveClusterInfo randomResolveClusterInfo() { - int val = randomIntBetween(1, 3); + int val = randomIntBetween(1, 4); return switch (val) { case 1 -> new ResolveClusterInfo(false, randomBoolean()); case 2 -> new ResolveClusterInfo(randomBoolean(), randomBoolean(), randomAlphaOfLength(15)); case 3 -> new ResolveClusterInfo(randomBoolean(), randomBoolean(), randomBoolean(), Build.current()); + case 4 -> new ResolveClusterInfo(true, randomBoolean(), null, Build.current()); default -> throw new UnsupportedOperationException("should not get here"); }; } diff --git a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterActionTests.java b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterActionTests.java index 2c618f19a3c75..824ad22b1af20 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterActionTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveClusterActionTests.java @@ -66,12 +66,8 @@ public void testCCSCompatibilityCheck() { ResolveClusterActionRequest request = new ResolveClusterActionRequest(new String[] { "test" }) { @Override public void writeTo(StreamOutput out) throws IOException { - throw new UnsupportedOperationException( - "ResolveClusterAction requires at least version " - + TransportVersions.V_8_13_0.toReleaseVersion() - + " but was " - + out.getTransportVersion().toReleaseVersion() - ); + String versionErrorMessage = ResolveClusterActionRequest.createVersionErrorMessage(out.getTransportVersion()); + throw new UnsupportedOperationException(versionErrorMessage); } }; ClusterService clusterService = new ClusterService( diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1ResolveClusterIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1ResolveClusterIT.java index f22f642bb9151..dabda4a216dc7 100644 --- a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1ResolveClusterIT.java +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1ResolveClusterIT.java @@ -12,6 +12,7 @@ import org.elasticsearch.client.Response; import org.elasticsearch.client.ResponseException; import org.elasticsearch.core.Strings; +import org.elasticsearch.core.Tuple; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.junit.ClassRule; import org.junit.rules.RuleChain; @@ -98,6 +99,15 @@ public void testResolveClusterUnderRCS1() throws Exception { assertThat((Boolean) remoteClusterResponse.get("connected"), equalTo(false)); assertThat((String) remoteClusterResponse.get("error"), containsString("unauthorized for user [remote_search_user]")); + // TEST CASE 1-b: Query with no index expression but still with no access to remote cluster + Response response2 = performRequestWithRemoteSearchUser(new Request("GET", "_resolve/cluster")); + assertOK(response2); + + Map responseMap2 = responseAsMap(response2); + Map remoteClusterResponse2 = (Map) responseMap2.get("my_remote_cluster"); + assertThat((Boolean) remoteClusterResponse2.get("connected"), equalTo(false)); + assertThat((String) remoteClusterResponse.get("error"), containsString("unauthorized for user [remote_search_user]")); + // TEST CASE 2: Query cluster -> add user role and user on remote cluster and try resolve again var putRoleOnRemoteClusterRequest = new Request("PUT", "/_security/role/" + REMOTE_SEARCH_ROLE); putRoleOnRemoteClusterRequest.setJsonEntity(""" @@ -157,7 +167,7 @@ public void testResolveClusterUnderRCS1() throws Exception { assertRemoteMatching(responseMap); } { - // TEST CASE 6: Query cluster -> resolve remote only for existing and privileged index + // TEST CASE 6a: Query cluster -> resolve remote only for existing and privileged index Request remoteOnly1 = new Request("GET", "_resolve/cluster/my_remote_cluster:index1"); Response response = performRequestWithRemoteSearchUser(remoteOnly1); assertOK(response); @@ -165,6 +175,18 @@ public void testResolveClusterUnderRCS1() throws Exception { assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); assertRemoteMatching(responseMap); } + { + // TEST CASE 6b: Resolution against a wildcarded index that does not exist (but no explicit permissions for "dummy") + final Request remoteOnly1 = new Request("GET", "_resolve/cluster/my_remote_cluster:dummy*"); + Response response = performRequestWithRemoteSearchUser(remoteOnly1); + assertOK(response); + Map responseMap = responseAsMap(response); + assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + Map remoteMap = (Map) responseMap.get("my_remote_cluster"); + assertThat((Boolean) remoteMap.get("connected"), equalTo(true)); + assertThat((Boolean) remoteMap.get("matching_indices"), equalTo(false)); + assertThat(remoteMap.get("version"), notNullValue()); + } { // TEST CASE 7: Query cluster -> resolve remote only for existing but non-privileged index Request remoteOnly2 = new Request("GET", "_resolve/cluster/my_remote_cluster:secretindex"); @@ -177,6 +199,18 @@ public void testResolveClusterUnderRCS1() throws Exception { assertThat((String) remoteClusterResponse.get("error"), containsString("unauthorized for user [remote_search_user]")); assertThat((String) remoteClusterResponse.get("error"), containsString("on indices [secretindex]")); } + { + // TEST CASE 7b: same as above except put a wildcard on secretindex*, which causes the error message to go away + final Request remoteOnly1 = new Request("GET", "_resolve/cluster/my_remote_cluster:secretindex*"); + Response response = performRequestWithRemoteSearchUser(remoteOnly1); + Map responseMap = responseAsMap(response); + assertOK(response); + assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + Map remoteMap = (Map) responseMap.get("my_remote_cluster"); + assertThat((Boolean) remoteMap.get("connected"), equalTo(true)); + assertThat((Boolean) remoteMap.get("matching_indices"), equalTo(false)); + assertThat(remoteMap.get("version"), notNullValue()); + } { // TEST CASE 8: Query cluster -> resolve remote only for non-existing and non-privileged index Request remoteOnly3 = new Request("GET", "_resolve/cluster/my_remote_cluster:doesnotexist"); @@ -217,6 +251,51 @@ public void testResolveClusterUnderRCS1() throws Exception { assertThat((String) remoteClusterResponse.get("error"), containsString("unauthorized for user [remote_search_user]")); assertThat((String) remoteClusterResponse.get("error"), containsString("on indices [secretindex]")); } + { + // TEST CASE 11: Query resolve/cluster with no index expression + Response response = performRequestWithRemoteSearchUser(new Request("GET", "_resolve/cluster")); + assertOK(response); + Map responseMap = responseAsMap(response); + assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + Map remoteClusterResponse = (Map) responseMap.get("my_remote_cluster"); + assertThat((Boolean) remoteClusterResponse.get("connected"), equalTo(true)); + assertNull(remoteClusterResponse.get("error")); + assertNotNull(remoteClusterResponse.get("version")); + } + { + // TEST CASE 12: Query resolve/cluster with no index expression, but include index options - should return error + Request getRequest = new Request("GET", "_resolve/cluster"); + Tuple indexOptionTuple = randomFrom( + new Tuple<>("ignore_throttled", "false"), + new Tuple<>("expand_wildcards", "none"), + new Tuple<>("allow_no_indices", "true"), + new Tuple<>("ignore_unavailable", "true") + ); + getRequest.addParameter(indexOptionTuple.v1(), indexOptionTuple.v2()); + + ResponseException exc = expectThrows(ResponseException.class, () -> performRequestWithRemoteSearchUser(getRequest)); + assertThat(exc.getResponse().getStatusLine().getStatusCode(), is(400)); + assertThat( + exc.getMessage(), + containsString("No index options are allowed on _resolve/cluster when no index expression is specified") + ); + assertThat(exc.getMessage(), containsString(indexOptionTuple.v1())); + } + // TODO: The security pathways are not using the new + // RemoteClusterService.groupIndices(IndicesOptions indicesOptions, String[] indices, boolean returnLocalAll) method + // so this use case still behaves badly - fix in follow on PR + // { + // // TEST CASE 13: Resolution against wildcarded remote cluster expression that matches no remotes + // final Request remoteOnly1 = new Request("GET", "_resolve/cluster/no_such_remote*:*"); + // Response response = performRequestWithRemoteSearchUser(remoteOnly1); + // assertOK(response); + // Map responseMap = responseAsMap(response); + // assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + // Map remoteMap = (Map) responseMap.get("my_remote_cluster"); + // assertThat((Boolean) remoteMap.get("connected"), equalTo(true)); + // assertThat((Boolean) remoteMap.get("matching_indices"), equalTo(false)); + // assertThat(remoteMap.get("version"), notNullValue()); + // } } private Response performRequestWithRemoteSearchUser(final Request request) throws IOException { diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2ResolveClusterIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2ResolveClusterIT.java index a1f897b5b0503..fab49c9c2932d 100644 --- a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2ResolveClusterIT.java +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2ResolveClusterIT.java @@ -13,6 +13,7 @@ import org.elasticsearch.client.ResponseException; import org.elasticsearch.common.UUIDs; import org.elasticsearch.core.Strings; +import org.elasticsearch.core.Tuple; import org.elasticsearch.test.cluster.ElasticsearchCluster; import org.elasticsearch.test.cluster.util.resource.Resource; import org.elasticsearch.test.junit.RunnableTestRuleAdapter; @@ -189,6 +190,19 @@ public void testResolveCluster() throws Exception { containsString("no remote indices privileges apply for the target cluster") ); + // TEST CASE 1-b: Query with no index expression but still with no access to remote cluster + Response response2 = performRequestWithRemoteSearchUser(new Request("GET", "_resolve/cluster")); + assertOK(response2); + + Map responseMap2 = responseAsMap(response2); + Map remoteClusterResponse2 = (Map) responseMap2.get("my_remote_cluster"); + assertThat((Boolean) remoteClusterResponse2.get("connected"), equalTo(false)); + assertThat((String) remoteClusterResponse.get("error"), containsString("is unauthorized for user")); + assertThat( + (String) remoteClusterResponse.get("error"), + containsString("no remote indices privileges apply for the target cluster") + ); + // TEST CASE 2: Query cluster -> add remote privs to the user role and try resolve again var updateRoleRequest = new Request("PUT", "/_security/role/" + REMOTE_SEARCH_ROLE); updateRoleRequest.setJsonEntity(""" @@ -247,7 +261,7 @@ public void testResolveCluster() throws Exception { assertRemoteMatching(responseMap); } { - // TEST CASE 6: Query cluster -> resolve remote only for existing and privileged index + // TEST CASE 6a: Query cluster -> resolve remote only for existing and privileged index final Request remoteOnly1 = new Request("GET", "_resolve/cluster/my_remote_cluster:index1"); Response response = performRequestWithRemoteSearchUser(remoteOnly1); assertOK(response); @@ -255,6 +269,18 @@ public void testResolveCluster() throws Exception { assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); assertRemoteMatching(responseMap); } + { + // TEST CASE 6b: Resolution against a wildcarded index that does not exist (but no explicit permissions for "dummy") + final Request remoteOnly1 = new Request("GET", "_resolve/cluster/my_remote_cluster:dummy*"); + Response response = performRequestWithRemoteSearchUser(remoteOnly1); + Map responseMap = responseAsMap(response); + assertOK(response); + assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + Map remoteMap = (Map) responseMap.get("my_remote_cluster"); + assertThat((Boolean) remoteMap.get("connected"), equalTo(true)); + assertThat((Boolean) remoteMap.get("matching_indices"), equalTo(false)); + assertThat(remoteMap.get("version"), notNullValue()); + } { // TEST CASE 7: Query cluster -> resolve remote only for existing but non-privileged index final Request remoteOnly2 = new Request("GET", "_resolve/cluster/my_remote_cluster:secretindex"); @@ -267,6 +293,18 @@ public void testResolveCluster() throws Exception { assertThat((String) remoteClusterResponse.get("error"), containsString("is unauthorized for user")); assertThat((String) remoteClusterResponse.get("error"), containsString("on indices [secretindex]")); } + { + // TEST CASE 7b: same as above except put a wildcard on secretindex*, which causes the error message to go away + final Request remoteOnly1 = new Request("GET", "_resolve/cluster/my_remote_cluster:secretindex*"); + Response response = performRequestWithRemoteSearchUser(remoteOnly1); + Map responseMap = responseAsMap(response); + assertOK(response); + assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + Map remoteMap = (Map) responseMap.get("my_remote_cluster"); + assertThat((Boolean) remoteMap.get("connected"), equalTo(true)); + assertThat((Boolean) remoteMap.get("matching_indices"), equalTo(false)); + assertThat(remoteMap.get("version"), notNullValue()); + } { // TEST CASE 8: Query cluster -> resolve remote only for non-existing and non-privileged index final Request remoteOnly3 = new Request("GET", "_resolve/cluster/my_remote_cluster:doesnotexist"); @@ -308,6 +346,49 @@ public void testResolveCluster() throws Exception { assertThat((String) remoteClusterResponse.get("error"), containsString("is unauthorized for user")); assertThat((String) remoteClusterResponse.get("error"), containsString("on indices [secretindex]")); } + { + // TEST CASE 11: Query resolve/cluster with no index expression + Response response = performRequestWithRemoteSearchUser(new Request("GET", "_resolve/cluster")); + assertOK(response); + Map responseMap = responseAsMap(response); + assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + Map remoteClusterResponse = (Map) responseMap.get("my_remote_cluster"); + assertThat((Boolean) remoteClusterResponse.get("connected"), equalTo(true)); + assertNull(remoteClusterResponse.get("error")); + assertNotNull(remoteClusterResponse.get("version")); + } + { + // TEST CASE 12: Query resolve/cluster with no index expression, but include index options - should return error + Request getRequest = new Request("GET", "_resolve/cluster"); + Tuple indexOptionTuple = randomFrom( + new Tuple<>("ignore_throttled", "false"), + new Tuple<>("expand_wildcards", "none"), + new Tuple<>("allow_no_indices", "true"), + new Tuple<>("ignore_unavailable", "true") + ); + getRequest.addParameter(indexOptionTuple.v1(), indexOptionTuple.v2()); + + ResponseException exc = expectThrows(ResponseException.class, () -> performRequestWithRemoteSearchUser(getRequest)); + assertThat(exc.getResponse().getStatusLine().getStatusCode(), is(400)); + assertThat( + exc.getMessage(), + containsString("No index options are allowed on _resolve/cluster when no index expression is specified") + ); + assertThat(exc.getMessage(), containsString(indexOptionTuple.v1())); + } + // TODO: fix this in a follow-on PR + // { + // // TEST CASE 13: Resolution against wildcarded remote cluster expression that matches no remotes + // final Request remoteOnly1 = new Request("GET", "_resolve/cluster/no_such_remote*:*"); + // Response response = performRequestWithRemoteSearchUser(remoteOnly1); + // assertOK(response); + // Map responseMap = responseAsMap(response); + // assertThat(responseMap.get(LOCAL_CLUSTER_NAME_REPRESENTATION), nullValue()); + // Map remoteMap = (Map) responseMap.get("my_remote_cluster"); + // assertThat((Boolean) remoteMap.get("connected"), equalTo(true)); + // assertThat((Boolean) remoteMap.get("matching_indices"), equalTo(false)); + // assertThat(remoteMap.get("version"), notNullValue()); + // } } private Response performRequestWithRemoteSearchUser(final Request request) throws IOException {