diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index cfe6345d7e590..68dc8da3b7d15 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -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; } diff --git a/server/src/main/java/org/elasticsearch/ElasticsearchException.java b/server/src/main/java/org/elasticsearch/ElasticsearchException.java index 79d1df6a09be4..98a0846ad9fd1 100644 --- a/server/src/main/java/org/elasticsearch/ElasticsearchException.java +++ b/server/src/main/java/org/elasticsearch/ElasticsearchException.java @@ -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; @@ -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. @@ -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 exceptionClass; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java new file mode 100644 index 0000000000000..c83b1731a1cb9 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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> rewriteIndexExpressions( + ProjectRoutingInfo originProject, + List 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 linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); + Map> 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 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 canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects); + canonicalExpressionsMap.put(resource, canonicalExpressions); + logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions); + } + } + return canonicalExpressionsMap; + } + + private static List rewriteUnqualified( + String indexExpression, + @Nullable ProjectRoutingInfo origin, + List projects + ) { + List 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 rewriteQualified( + String requestedProjectAlias, + String indexExpression, + @Nullable ProjectRoutingInfo originProject, + Set 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 resourcesMatchingAliases = new ArrayList<>(); + List 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 + "]"); + + } + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java new file mode 100644 index 0000000000000..5c271e251795e --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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); + } + +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java new file mode 100644 index 0000000000000..e8c52be5ea86d --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java @@ -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); + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java new file mode 100644 index 0000000000000..6db280e6f7d22 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java @@ -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 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 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 + "]"); + } +} diff --git a/server/src/main/resources/transport/definitions/referable/no_matching_project_exception.csv b/server/src/main/resources/transport/definitions/referable/no_matching_project_exception.csv new file mode 100644 index 0000000000000..0539697e70bda --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/no_matching_project_exception.csv @@ -0,0 +1 @@ +9178000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 980b8e87c6329..34a3bd3790d5b 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -extended_search_usage_telemetry,9177000 +no_matching_project_exception,9178000 diff --git a/server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java b/server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java index 64c0b9a780cfe..2f7fb3660446f 100644 --- a/server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java +++ b/server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java @@ -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; @@ -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, Integer> reverse = new HashMap<>(); for (Map.Entry> entry : ids.entrySet()) { diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java new file mode 100644 index 0000000000000..95285c06d1a4f --- /dev/null +++ b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java @@ -0,0 +1,427 @@ +/* + * 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.cluster.metadata.ProjectId; +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsInAnyOrder; + +public class CrossProjectIndexExpressionsRewriterTests extends ESTestCase { + + public void testFlatOnlyRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("P3") + ); + String[] requestedResources = new String[] { "logs*", "metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("logs*", "metrics*")); + assertThat(canonical.get("logs*"), containsInAnyOrder("logs*", "P1:logs*", "P2:logs*", "P3:logs*")); + assertThat(canonical.get("metrics*"), containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*")); + } + + public void testFlatAndQualifiedRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("P3") + ); + String[] requestedResources = new String[] { "P1:logs*", "metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "metrics*")); + assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*")); + assertThat(canonical.get("metrics*"), containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*")); + } + + public void testQualifiedOnlyRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("P3") + ); + String[] requestedResources = new String[] { "P1:logs*", "P2:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "P2:metrics*")); + assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*")); + assertThat(canonical.get("P2:metrics*"), containsInAnyOrder("P2:metrics*")); + } + + public void testOriginQualifiedOnlyRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("P3") + ); + String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); + assertThat(canonical.get("_origin:logs*"), containsInAnyOrder("logs*")); + assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + } + + public void testOriginQualifiedOnlyRewriteWithNoLikedProjects() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(); + String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); + assertThat(canonical.get("_origin:logs*"), containsInAnyOrder("logs*")); + assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + } + + public void testOriginWithDifferentAliasQualifiedOnlyRewrite() { + String aliasForOrigin = randomAlphaOfLength(10); + ProjectRoutingInfo origin = createRandomProjectWithAlias(aliasForOrigin); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("P3") + ); + String logIndexAlias = "logs*"; + String logResource = aliasForOrigin + ":" + logIndexAlias; + String metricsIndexAlias = "metrics*"; + String metricResource = aliasForOrigin + ":" + metricsIndexAlias; + String[] requestedResources = new String[] { logResource, metricResource }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder(logResource, metricResource)); + assertThat(canonical.get(logResource), containsInAnyOrder(logIndexAlias)); + assertThat(canonical.get(metricResource), containsInAnyOrder(metricsIndexAlias)); + } + + public void testQualifiedLinkedAndOriginRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("P3") + ); + String[] requestedResources = new String[] { "P1:logs*", "_origin:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "_origin:metrics*")); + assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*")); + assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + } + + public void testQualifiedStartsWithProjectWildcardRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "Q*:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("Q*:metrics*")); + assertThat(canonical.get("Q*:metrics*"), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); + } + + public void testQualifiedEndsWithProjectWildcardRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "*1:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("*1:metrics*")); + assertThat(canonical.get("*1:metrics*"), containsInAnyOrder("P1:metrics*", "Q1:metrics*")); + } + + public void testOriginProjectMatchingTwice() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); + String[] requestedResources = new String[] { "P0:metrics*", "_origin:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("P0:metrics*", "_origin:metrics*")); + assertThat(canonical.get("P0:metrics*"), containsInAnyOrder("metrics*")); + assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + } + + public void testUnderscoreWildcardShouldNotMatchOrigin() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(createRandomProjectWithAlias("_P1"), createRandomProjectWithAlias("_P2")); + String[] requestedResources = new String[] { "_*:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("_*:metrics*")); + assertThat(canonical.get("_*:metrics*"), containsInAnyOrder("_P1:metrics*", "_P2:metrics*")); + } + + public void testDuplicateInputShouldProduceSingleOutput() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String indexPattern = "Q*:metrics*"; + String[] requestedResources = new String[] { indexPattern, indexPattern }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder(indexPattern)); + assertThat(canonical.get(indexPattern), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); + } + + public void testProjectWildcardNotMatchingAnythingShouldThrow() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "S*:metrics*" }; + + expectThrows( + ResourceNotFoundException.class, + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + ); + } + + public void testRewritingShouldThrowOnIndexExclusions() { + // This will fail when we implement index exclusions + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "P*:metrics*", "-P1:metrics*" }; + + expectThrows( + IllegalArgumentException.class, + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + ); + } + + public void testRewritingShouldThrowOnIndexSelectors() { + // This will fail when we implement index exclusions + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "index::data" }; + + expectThrows( + IllegalArgumentException.class, + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + ); + } + + public void testWildcardOnlyProjectRewrite() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "*:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("*:metrics*")); + assertThat(canonical.get("*:metrics*"), containsInAnyOrder("P1:metrics*", "P2:metrics*", "Q1:metrics*", "Q2:metrics*", "metrics*")); + } + + public void testWildcardMatchesOnlyOriginProject() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("aliasForOrigin"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "alias*:metrics*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("alias*:metrics*")); + assertThat(canonical.get("alias*:metrics*"), containsInAnyOrder("metrics*")); + } + + public void testEmptyExpressionShouldMatchAll() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); + String[] requestedResources = new String[] {}; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("*")); + assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); + } + + public void testNullExpressionShouldMatchAll() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, null); + + assertThat(canonical.keySet(), containsInAnyOrder("*")); + assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); + } + + public void testWildcardExpressionShouldMatchAll() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); + String[] requestedResources = new String[] { "*" }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder("*")); + assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); + } + + public void test_ALLExpressionShouldMatchAll() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); + String all = randomBoolean() ? "_ALL" : "_all"; + String[] requestedResources = new String[] { all }; + + Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + origin, + linked, + requestedResources + ); + + assertThat(canonical.keySet(), containsInAnyOrder(all)); + assertThat(canonical.get(all), containsInAnyOrder("P1:" + all, "P2:" + all, all)); + } + + public void testRewritingShouldThrowIfNotProjectMatchExpression() { + ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); + List linked = List.of( + createRandomProjectWithAlias("P1"), + createRandomProjectWithAlias("P2"), + createRandomProjectWithAlias("Q1"), + createRandomProjectWithAlias("Q2") + ); + String[] requestedResources = new String[] { "X*:metrics" }; + + expectThrows( + NoMatchingProjectException.class, + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + ); + } + + private ProjectRoutingInfo createRandomProjectWithAlias(String alias) { + ProjectId projectId = randomUniqueProjectId(); + String type = randomFrom("elasticsearch", "security", "observability"); + String org = randomAlphaOfLength(10); + + Map tags = Map.of("_id", projectId.id(), "_type", type, "_organization", org, "_alias", alias); + ProjectTags projectTags = new ProjectTags(tags); + return new ProjectRoutingInfo(projectId, type, alias, org, projectTags); + } +}