Skip to content

Commit c56b4dc

Browse files
authored
Move ProjectRoutingInfo and related classes (#135586)
This PR moves `ProjectRoutingInfo` and related classes into core to be used by the IndexAbstractionResolver (see bigger [draft PR](https://github.com/elastic/elasticsearch/pull/135346/files#diff-3e278f8a5f49993b4e491a25ddeaf382289d122a69492902cf61ede33589e9a6R53)). This is a copy & paste refactor without functional changes.
1 parent c3d20fc commit c56b4dc

File tree

10 files changed

+738
-1
lines changed

10 files changed

+738
-1
lines changed

server/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,4 +491,5 @@
491491
exports org.elasticsearch.inference.telemetry;
492492
exports org.elasticsearch.index.codec.vectors.diskbbq to org.elasticsearch.test.knn;
493493
exports org.elasticsearch.index.codec.vectors.cluster to org.elasticsearch.test.knn;
494+
exports org.elasticsearch.search.crossproject;
494495
}

server/src/main/java/org/elasticsearch/ElasticsearchException.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.elasticsearch.search.aggregations.AggregationExecutionException;
4646
import org.elasticsearch.search.aggregations.MultiBucketConsumerService;
4747
import org.elasticsearch.search.aggregations.UnsupportedAggregationOnDownsampledIndex;
48+
import org.elasticsearch.search.crossproject.NoMatchingProjectException;
4849
import org.elasticsearch.search.query.SearchTimeoutException;
4950
import org.elasticsearch.transport.TcpTransport;
5051
import org.elasticsearch.xcontent.ParseField;
@@ -79,6 +80,7 @@
7980
import static org.elasticsearch.cluster.metadata.IndexMetadata.INDEX_UUID_NA_VALUE;
8081
import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
8182
import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName;
83+
import static org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION;
8284

8385
/**
8486
* A base class for all elasticsearch exceptions.
@@ -2022,6 +2024,12 @@ private enum ElasticsearchExceptionHandle {
20222024
184,
20232025
TransportVersions.REMOTE_EXCEPTION,
20242026
TransportVersions.REMOTE_EXCEPTION_8_19
2027+
),
2028+
NO_MATCHING_PROJECT_EXCEPTION(
2029+
NoMatchingProjectException.class,
2030+
NoMatchingProjectException::new,
2031+
185,
2032+
NO_MATCHING_PROJECT_EXCEPTION_VERSION
20252033
);
20262034

20272035
final Class<? extends ElasticsearchException> exceptionClass;
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.search.crossproject;
11+
12+
import org.elasticsearch.TransportVersion;
13+
import org.elasticsearch.cluster.metadata.ClusterNameExpressionResolver;
14+
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
15+
import org.elasticsearch.core.Nullable;
16+
import org.elasticsearch.logging.LogManager;
17+
import org.elasticsearch.logging.Logger;
18+
import org.elasticsearch.transport.NoSuchRemoteClusterException;
19+
import org.elasticsearch.transport.RemoteClusterAware;
20+
21+
import java.util.ArrayList;
22+
import java.util.LinkedHashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Set;
26+
import java.util.stream.Collectors;
27+
28+
/**
29+
* Utility class for rewriting cross-project index expressions.
30+
* Provides methods that can rewrite qualified and unqualified index expressions to canonical CCS.
31+
*/
32+
public class CrossProjectIndexExpressionsRewriter {
33+
public static TransportVersion NO_MATCHING_PROJECT_EXCEPTION_VERSION = TransportVersion.fromName("no_matching_project_exception");
34+
35+
private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class);
36+
private static final String ORIGIN_PROJECT_KEY = "_origin";
37+
private static final String WILDCARD = "*";
38+
private static final String[] MATCH_ALL = new String[] { WILDCARD };
39+
private static final String EXCLUSION = "-";
40+
private static final String DATE_MATH = "<";
41+
42+
/**
43+
* Rewrites index expressions for cross-project search requests.
44+
* Handles qualified and unqualified expressions and match-all cases will also hand exclusions in the future.
45+
*
46+
* @param originProject the _origin project with its alias
47+
* @param linkedProjects the list of linked and available projects to consider for a request
48+
* @param originalIndices the array of index expressions to be rewritten to canonical CCS
49+
* @return a map from original index expressions to lists of canonical index expressions
50+
* @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions
51+
* @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing
52+
*/
53+
public static Map<String, List<String>> rewriteIndexExpressions(
54+
ProjectRoutingInfo originProject,
55+
List<ProjectRoutingInfo> linkedProjects,
56+
final String[] originalIndices
57+
) {
58+
final String[] indices;
59+
if (originalIndices == null || originalIndices.length == 0) { // handling of match all cases besides _all and `*`
60+
indices = MATCH_ALL;
61+
} else {
62+
indices = originalIndices;
63+
}
64+
assert false == IndexNameExpressionResolver.isNoneExpression(indices)
65+
: "expression list is *,-* which effectively means a request that requests no indices";
66+
assert originProject != null || linkedProjects.isEmpty() == false
67+
: "either origin project or linked projects must be in project target set";
68+
69+
Set<String> linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet());
70+
Map<String, List<String>> canonicalExpressionsMap = new LinkedHashMap<>(indices.length);
71+
for (String resource : indices) {
72+
if (canonicalExpressionsMap.containsKey(resource)) {
73+
continue;
74+
}
75+
maybeThrowOnUnsupportedResource(resource);
76+
77+
boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource);
78+
if (isQualified) {
79+
// handing of qualified expressions
80+
String[] splitResource = RemoteClusterAware.splitIndexName(resource);
81+
assert splitResource.length == 2
82+
: "Expected two strings (project and indexExpression) for a qualified resource ["
83+
+ resource
84+
+ "], but found ["
85+
+ splitResource.length
86+
+ "]";
87+
String projectAlias = splitResource[0];
88+
assert projectAlias != null : "Expected a project alias for a qualified resource but was null";
89+
String indexExpression = splitResource[1];
90+
maybeThrowOnUnsupportedResource(indexExpression);
91+
92+
List<String> canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames);
93+
94+
canonicalExpressionsMap.put(resource, canonicalExpressions);
95+
logger.debug("Rewrote qualified expression [{}] to [{}]", resource, canonicalExpressions);
96+
} else {
97+
// un-qualified expression, i.e. flat-world
98+
List<String> canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects);
99+
canonicalExpressionsMap.put(resource, canonicalExpressions);
100+
logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions);
101+
}
102+
}
103+
return canonicalExpressionsMap;
104+
}
105+
106+
private static List<String> rewriteUnqualified(
107+
String indexExpression,
108+
@Nullable ProjectRoutingInfo origin,
109+
List<ProjectRoutingInfo> projects
110+
) {
111+
List<String> canonicalExpressions = new ArrayList<>();
112+
if (origin != null) {
113+
canonicalExpressions.add(indexExpression); // adding the original indexExpression for the _origin cluster.
114+
}
115+
for (ProjectRoutingInfo targetProject : projects) {
116+
canonicalExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProject.projectAlias(), indexExpression));
117+
}
118+
return canonicalExpressions;
119+
}
120+
121+
private static List<String> rewriteQualified(
122+
String requestedProjectAlias,
123+
String indexExpression,
124+
@Nullable ProjectRoutingInfo originProject,
125+
Set<String> allProjectAliases
126+
) {
127+
if (originProject != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) {
128+
// handling case where we have a qualified expression like: _origin:indexName
129+
return List.of(indexExpression);
130+
}
131+
132+
if (originProject == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) {
133+
// handling case where we have a qualified expression like: _origin:indexName but no _origin project is set
134+
throw new NoMatchingProjectException(requestedProjectAlias);
135+
}
136+
137+
try {
138+
if (originProject != null) {
139+
allProjectAliases.add(originProject.projectAlias());
140+
}
141+
List<String> resourcesMatchingAliases = new ArrayList<>();
142+
List<String> allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames(
143+
allProjectAliases,
144+
requestedProjectAlias
145+
);
146+
147+
if (allProjectsMatchingAlias.isEmpty()) {
148+
throw new NoMatchingProjectException(requestedProjectAlias);
149+
}
150+
151+
for (String project : allProjectsMatchingAlias) {
152+
if (originProject != null && project.equals(originProject.projectAlias())) {
153+
resourcesMatchingAliases.add(indexExpression);
154+
} else {
155+
resourcesMatchingAliases.add(RemoteClusterAware.buildRemoteIndexName(project, indexExpression));
156+
}
157+
}
158+
159+
return resourcesMatchingAliases;
160+
} catch (NoSuchRemoteClusterException ex) {
161+
logger.debug(ex.getMessage(), ex);
162+
throw new NoMatchingProjectException(requestedProjectAlias);
163+
}
164+
}
165+
166+
private static void maybeThrowOnUnsupportedResource(String resource) {
167+
// TODO To be handled in future PR.
168+
if (resource.startsWith(EXCLUSION)) {
169+
throw new IllegalArgumentException("Exclusions are not currently supported but was found in the expression [" + resource + "]");
170+
}
171+
if (resource.startsWith(DATE_MATH)) {
172+
throw new IllegalArgumentException("Date math are not currently supported but was found in the expression [" + resource + "]");
173+
}
174+
if (IndexNameExpressionResolver.hasSelectorSuffix(resource)) {
175+
throw new IllegalArgumentException("Selectors are not currently supported but was found in the expression [" + resource + "]");
176+
177+
}
178+
}
179+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.search.crossproject;
11+
12+
import org.elasticsearch.ResourceNotFoundException;
13+
import org.elasticsearch.common.io.stream.StreamInput;
14+
15+
import java.io.IOException;
16+
17+
/**
18+
* An exception that a project is missing
19+
*/
20+
public final class NoMatchingProjectException extends ResourceNotFoundException {
21+
22+
public NoMatchingProjectException(String projectName) {
23+
super("No such project: [" + projectName + "]");
24+
}
25+
26+
public NoMatchingProjectException(StreamInput in) throws IOException {
27+
super(in);
28+
}
29+
30+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.search.crossproject;
11+
12+
import org.elasticsearch.cluster.metadata.ProjectId;
13+
14+
/**
15+
* Information about a project used for routing in cross-project search.
16+
*/
17+
public record ProjectRoutingInfo(
18+
ProjectId projectId,
19+
String projectType,
20+
String projectAlias,
21+
String organizationId,
22+
ProjectTags projectTags
23+
) {
24+
public ProjectRoutingInfo(ProjectId projectId, ProjectTags projectTags) {
25+
this(projectId, projectTags.projectType(), projectTags.projectAlias(), projectTags.organizationId(), projectTags);
26+
}
27+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.search.crossproject;
11+
12+
import java.util.Map;
13+
14+
/**
15+
* Project tags used for cross-project search routing.
16+
* @param tags the map of tags -- contains both built-in (Elastic-supplied) and custom user-defined tags.
17+
* All built-in tags are prefixed with an underscore (_).
18+
*/
19+
public record ProjectTags(Map<String, String> tags) {
20+
public static final String PROJECT_ID_TAG = "_id";
21+
public static final String PROJECT_ALIAS = "_alias";
22+
public static final String PROJECT_TYPE_TAG = "_type";
23+
public static final String ORGANIZATION_ID_TAG = "_organization";
24+
25+
public String projectId() {
26+
return tags.get(PROJECT_ID_TAG);
27+
}
28+
29+
public String organizationId() {
30+
return tags.get(ORGANIZATION_ID_TAG);
31+
}
32+
33+
public String projectType() {
34+
return tags.get(PROJECT_TYPE_TAG);
35+
}
36+
37+
public String projectAlias() {
38+
return tags.get(PROJECT_ALIAS);
39+
}
40+
41+
/**
42+
* Validate that all required tags are present.
43+
*/
44+
public static void validateTags(String projectId, Map<String, String> tags) {
45+
if (false == tags.containsKey(PROJECT_ID_TAG)) {
46+
throw missingTagException(projectId, PROJECT_ID_TAG);
47+
}
48+
if (false == tags.containsKey(PROJECT_TYPE_TAG)) {
49+
throw missingTagException(projectId, PROJECT_TYPE_TAG);
50+
}
51+
if (false == tags.containsKey(ORGANIZATION_ID_TAG)) {
52+
throw missingTagException(projectId, ORGANIZATION_ID_TAG);
53+
}
54+
if (false == tags.containsKey(PROJECT_ALIAS)) {
55+
throw missingTagException(projectId, PROJECT_ALIAS);
56+
}
57+
}
58+
59+
private static IllegalStateException missingTagException(String projectId, String tagKey) {
60+
return new IllegalStateException("Project configuration for [" + projectId + "] is missing required tag [" + tagKey + "]");
61+
}
62+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9178000
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
extended_search_usage_telemetry,9177000
1+
no_matching_project_exception,9178000

server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
import org.elasticsearch.search.aggregations.AggregationExecutionException;
8787
import org.elasticsearch.search.aggregations.MultiBucketConsumerService;
8888
import org.elasticsearch.search.aggregations.UnsupportedAggregationOnDownsampledIndex;
89+
import org.elasticsearch.search.crossproject.NoMatchingProjectException;
8990
import org.elasticsearch.search.internal.ShardSearchContextId;
9091
import org.elasticsearch.search.query.SearchTimeoutException;
9192
import org.elasticsearch.snapshots.Snapshot;
@@ -846,6 +847,7 @@ public void testIds() {
846847
ids.put(182, IngestPipelineException.class);
847848
ids.put(183, IndexDocFailureStoreStatus.ExceptionWithFailureStoreStatus.class);
848849
ids.put(184, RemoteException.class);
850+
ids.put(185, NoMatchingProjectException.class);
849851

850852
Map<Class<? extends ElasticsearchException>, Integer> reverse = new HashMap<>();
851853
for (Map.Entry<Integer, Class<? extends ElasticsearchException>> entry : ids.entrySet()) {

0 commit comments

Comments
 (0)