This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 04a8142de UNOMI-996: Let the 3.0 to 4.0 migration finish (#908)
04a8142de is described below
commit 04a8142deb0c77be446185849bce53d30845af35
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 6 20:17:29 2026 +0200
UNOMI-996: Let the 3.0 to 4.0 migration finish (#908)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../test/java/org/apache/unomi/itests/AllITs.java | 2 +
.../apache/unomi/itests/CorePersistenceITs.java | 2 +
.../itests/migration/Migrate40IndexMappingIT.java | 169 +++++++++++++++++++++
tools/shell-commands/pom.xml | 23 +++
.../shell/migration/utils/MigrationUtils.java | 81 ++++++++--
.../migrate-4.0.0-01-tenantDocumentIds.groovy | 39 +++--
.../migrate-4.0.0-10-tenantInitialization.groovy | 3 +-
.../initialize_tenant_and_audit_fields.painless | 10 +-
.../shell/migration/utils/MigrationUtilsTest.java | 95 +++++++++++-
9 files changed, 396 insertions(+), 28 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 61e39077e..4d377c022 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -18,6 +18,7 @@ package org.apache.unomi.itests;
import org.apache.unomi.itests.migration.Migrate16xToCurrentVersionIT;
import org.apache.unomi.itests.graphql.*;
+import org.apache.unomi.itests.migration.Migrate40IndexMappingIT;
import org.apache.unomi.itests.migration.MigrationIT;
import org.apache.unomi.itests.shell.*;
import org.junit.runner.RunWith;
@@ -34,6 +35,7 @@ import org.junit.runners.Suite.SuiteClasses;
@SuiteClasses({
Migrate16xToCurrentVersionIT.class,
MigrationIT.class,
+ Migrate40IndexMappingIT.class,
PersistenceServiceIT.class,
BasicIT.class,
ConditionEvaluatorIT.class,
diff --git
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index 3d19d4538..bf50ac2b2 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -18,6 +18,7 @@ package org.apache.unomi.itests;
import org.apache.unomi.itests.migration.Migrate16xToCurrentVersionIT;
import org.apache.unomi.itests.graphql.*;
+import org.apache.unomi.itests.migration.Migrate40IndexMappingIT;
import org.apache.unomi.itests.migration.MigrationIT;
import org.apache.unomi.itests.shell.*;
import org.junit.runner.RunWith;
@@ -35,6 +36,7 @@ import org.junit.runners.Suite.SuiteClasses;
@SuiteClasses({
Migrate16xToCurrentVersionIT.class,
MigrationIT.class,
+ Migrate40IndexMappingIT.class,
PersistenceServiceIT.class,
BasicIT.class,
ConditionEvaluatorIT.class,
diff --git
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
new file mode 100644
index 000000000..b66b472f5
--- /dev/null
+++
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+package org.apache.unomi.itests.migration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.unomi.itests.BaseIT;
+import org.apache.unomi.itests.persistence.SearchBackendIT;
+import org.apache.unomi.shell.migration.utils.HttpUtils;
+import org.apache.unomi.shell.migration.utils.MigrationUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * UNOMI-996: migration must find bundled mappings (including cluster nodes)
and
+ * copy the mapping of an unknown index instead of stopping.
+ */
+@Category(SearchBackendIT.class)
+public class Migrate40IndexMappingIT extends BaseIT {
+
+ private static final String UNKNOWN_INDEX = "context-unomi996unknown";
+ private static final String DEST_INDEX = "context-unomi996unknown-dest";
+
+ @After
+ public void deleteTestIndices() throws Exception {
+ if (!persistenceCapabilities().httpAdminApi()) {
+ return;
+ }
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ MigrationUtils.deleteIndex(httpClient, es, UNKNOWN_INDEX);
+ MigrationUtils.deleteIndex(httpClient, es, DEST_INDEX);
+ }
+ }
+
+ @Test
+ public void mappingsAreBundledWithTheMigrationCommandForBothEngines() {
+ // Look in the migration command bundle only: the started persistence
bundle ships these files too
+ Bundle migrationBundle = FrameworkUtil.getBundle(MigrationUtils.class);
+ for (String engine : Arrays.asList(SEARCH_ENGINE_ELASTICSEARCH,
SEARCH_ENGINE_OPENSEARCH)) {
+ for (String mappingFile : Arrays.asList("clusterNode.json",
"profile.json", "event.json", "geonameEntry.json")) {
+ String path = "META-INF/cxs/migration-mappings/" + engine +
"/" + mappingFile;
+ Assert.assertNotNull(path + " should be shipped with the
migration command", migrationBundle.getEntry(path));
+ }
+ }
+ }
+
+ @Test
+ public void searchEngineIsDetected() throws Exception {
+ assumeHttpAdminApi();
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ Assert.assertEquals(getPersistenceBackend().providerId(),
+ MigrationUtils.getSearchEngine(httpClient,
getSearchEngineBaseUrl()));
+ }
+ }
+
+ @Test
+ public void unknownIndexKeepsDocumentsOnTheConfiguredTenant() throws
Exception {
+ JsonNode hits = migrateUnknownIndex(true);
+ Assert.assertEquals(2, hits.size());
+ for (JsonNode hit : hits) {
+ String id = hit.path("_id").asText();
+ Assert.assertTrue("document id should use the configured tenant: "
+ id, id.startsWith(TEST_TENANT_ID + "_"));
+ Assert.assertEquals(TEST_TENANT_ID,
hit.path("_source").path("tenantId").asText());
+ }
+ }
+
+ @Test
+ public void knownIndexKeepsSystemScopeDocumentsOnTheSystemTenant() throws
Exception {
+ JsonNode hits = migrateUnknownIndex(false);
+ Assert.assertEquals(2, hits.size());
+ for (JsonNode hit : hits) {
+ boolean systemScope =
"system".equals(hit.path("_source").path("scope").asText());
+ String expectedTenant = systemScope ? "system" : TEST_TENANT_ID;
+ Assert.assertEquals(expectedTenant + "_" +
hit.path("_source").path("itemId").asText(), hit.path("_id").asText());
+ Assert.assertEquals(expectedTenant,
hit.path("_source").path("tenantId").asText());
+ }
+ }
+
+ private void assumeHttpAdminApi() {
+ Assume.assumeTrue(
+ "HTTP admin API required to create migration fixture indices
(provider="
+ + getPersistenceBackend().providerId() + ")",
+ persistenceCapabilities().httpAdminApi());
+ }
+
+ /**
+ * Reindexes a fixture index holding one document without scope and one
with scope "system"
+ * through the 4.0 tenant script, the way the migration does, and returns
the resulting hits.
+ */
+ private JsonNode migrateUnknownIndex(boolean forceDefaultTenant) throws
Exception {
+ assumeHttpAdminApi();
+
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ BundleContext migrationBundles =
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
+
+ String sourceSettings = "{"
+ +
"\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},"
+ + "\"mappings\":{\"properties\":{"
+ + "\"itemId\":{\"type\":\"keyword\"},"
+ + "\"itemType\":{\"type\":\"keyword\"},"
+ + "\"scope\":{\"type\":\"keyword\"},"
+ + "\"tenantId\":{\"type\":\"keyword\"}"
+ + "}}}";
+ HttpUtils.executePutRequest(httpClient, es + "/" + UNKNOWN_INDEX,
sourceSettings, null);
+ HttpUtils.executePutRequest(httpClient, es + "/" + UNKNOWN_INDEX
+ + "/_doc/unknown1?refresh=true",
+
"{\"itemId\":\"unknown1\",\"itemType\":\"sfdcConfiguration\"}", null);
+ HttpUtils.executePutRequest(httpClient, es + "/" + UNKNOWN_INDEX
+ + "/_doc/unknown2?refresh=true",
+
"{\"itemId\":\"unknown2\",\"itemType\":\"sfdcConfiguration\",\"scope\":\"system\"}",
null);
+
+ String mapping =
MigrationUtils.extractMappingFromIndex(httpClient, es, UNKNOWN_INDEX);
+ Assert.assertTrue("copied mapping should keep itemType",
mapping.contains("itemType"));
+
+ String itemType = MigrationUtils.resolveItemType(UNKNOWN_INDEX,
"context",
+ Arrays.asList("profile", "event", "clusterNode",
"generic"));
+ Assert.assertEquals("unomi996unknown", itemType);
+
+ String destSettings =
"{\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},\"mappings\":"
+ + mapping + "}";
+ MigrationUtils.createIndex(httpClient, es, DEST_INDEX,
destSettings);
+
+ Map<String, Object> params = new HashMap<>();
+ params.put("tenantId", TEST_TENANT_ID);
+ params.put("systemTenantId", "system");
+ params.put("forceDefaultTenant", forceDefaultTenant);
+ params.put("itemType", itemType);
+ params.put("date", "2026-01-01T00:00:00Z");
+ params.put("systemItems", Collections.emptySet());
+
+ String painless = MigrationUtils.getFileWithoutComments(
+ migrationBundles,
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
+ MigrationUtils.moveToIndex(httpClient, migrationBundles, es,
UNKNOWN_INDEX, DEST_INDEX, painless, params);
+ HttpUtils.executePostRequest(httpClient, es + "/" + DEST_INDEX +
"/_refresh", null, null);
+
+ String search = HttpUtils.executeGetRequest(httpClient,
+ es + "/" + DEST_INDEX + "/_search?size=10", null);
+ return new
ObjectMapper().readTree(search).path("hits").path("hits");
+ }
+ }
+}
diff --git a/tools/shell-commands/pom.xml b/tools/shell-commands/pom.xml
index 9451764d7..17ebbcced 100644
--- a/tools/shell-commands/pom.xml
+++ b/tools/shell-commands/pom.xml
@@ -157,6 +157,29 @@
</dependencies>
<build>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ </resource>
+ <!-- Mappings needed by the migration before Unomi is started.
Kept out of META-INF/cxs/mappings
+ so the persistence services do not load them as predefined
mappings. -->
+ <resource>
+
<directory>../../persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings</directory>
+
<targetPath>META-INF/cxs/migration-mappings/elasticsearch</targetPath>
+ </resource>
+ <resource>
+
<directory>../../extensions/geonames/services/src/main/resources/META-INF/cxs/mappings</directory>
+
<targetPath>META-INF/cxs/migration-mappings/elasticsearch</targetPath>
+ </resource>
+ <resource>
+
<directory>../../persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings</directory>
+
<targetPath>META-INF/cxs/migration-mappings/opensearch</targetPath>
+ </resource>
+ <resource>
+
<directory>../../extensions/geonames/services/src/main/resources/META-INF/cxs/mappings</directory>
+
<targetPath>META-INF/cxs/migration-mappings/opensearch</targetPath>
+ </resource>
+ </resources>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
diff --git
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
index b80055576..4e8d71657 100644
---
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
+++
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
@@ -303,20 +303,68 @@ public class MigrationUtils {
}
public static String extractMappingFromBundles(BundleContext
bundleContext, String fileName) throws IOException {
- for (Bundle bundle : bundleContext.getBundles()) {
- Enumeration<URL> predefinedMappings =
bundle.findEntries("META-INF/cxs/mappings", fileName, true);
- if (predefinedMappings == null) {
- continue;
- }
- if (predefinedMappings.hasMoreElements()) {
- URL predefinedMappingURL = predefinedMappings.nextElement();
- return IOUtils.toString(predefinedMappingURL);
+ return extractMappingFromBundles(bundleContext, fileName,
"elasticsearch");
+ }
+
+ /**
+ * Looks up a mapping file in every OSGi bundle: first in {@code
META-INF/cxs/mappings}
+ * (the persistence bundles, when installed), then in the per-engine copy
shipped with the
+ * migration command bundle, which is available before Unomi is started.
The copy lives in a
+ * separate folder so the persistence services do not load it as a
predefined mapping.
+ *
+ * @param searchEngine {@code elasticsearch} or {@code opensearch}, see
{@link #getSearchEngine}
+ */
+ public static String extractMappingFromBundles(BundleContext
bundleContext, String fileName, String searchEngine) throws IOException {
+ for (String path : List.of("META-INF/cxs/mappings",
"META-INF/cxs/migration-mappings/" + searchEngine)) {
+ for (Bundle bundle : bundleContext.getBundles()) {
+ Enumeration<URL> predefinedMappings = bundle.findEntries(path,
fileName, true);
+ if (predefinedMappings != null &&
predefinedMappings.hasMoreElements()) {
+ return IOUtils.toString(predefinedMappings.nextElement(),
StandardCharsets.UTF_8);
+ }
}
}
-
throw new RuntimeException("no mapping found in bundles for: " +
fileName);
}
+ /**
+ * Copies the mapping currently stored on an index (GET {@code
/{index}/_mapping}).
+ * Used for indices that have no bundled 4.0 mapping.
+ */
+ public static String extractMappingFromIndex(CloseableHttpClient
httpClient, String esAddress, String indexName) throws IOException {
+ return
mappingsFromIndexResponse(HttpUtils.executeGetRequest(httpClient, esAddress +
"/" + indexName + "/_mapping", null));
+ }
+
+ static String mappingsFromIndexResponse(String responseBody) {
+ if (responseBody == null || responseBody.isBlank()) {
+ throw new IllegalArgumentException("Index mapping response is
empty");
+ }
+ JSONObject root = new JSONObject(responseBody);
+ if (root.length() != 1) {
+ throw new IllegalArgumentException("Expected the mapping of
exactly one index, got: " + root.keySet());
+ }
+ return
root.getJSONObject(root.keys().next()).getJSONObject("mappings").toString();
+ }
+
+ /**
+ * Maps an index name such as {@code context-event-000001} or {@code
context-clusternode}
+ * to an item type. The name after the prefix (without a rollover number)
must equal a known
+ * type, ignoring case; otherwise that name is returned as is. Names that
do not start with
+ * the prefix resolve to {@code generic}.
+ */
+ public static String resolveItemType(String indexName, String indexPrefix,
Collection<String> knownTypes) {
+ String prefix = indexPrefix + "-";
+ if (!indexName.regionMatches(true, 0, prefix, 0, prefix.length())) {
+ return "generic";
+ }
+ String name =
indexName.substring(prefix.length()).replaceFirst("-\\d{6}$", "");
+ for (String type : knownTypes) {
+ if (name.equalsIgnoreCase(type)) {
+ return type;
+ }
+ }
+ return name;
+ }
+
public static String buildIndexCreationRequest(String baseIndexSettings,
String mapping, MigrationContext context, boolean isMonthlyIndex) throws
IOException {
String settings = baseIndexSettings
.replace("#numberOfShards",
context.getConfigString(isMonthlyIndex ? MONTHLY_NUMBER_OF_SHARDS :
NUMBER_OF_SHARDS))
@@ -920,6 +968,21 @@ public class MigrationUtils {
return version.split("\\.")[0]; // Return major version number
}
+ /**
+ * Detects the engine behind the given address from its root endpoint.
+ *
+ * @return {@code opensearch} or {@code elasticsearch}
+ */
+ public static String getSearchEngine(CloseableHttpClient httpClient,
String esAddress) throws IOException {
+ return
searchEngineFromRootResponse(HttpUtils.executeGetRequest(httpClient, esAddress,
null));
+ }
+
+ static String searchEngineFromRootResponse(String responseBody) {
+ // Only OpenSearch reports a distribution
+ String distribution = new
JSONObject(responseBody).getJSONObject("version").optString("distribution");
+ return "opensearch".equals(distribution) ? "opensearch" :
"elasticsearch";
+ }
+
public interface ScrollCallback {
void execute(String hits);
}
diff --git
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
index 647aae6ac..f5f265e3d 100644
---
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
+++
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
@@ -67,22 +67,25 @@ def indexConfigs = [
mapping: "personaSession.json",
useRollover: false
],
- "generic": [
+ "profileAlias": [
baseSettings: "requestBody/2.0.0/base_index_mapping.json",
- mapping: null, // Will be determined dynamically from resolved
item type
+ mapping: "profileAlias.json",
useRollover: false
+ ],
+ "clusterNode": [
+ baseSettings: "requestBody/2.0.0/base_index_mapping.json",
+ mapping: "clusterNode.json",
+ useRollover: false,
+ forceDefaultTenant: true
+ ],
+ "generic": [
+ baseSettings: "requestBody/2.0.0/base_index_mapping.json",
+ mapping: null, // Copy the mapping currently stored on the
index
+ useRollover: false,
+ forceDefaultTenant: true
]
]
-// Helper function to resolve item type from index name
-def resolveItemType = { String indexName ->
- def type = indexConfigs.find { type, config ->
- indexName.startsWith("${indexPrefix}-${type}")
- }
- return type ? type.key : "generic"
-}
-
-// Helper function to get index configuration
def getIndexConfig = { String itemType ->
return indexConfigs[itemType] ?: indexConfigs["generic"]
}
@@ -117,23 +120,31 @@ context.performMigrationStep("4.0.0-get-all-indices", ()
-> {
// Get the Painless script
String updateScript = MigrationUtils.getFileWithoutComments(bundleContext,
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless")
+ String searchEngine =
MigrationUtils.getSearchEngine(context.getHttpClient(), esAddress)
+ context.printMessage("Search engine: " + searchEngine)
+
// Process each index (reindex them)
allIndices.each { indexName ->
context.printMessage("Processing index: " + indexName)
// Determine item type and get configuration
- String itemType = resolveItemType(indexName)
+ String itemType = MigrationUtils.resolveItemType(indexName,
indexPrefix, indexConfigs.keySet())
def indexConfig = getIndexConfig(itemType)
// Add item type to parameters
Map<String, Object> params = new HashMap<>(baseParams)
params.put("itemType", itemType)
+ // Cluster nodes and unknown indices keep site data in the configured
tenant, never "system"
+ boolean forceDefaultTenant = indexConfig.forceDefaultTenant == true
+ params.put("forceDefaultTenant", forceDefaultTenant)
// Get base settings and mapping
String baseSettings = MigrationUtils.resourceAsString(bundleContext,
indexConfig.baseSettings)
+ // A known type must find its bundled 4.0 mapping; only unknown types
copy the mapping stored on the index
String mapping = indexConfig.mapping ?
- MigrationUtils.extractMappingFromBundles(bundleContext,
indexConfig.mapping) :
- MigrationUtils.extractMappingFromBundles(bundleContext,
"${itemType}.json")
+ MigrationUtils.extractMappingFromBundles(bundleContext,
indexConfig.mapping, searchEngine) :
+
MigrationUtils.extractMappingFromIndex(context.getHttpClient(), esAddress,
indexName)
+ context.printMessage("Item type: ${itemType}, mapping:
${indexConfig.mapping ?: 'copied from the index'}, default tenant only:
${forceDefaultTenant}")
// Build index settings
String newIndexSettings
diff --git
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-10-tenantInitialization.groovy
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-10-tenantInitialization.groovy
index 995f09d69..dada0f0f4 100644
---
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-10-tenantInitialization.groovy
+++
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-10-tenantInitialization.groovy
@@ -71,7 +71,8 @@ if (Files.exists(secretsDir)) {
// Create the default tenant index and items
context.performMigrationStep("4.0.0-create-tenant-index", () -> {
String baseSettings = MigrationUtils.resourceAsString(bundleContext,
"requestBody/2.0.0/base_index_mapping.json")
- String mapping = MigrationUtils.extractMappingFromBundles(bundleContext,
"tenant.json")
+ String mapping = MigrationUtils.extractMappingFromBundles(bundleContext,
"tenant.json",
+ MigrationUtils.getSearchEngine(context.getHttpClient(), esAddress))
String newIndexSettings =
MigrationUtils.buildIndexCreationRequest(baseSettings, mapping, context, false)
if (!MigrationUtils.indexExists(context.getHttpClient(), esAddress,
"${indexPrefix}-tenant")) {
diff --git
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
index 07b89377b..7b089252d 100644
---
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
+++
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
@@ -18,8 +18,10 @@
if (ctx._id != null) {
// Skip if already has tenant prefix
if (!ctx._id.startsWith(params.tenantId + '_') &&
!ctx._id.startsWith(params.systemTenantId + '_')) {
- // For geonames or items with system scope, use system tenant
- if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null &&
ctx._source.scope == 'system')) {
+ // Cluster nodes and unknown indices always stay in the configured
(default) tenant
+ if (params.forceDefaultTenant == true) {
+ ctx._id = params.tenantId + '_' + ctx._id;
+ } else if (ctx._index.endsWith('-geonames') || (ctx._source.scope !=
null && ctx._source.scope == 'system')) {
// Preserve the entire original document ID (it may contain
underscores, e.g., "dummy_scope")
// Do NOT split on underscores - this was a bug in the original
migration that caused itemIds
// like "dummy_scope" to become "dummy" when the document ID was
"dummy_scope_scope"
@@ -92,7 +94,9 @@ if (!ctx._index.endsWith('-systemitems') &&
!ctx._index.endsWith('-geonames')) {
// Set tenant ID in the source document based on scope for ALL items
if (ctx._source.tenantId == null) {
- if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null &&
ctx._source.scope == 'system')) {
+ if (params.forceDefaultTenant == true) {
+ ctx._source.tenantId = params.tenantId;
+ } else if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null
&& ctx._source.scope == 'system')) {
ctx._source.tenantId = params.systemTenantId;
} else {
ctx._source.tenantId = params.tenantId;
diff --git
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
index 96129f47c..92b48578c 100644
---
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
+++
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
@@ -25,6 +25,11 @@ import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
@@ -209,4 +214,92 @@ public class MigrationUtilsTest {
() -> MigrationUtils.getFileWithoutComments(bundleContext,
"missing.painless"));
assertEquals("Resource not found: missing.painless", ex.getMessage());
}
-}
\ No newline at end of file
+
+ private static final String MAPPING =
"{\"properties\":{\"cpuLoad\":{\"type\":\"double\"}}}";
+
+ private String extractClusterNodeMappingFrom(String path) throws Exception
{
+ return extractClusterNodeMappingFrom(path, "elasticsearch");
+ }
+
+ private String extractClusterNodeMappingFrom(String path, String
searchEngine) throws Exception {
+ Path mappingFile = Files.createTempFile("clusterNode", ".json");
+ try {
+ Files.writeString(mappingFile, MAPPING);
+ when(bundleContext.getBundles()).thenReturn(new Bundle[]{bundle});
+ when(bundle.findEntries(path, "clusterNode.json", true))
+
.thenReturn(Collections.enumeration(Collections.singletonList(mappingFile.toUri().toURL())));
+ return MigrationUtils.extractMappingFromBundles(bundleContext,
"clusterNode.json", searchEngine);
+ } finally {
+ Files.deleteIfExists(mappingFile);
+ }
+ }
+
+ @Test
+ public void extractMappingFromBundlesFindsPersistenceMapping() throws
Exception {
+ assertEquals(MAPPING,
extractClusterNodeMappingFrom("META-INF/cxs/mappings"));
+ }
+
+ @Test
+ public void extractMappingFromBundlesFallsBackToMigrationCopy() throws
Exception {
+ assertEquals(MAPPING,
extractClusterNodeMappingFrom("META-INF/cxs/migration-mappings/elasticsearch"));
+ assertEquals(MAPPING,
extractClusterNodeMappingFrom("META-INF/cxs/migration-mappings/opensearch",
"opensearch"));
+ }
+
+ @Test
+ public void extractMappingFromBundlesIgnoresTheOtherEngineCopy() {
+ when(bundleContext.getBundles()).thenReturn(new Bundle[]{bundle});
+
when(bundle.findEntries("META-INF/cxs/migration-mappings/elasticsearch",
"event.json", true))
+
.thenReturn(Collections.enumeration(Collections.singletonList(getClass().getResource("/"))));
+
+ assertThrows(RuntimeException.class,
+ () -> MigrationUtils.extractMappingFromBundles(bundleContext,
"event.json", "opensearch"));
+ }
+
+ @Test
+ public void searchEngineIsDetectedFromTheRootResponse() {
+ assertEquals("opensearch", MigrationUtils.searchEngineFromRootResponse(
+
"{\"version\":{\"distribution\":\"opensearch\",\"number\":\"3.0.0\"}}"));
+ assertEquals("elasticsearch",
MigrationUtils.searchEngineFromRootResponse(
+ "{\"version\":{\"number\":\"9.1.0\"}}"));
+ }
+
+ @Test
+ public void extractMappingFromBundlesThrowsWhenMissing() {
+ when(bundleContext.getBundles()).thenReturn(new Bundle[]{bundle});
+
+ RuntimeException ex = assertThrows(RuntimeException.class,
+ () -> MigrationUtils.extractMappingFromBundles(bundleContext,
"generic.json"));
+ assertEquals("no mapping found in bundles for: generic.json",
ex.getMessage());
+ }
+
+ @Test
+ public void mappingsFromIndexResponseReadsTheIndexMappings() {
+ String body =
"{\"context-sfdcconfiguration\":{\"mappings\":{\"properties\":{\"itemId\":{\"type\":\"keyword\"}}}}}";
+ assertEquals("{\"properties\":{\"itemId\":{\"type\":\"keyword\"}}}",
MigrationUtils.mappingsFromIndexResponse(body));
+ }
+
+ @Test
+ public void mappingsFromIndexResponseRejectsAnythingButOneIndex() {
+ assertThrows(IllegalArgumentException.class, () ->
MigrationUtils.mappingsFromIndexResponse(""));
+ assertThrows(IllegalArgumentException.class, () ->
MigrationUtils.mappingsFromIndexResponse("{}"));
+ assertThrows(IllegalArgumentException.class,
+ () ->
MigrationUtils.mappingsFromIndexResponse("{\"a\":{\"mappings\":{}},\"b\":{\"mappings\":{}}}"));
+ }
+
+ @Test
+ public void
resolveItemTypeMatchesKnownTypesIgnoringCaseAndRolloverNumber() {
+ Collection<String> types = Arrays.asList("profile", "profileAlias",
"event", "clusterNode");
+ assertEquals("clusterNode",
MigrationUtils.resolveItemType("context-clusternode", "context", types));
+ assertEquals("profileAlias",
MigrationUtils.resolveItemType("context-profilealias", "context", types));
+ assertEquals("event",
MigrationUtils.resolveItemType("context-event-000001", "context", types));
+ }
+
+ @Test
+ public void resolveItemTypeDoesNotMatchOnKnownTypePrefix() {
+ Collection<String> types = Arrays.asList("profile", "event");
+ assertEquals("profilealias",
MigrationUtils.resolveItemType("context-profilealias", "context", types));
+ assertEquals("eventfoo",
MigrationUtils.resolveItemType("context-eventfoo-000001", "context", types));
+ assertEquals("sfdcconfiguration",
MigrationUtils.resolveItemType("context-sfdcconfiguration", "context", types));
+ assertEquals("generic",
MigrationUtils.resolveItemType("other-profile", "context", types));
+ }
+}