Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,5 @@
exports org.elasticsearch.inference.telemetry;
exports org.elasticsearch.index.codec.vectors.diskbbq to org.elasticsearch.test.knn;
exports org.elasticsearch.index.codec.vectors.cluster to org.elasticsearch.test.knn;
exports org.elasticsearch.search.crossproject;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import org.elasticsearch.search.aggregations.AggregationExecutionException;
import org.elasticsearch.search.aggregations.MultiBucketConsumerService;
import org.elasticsearch.search.aggregations.UnsupportedAggregationOnDownsampledIndex;
import org.elasticsearch.search.crossproject.NoMatchingProjectException;
import org.elasticsearch.search.query.SearchTimeoutException;
import org.elasticsearch.transport.TcpTransport;
import org.elasticsearch.xcontent.ParseField;
Expand Down Expand Up @@ -79,6 +80,7 @@
import static org.elasticsearch.cluster.metadata.IndexMetadata.INDEX_UUID_NA_VALUE;
import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName;
import static org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION;

/**
* A base class for all elasticsearch exceptions.
Expand Down Expand Up @@ -2022,6 +2024,12 @@ private enum ElasticsearchExceptionHandle {
184,
TransportVersions.REMOTE_EXCEPTION,
TransportVersions.REMOTE_EXCEPTION_8_19
),
NO_MATCHING_PROJECT_EXCEPTION(
NoMatchingProjectException.class,
NoMatchingProjectException::new,
185,
NO_MATCHING_PROJECT_EXCEPTION_VERSION
);

final Class<? extends ElasticsearchException> exceptionClass;
Expand Down
Original file line number Diff line number Diff line change
@@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.search.crossproject;

import org.elasticsearch.TransportVersion;
import org.elasticsearch.cluster.metadata.ClusterNameExpressionResolver;
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.logging.LogManager;
import org.elasticsearch.logging.Logger;
import org.elasticsearch.transport.NoSuchRemoteClusterException;
import org.elasticsearch.transport.RemoteClusterAware;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Utility class for rewriting cross-project index expressions.
* Provides methods that can rewrite qualified and unqualified index expressions to canonical CCS.
*/
public class CrossProjectIndexExpressionsRewriter {
public static TransportVersion NO_MATCHING_PROJECT_EXCEPTION_VERSION = TransportVersion.fromName("no_matching_project_exception");

private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class);
private static final String ORIGIN_PROJECT_KEY = "_origin";
private static final String WILDCARD = "*";
private static final String[] MATCH_ALL = new String[] { WILDCARD };
private static final String EXCLUSION = "-";
private static final String DATE_MATH = "<";

/**
* Rewrites index expressions for cross-project search requests.
* Handles qualified and unqualified expressions and match-all cases will also hand exclusions in the future.
*
* @param originProject the _origin project with its alias
* @param linkedProjects the list of linked and available projects to consider for a request
* @param originalIndices the array of index expressions to be rewritten to canonical CCS
* @return a map from original index expressions to lists of canonical index expressions
* @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions
* @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing
*/
public static Map<String, List<String>> rewriteIndexExpressions(
ProjectRoutingInfo originProject,
List<ProjectRoutingInfo> linkedProjects,
final String[] originalIndices
) {
final String[] indices;
if (originalIndices == null || originalIndices.length == 0) { // handling of match all cases besides _all and `*`
indices = MATCH_ALL;
} else {
indices = originalIndices;
}
assert false == IndexNameExpressionResolver.isNoneExpression(indices)
: "expression list is *,-* which effectively means a request that requests no indices";
assert originProject != null || linkedProjects.isEmpty() == false
: "either origin project or linked projects must be in project target set";

Set<String> linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet());
Map<String, List<String>> canonicalExpressionsMap = new LinkedHashMap<>(indices.length);
for (String resource : indices) {
if (canonicalExpressionsMap.containsKey(resource)) {
continue;
}
maybeThrowOnUnsupportedResource(resource);

boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource);
if (isQualified) {
// handing of qualified expressions
String[] splitResource = RemoteClusterAware.splitIndexName(resource);
assert splitResource.length == 2
: "Expected two strings (project and indexExpression) for a qualified resource ["
+ resource
+ "], but found ["
+ splitResource.length
+ "]";
String projectAlias = splitResource[0];
assert projectAlias != null : "Expected a project alias for a qualified resource but was null";
String indexExpression = splitResource[1];
maybeThrowOnUnsupportedResource(indexExpression);

List<String> canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames);

canonicalExpressionsMap.put(resource, canonicalExpressions);
logger.debug("Rewrote qualified expression [{}] to [{}]", resource, canonicalExpressions);
} else {
// un-qualified expression, i.e. flat-world
List<String> canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects);
canonicalExpressionsMap.put(resource, canonicalExpressions);
logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions);
}
}
return canonicalExpressionsMap;
}

private static List<String> rewriteUnqualified(
String indexExpression,
@Nullable ProjectRoutingInfo origin,
List<ProjectRoutingInfo> projects
) {
List<String> canonicalExpressions = new ArrayList<>();
if (origin != null) {
canonicalExpressions.add(indexExpression); // adding the original indexExpression for the _origin cluster.
}
for (ProjectRoutingInfo targetProject : projects) {
canonicalExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProject.projectAlias(), indexExpression));
}
return canonicalExpressions;
}

