Skip to content

Commit b6d9132

Browse files
authored
Wire project routing resolver for index resolution (#137170)
This PR wires the current project routing resolver implementation into index resolution process. The behaviour expectation is that a qualified index expresssion, such as `P1:foo` should behave the same way as an unqualified expression plus project routing, i.e. `foo` and `_alias:P1`.
1 parent 82df082 commit b6d9132

File tree

11 files changed

+276
-92
lines changed

11 files changed

+276
-92
lines changed

server/src/main/java/org/elasticsearch/action/IndicesRequest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ default boolean allowsRemoteIndices() {
8989
default boolean allowsCrossProject() {
9090
return false;
9191
}
92+
93+
@Nullable // if no routing is specified
94+
default String getProjectRouting() {
95+
return null;
96+
}
9297
}
9398

9499
/**

server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public static class Request extends LegacyActionRequest implements IndicesReques
9898
private IndicesOptions indicesOptions = DEFAULT_INDICES_OPTIONS;
9999
private EnumSet<IndexMode> indexModes = EnumSet.noneOf(IndexMode.class);
100100
private ResolvedIndexExpressions resolvedIndexExpressions = null;
101+
private String projectRouting;
101102

102103
public Request(String[] names) {
103104
this.names = names;
@@ -109,11 +110,21 @@ public Request(String[] names, IndicesOptions indicesOptions) {
109110
}
110111

111112
public Request(String[] names, IndicesOptions indicesOptions, @Nullable EnumSet<IndexMode> indexModes) {
113+
this(names, indicesOptions, indexModes, null);
114+
}
115+
116+
public Request(
117+
String[] names,
118+
IndicesOptions indicesOptions,
119+
@Nullable EnumSet<IndexMode> indexModes,
120+
@Nullable String projectRouting
121+
) {
112122
this.names = names;
113123
this.indicesOptions = indicesOptions;
114124
if (indexModes != null) {
115125
this.indexModes = indexModes;
116126
}
127+
this.projectRouting = projectRouting;
117128
}
118129

119130
@Override
@@ -196,6 +207,11 @@ public boolean includeDataStreams() {
196207
// request must allow data streams because the index name expression resolver for the action handler assumes it
197208
return true;
198209
}
210+
211+
@Override
212+
public String getProjectRouting() {
213+
return projectRouting;
214+
}
199215
}
200216

201217
public static class ResolvedIndexAbstraction {
@@ -631,6 +647,7 @@ protected void doExecute(Task task, Request request, final ActionListener<Respon
631647
}
632648
final Exception ex = CrossProjectIndexResolutionValidator.validate(
633649
originalIndicesOptions,
650+
request.getProjectRouting(),
634651
localResolvedIndexExpressions,
635652
getResolvedExpressionsByRemote(remoteResponses)
636653
);
@@ -668,6 +685,7 @@ protected void doExecute(Task task, Request request, final ActionListener<Respon
668685
// `<alias-pattern-matching-origin-only>:index` also get deferred validation
669686
final Exception ex = CrossProjectIndexResolutionValidator.validate(
670687
originalIndicesOptions,
688+
request.getProjectRouting(),
671689
localResolvedIndexExpressions,
672690
Map.of()
673691
);

server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,8 @@ public ResolvedIndexExpressions resolveIndexAbstractions(
7676
final TargetProjects targetProjects,
7777
final boolean includeDataStreams
7878
) {
79-
assert targetProjects != TargetProjects.LOCAL_ONLY_FOR_CPS_DISABLED
80-
: "cannot resolve indices cross project if target set is local only";
81-
if (false == targetProjects.crossProject()) {
82-
final String message = "cannot resolve indices cross project if target set is not cross project";
79+
if (targetProjects == TargetProjects.LOCAL_ONLY_FOR_CPS_DISABLED) {
80+
final String message = "cannot resolve indices cross project if target set is local only";
8381
assert false : message;
8482
throw new IllegalArgumentException(message);
8583
}

server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request
5858
String[] indices = Strings.splitStringByCommaToArray(request.param("name"));
5959
String modeParam = request.param("mode");
6060
final boolean crossProjectEnabled = settings != null && settings.getAsBoolean("serverless.cross_project.enabled", false);
61+
String projectRouting = null;
6162
if (crossProjectEnabled) {
62-
// accept but drop project_routing param until fully supported
63-
request.param("project_routing");
63+
projectRouting = request.param("project_routing");
6464
}
6565
IndicesOptions indicesOptions = IndicesOptions.fromRequest(request, ResolveIndexAction.Request.DEFAULT_INDICES_OPTIONS);
6666
if (crossProjectEnabled) {
@@ -75,7 +75,8 @@ protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request
7575
? null
7676
: Arrays.stream(modeParam.split(","))
7777
.map(IndexMode::fromString)
78-
.collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll)
78+
.collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll),
79+
projectRouting
7980
);
8081
return channel -> client.admin().indices().resolveIndex(resolveRequest, new RestToXContentListener<>(channel));
8182
}

server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,15 @@ public static IndexRewriteResult rewriteIndexExpression(
9494
@Nullable String originProjectAlias,
9595
Set<String> allProjectAliases
9696
) {
97-
assert originProjectAlias != null || allProjectAliases.isEmpty() == false
98-
: "either origin project or linked projects must be in project target set";
99-
10097
maybeThrowOnUnsupportedResource(indexExpression);
10198

99+
// Always 404 when no project is available for index resolution. This is matching error handling behaviour for resolving
100+
// projects with qualified index patterns such as "missing-*:index".
101+
if (originProjectAlias == null && allProjectAliases.isEmpty()) {
102+
// TODO: add project_routing string to the exception message
103+
throw new NoMatchingProjectException("no matching project after applying project routing");
104+
}
105+
102106
final boolean isQualified = RemoteClusterAware.isRemoteIndexName(indexExpression);
103107
final IndexRewriteResult rewrittenExpression;
104108
if (isQualified) {

server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import org.elasticsearch.action.ResolvedIndexExpression;
1717
import org.elasticsearch.action.ResolvedIndexExpressions;
1818
import org.elasticsearch.action.support.IndicesOptions;
19+
import org.elasticsearch.common.Strings;
20+
import org.elasticsearch.core.Nullable;
1921
import org.elasticsearch.index.IndexNotFoundException;
2022
import org.elasticsearch.rest.RestStatus;
2123
import org.elasticsearch.transport.RemoteClusterAware;
@@ -66,12 +68,14 @@ public class CrossProjectIndexResolutionValidator {
6668
* local and linked project resolution results when determining the appropriate error response.
6769
*
6870
* @param indicesOptions Controls error behavior for missing indices
71+
* @param projectRouting The project routing string from the request, can be null if request does not specify it
6972
* @param localResolvedExpressions Resolution results from the origin project
7073
* @param remoteResolvedExpressions Resolution results from linked projects
7174
* @return a {@link ElasticsearchException} if validation fails, null if validation passes
7275
*/
7376
public static ElasticsearchException validate(
7477
IndicesOptions indicesOptions,
78+
@Nullable String projectRouting,
7579
ResolvedIndexExpressions localResolvedExpressions,
7680
Map<String, ResolvedIndexExpressions> remoteResolvedExpressions
7781
) {
@@ -80,19 +84,21 @@ public static ElasticsearchException validate(
8084
return null;
8185
}
8286

87+
final boolean hasProjectRouting = Strings.isEmpty(projectRouting) == false;
8388
logger.debug(
84-
"Checking index existence for [{}] and [{}] with indices options [{}]",
89+
"Checking index existence for [{}] and [{}] with indices options [{}]{}",
8590
localResolvedExpressions,
8691
remoteResolvedExpressions,
87-
indicesOptions
92+
indicesOptions,
93+
hasProjectRouting ? " and project routing [" + projectRouting + "]" : ""
8894
);
8995

9096
for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) {
9197
String originalExpression = localResolvedIndices.original();
9298
logger.debug("Checking replaced expression for original expression [{}]", originalExpression);
9399

94-
// Check if this is a qualified resource (project:index pattern)
95-
boolean isQualifiedExpression = RemoteClusterAware.isRemoteIndexName(originalExpression);
100+
// Check if this is a qualified resource (project:index pattern) or has project routing
101+
boolean isQualifiedExpression = hasProjectRouting || RemoteClusterAware.isRemoteIndexName(originalExpression);
96102

97103
Set<String> remoteExpressions = localResolvedIndices.remoteExpressions();
98104
ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions();

server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolver.java

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import java.util.Set;
1616
import java.util.function.Predicate;
1717
import java.util.stream.IntStream;
18-
import java.util.stream.Stream;
1918

2019
import static org.elasticsearch.rest.RestStatus.BAD_REQUEST;
2120

@@ -24,7 +23,7 @@
2423
* Resolves a single entry _alias for a cross-project request specifying a project_routing.
2524
* We currently only support a single entry routing containing either a specific name, a prefix, a suffix, or a match-all (*).
2625
*/
27-
public class CrossProjectRoutingResolver {
26+
public class CrossProjectRoutingResolver implements ProjectRoutingResolver {
2827
private static final String ALIAS = "_alias:";
2928
private static final String ORIGIN = "_origin";
3029
private static final int ALIAS_LENGTH = ALIAS.length();
@@ -60,39 +59,50 @@ public class CrossProjectRoutingResolver {
6059
);
6160

6261
/**
63-
* @param projectRouting the project_routing specified in the request object.
64-
* @param originProject the project alias where this function is being called.
65-
* @param candidateProjects the list of project aliases for the active request. This list must *NOT* contain the originProject entry.
66-
* @return the filtered list of projects matching the projectRouting, or an empty list if none are found.
67-
* @throws ElasticsearchStatusException if the projectRouting is null, empty, does not start with "_alias:", contains more than one
68-
* entry, or contains an '*' in the middle of a string.
62+
* Filters the specified TargetProjects based on the provided project routing string
63+
* @param projectRouting the project_routing specified in the request object
64+
* @param targetProjects The target projects to be filtered
65+
* @return A new TargetProjects instance containing only the projects that match the project routing.
66+
* @throws ElasticsearchStatusException if the projectRouting is null, empty, does not start with "_alias:", contains more than one
67+
* entry, or contains an '*' in the middle of a string.
6968
*/
70-
public List<ProjectRoutingInfo> resolve(
71-
String projectRouting,
72-
ProjectRoutingInfo originProject,
73-
List<ProjectRoutingInfo> candidateProjects
74-
) {
69+
@Override
70+
public TargetProjects resolve(String projectRouting, TargetProjects targetProjects) {
71+
assert targetProjects != TargetProjects.LOCAL_ONLY_FOR_CPS_DISABLED;
72+
if (targetProjects.isEmpty()) {
73+
return TargetProjects.EMPTY;
74+
}
75+
76+
if (projectRouting == null || projectRouting.isEmpty() || ALIAS_MATCH_ALL.equalsIgnoreCase(projectRouting)) {
77+
return targetProjects;
78+
}
79+
80+
final var originProject = targetProjects.originProject();
81+
assert originProject != null : "origin project must not be null";
82+
// TODO: some of the assertions such as alias and non-overlapping could be enforced in TargetProjects constructor
7583
assert originProject.projectAlias().equalsIgnoreCase(ORIGIN) == false : "origin project alias must not be " + ORIGIN;
7684

85+
if (ALIAS_MATCH_ORIGIN.equalsIgnoreCase(projectRouting)) {
86+
return new TargetProjects(originProject, List.of());
87+
}
88+
89+
final var candidateProjects = targetProjects.linkedProjects();
90+
assert candidateProjects != null : "candidate projects must not be null";
91+
7792
var candidateProjectStream = candidateProjects.stream().peek(candidateProject -> {
7893
assert candidateProject.projectAlias().equalsIgnoreCase(ORIGIN) == false : "project alias must not be " + ORIGIN;
7994
}).filter(candidateProject -> {
8095
assert candidateProject.equals(originProject) == false : "origin project must not be in the candidateProjects list";
8196
return candidateProject.equals(originProject) == false; // assertions are disabled in prod, instead we should filter this out
8297
});
8398

84-
if (ALIAS_MATCH_ORIGIN.equalsIgnoreCase(projectRouting)) {
85-
return List.of(originProject);
86-
}
87-
88-
if (projectRouting == null || projectRouting.isEmpty() || ALIAS_MATCH_ALL.equalsIgnoreCase(projectRouting)) {
89-
return Stream.concat(Stream.of(originProject), candidateProjectStream).toList();
90-
}
91-
9299
validateProjectRouting(projectRouting);
93100

94101
var matchesSpecifiedRoute = createRoutingEntryFilter(projectRouting);
95-
return Stream.concat(Stream.of(originProject), candidateProjectStream).filter(matchesSpecifiedRoute).toList();
102+
return new TargetProjects(
103+
matchesSpecifiedRoute.test(originProject) ? originProject : null,
104+
candidateProjectStream.filter(matchesSpecifiedRoute).toList()
105+
);
96106
}
97107

98108
private static void validateProjectRouting(String projectRouting) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
/**
13+
* Filter for the target projects based on the provided project routing string.
14+
*/
15+
public interface ProjectRoutingResolver {
16+
17+
/**
18+
* Filters the specified TargetProjects based on the provided project routing string
19+
* @param projectRouting the project_routing specified in the request object
20+
* @param targetProjects The target projects to be filtered
21+
* @return A new TargetProjects instance containing only the projects that match the project routing.
22+
*/
23+
TargetProjects resolve(String projectRouting, TargetProjects targetProjects);
24+
}

0 commit comments

Comments
 (0)