private static List<String> rewriteQualified(
String requestedProjectAlias,
String indexExpression,
@Nullable ProjectRoutingInfo originProject,
Set<String> allProjectAliases
) {
if (originProject != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) {
// handling case where we have a qualified expression like: _origin:indexName
return List.of(indexExpression);
}

if (originProject == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) {
// handling case where we have a qualified expression like: _origin:indexName but no _origin project is set
throw new NoMatchingProjectException(requestedProjectAlias);
}

try {
if (originProject != null) {
allProjectAliases.add(originProject.projectAlias());
}
List<String> resourcesMatchingAliases = new ArrayList<>();
List<String> allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames(
allProjectAliases,
requestedProjectAlias
);

if (allProjectsMatchingAlias.isEmpty()) {
throw new NoMatchingProjectException(requestedProjectAlias);
}

for (String project : allProjectsMatchingAlias) {
if (originProject != null && project.equals(originProject.projectAlias())) {
resourcesMatchingAliases.add(indexExpression);
} else {
resourcesMatchingAliases.add(RemoteClusterAware.buildRemoteIndexName(project, indexExpression));
}
}

return resourcesMatchingAliases;
} catch (NoSuchRemoteClusterException ex) {
logger.debug(ex.getMessage(), ex);
throw new NoMatchingProjectException(requestedProjectAlias);
}
}

private static void maybeThrowOnUnsupportedResource(String resource) {
// TODO To be handled in future PR.
if (resource.startsWith(EXCLUSION)) {
throw new IllegalArgumentException("Exclusions are not currently supported but was found in the expression [" + resource + "]");
}
if (resource.startsWith(DATE_MATH)) {
throw new IllegalArgumentException("Date math are not currently supported but was found in the expression [" + resource + "]");
}
if (IndexNameExpressionResolver.hasSelectorSuffix(resource)) {
throw new IllegalArgumentException("Selectors are not currently supported but was found in the expression [" + resource + "]");

}
}
}
Original file line number Diff line number Diff line change
@@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.search.crossproject;

import org.elasticsearch.ResourceNotFoundException;
import org.elasticsearch.common.io.stream.StreamInput;

import java.io.IOException;

/**
* An exception that a project is missing
*/
public final class NoMatchingProjectException extends ResourceNotFoundException {

public NoMatchingProjectException(String projectName) {
super("No such project: [" + projectName + "]");
}

public NoMatchingProjectException(StreamInput in) throws IOException {
super(in);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.search.crossproject;

import org.elasticsearch.cluster.metadata.ProjectId;

/**
* Information about a project used for routing in cross-project search.
*/
public record ProjectRoutingInfo(
ProjectId projectId,
String projectType,
String projectAlias,
String organizationId,
ProjectTags projectTags
) {
public ProjectRoutingInfo(ProjectId projectId, ProjectTags projectTags) {
this(projectId, projectTags.projectType(), projectTags.projectAlias(), projectTags.organizationId(), projectTags);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.search.crossproject;

import java.util.Map;

/**
* Project tags used for cross-project search routing.
* @param tags the map of tags -- contains both built-in (Elastic-supplied) and custom user-defined tags.
* All built-in tags are prefixed with an underscore (_).
*/
public record ProjectTags(Map<String, String> tags) {
public static final String PROJECT_ID_TAG = "_id";
public static final String PROJECT_ALIAS = "_alias";
public static final String PROJECT_TYPE_TAG = "_type";
public static final String ORGANIZATION_ID_TAG = "_organization";

public String projectId() {
return tags.get(PROJECT_ID_TAG);
}

public String organizationId() {
return tags.get(ORGANIZATION_ID_TAG);
}

public String projectType() {
return tags.get(PROJECT_TYPE_TAG);
}

public String projectAlias() {
return tags.get(PROJECT_ALIAS);
}

/**
* Validate that all required tags are present.
*/
public static void validateTags(String projectId, Map<String, String> tags) {
if (false == tags.containsKey(PROJECT_ID_TAG)) {
throw missingTagException(projectId, PROJECT_ID_TAG);
}
if (false == tags.containsKey(PROJECT_TYPE_TAG)) {
throw missingTagException(projectId, PROJECT_TYPE_TAG);
}
if (false == tags.containsKey(ORGANIZATION_ID_TAG)) {
throw missingTagException(projectId, ORGANIZATION_ID_TAG);
}
if (false == tags.containsKey(PROJECT_ALIAS)) {
throw missingTagException(projectId, PROJECT_ALIAS);
}
}

private static IllegalStateException missingTagException(String projectId, String tagKey) {
return new IllegalStateException("Project configuration for [" + projectId + "] is missing required tag [" + tagKey + "]");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
9178000
2 changes: 1 addition & 1 deletion server/src/main/resources/transport/upper_bounds/9.2.csv
Original file line number Diff line number Diff line change
@@ -1 +1 @@
extended_search_usage_telemetry,9177000
no_matching_project_exception,9178000
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
import org.elasticsearch.search.aggregations.AggregationExecutionException;
import org.elasticsearch.search.aggregations.MultiBucketConsumerService;
import org.elasticsearch.search.aggregations.UnsupportedAggregationOnDownsampledIndex;
import org.elasticsearch.search.crossproject.NoMatchingProjectException;
import org.elasticsearch.search.internal.ShardSearchContextId;
import org.elasticsearch.search.query.SearchTimeoutException;
import org.elasticsearch.snapshots.Snapshot;
Expand Down Expand Up @@ -846,6 +847,7 @@ public void testIds() {
ids.put(182, IngestPipelineException.class);
ids.put(183, IndexDocFailureStoreStatus.ExceptionWithFailureStoreStatus.class);
ids.put(184, RemoteException.class);
ids.put(185, NoMatchingProjectException.class);

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