This is an automated email from the ASF dual-hosted git repository. shuber pushed a commit to branch opensearch-persistence in repository https://gitbox.apache.org/repos/asf/unomi.git
commit dc14b2088eb06c1d2c686dbaae6ad18546c41cf4 Author: Serge Huber <shu...@jahia.com> AuthorDate: Tue Dec 31 16:26:42 2024 +0100 - Introduce new ProgressListener system to indicate the current progress status in the integration tests - Make sure the Unomi Management Service is started in IT tests before starting the unomi:start command - Add support for minimal cluster state to allow to start an OpenSearch cluster with yellow status in IT tests - Fix OpenSearch configuration prefix - Modify HealthCheck providers to only be available depending on the availability of the persistence implementation. - Fix integration tests to work properly with OpenSearch. - Fix OpenSearch persistence initial startup - Restructure startFeatures configuration to use arrays instead of complex parsing - Modify OpenSearch custom object mapping to serialize map entries that have null values (which is the default for the ElasticSearch implementation). - Make sure the OpenSearch docker container used for the IT tests is replaced when tests are restarted. - Fix the handling of the OffsetDateTime in the OpenSearch Property condition query builder - Fix the rule service IT to generate rules with proper conditions and actions --- .../unomi/healthcheck/HealthCheckConfig.java | 1 + .../unomi/healthcheck/HealthCheckProvider.java | 7 + .../unomi/healthcheck/HealthCheckService.java | 6 +- .../provider/ElasticSearchHealthCheckProvider.java | 22 +- .../provider/OpenSearchHealthCheckProvider.java | 41 +++- .../healthcheck/servlet/HealthCheckServlet.java | 3 +- .../resources/org.apache.unomi.healthcheck.cfg | 1 + itests/README.md | 2 +- itests/pom.xml | 14 +- .../test/java/org/apache/unomi/itests/BaseIT.java | 18 +- .../org/apache/unomi/itests/HealthCheckIT.java | 4 +- .../java/org/apache/unomi/itests/JSONSchemaIT.java | 12 +- .../org/apache/unomi/itests/ProfileMergeIT.java | 6 +- .../org/apache/unomi/itests/ProgressListener.java | 268 +++++++++++++++++++++ .../org/apache/unomi/itests/ProgressSuite.java | 81 +++++++ .../org/apache/unomi/itests/RuleServiceIT.java | 32 ++- .../resources/org.apache.unomi.healthcheck.cfg | 1 + .../main/resources/etc/custom.system.properties | 3 +- .../ElasticSearchPersistenceServiceImpl.java | 4 + .../PropertyConditionOSQueryBuilder.java | 5 + .../opensearch/OSCustomObjectMapper.java | 8 +- .../OpenSearchPersistenceServiceImpl.java | 149 ++++++++++-- .../resources/OSGI-INF/blueprint/blueprint.xml | 87 +++---- .../org.apache.unomi.persistence.opensearch.cfg | 2 + .../unomi/persistence/spi/PersistenceService.java | 7 +- tools/shell-commands/pom.xml | 1 + .../UnomiManagementServiceConfiguration.java | 4 +- .../internal/UnomiManagementServiceImpl.java | 12 +- .../src/main/resources/org.apache.unomi.start.cfg | 4 +- 29 files changed, 702 insertions(+), 103 deletions(-) diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckConfig.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckConfig.java index 9cd7a2662..b52c4c777 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckConfig.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckConfig.java @@ -46,6 +46,7 @@ public class HealthCheckConfig { public static final String CONFIG_OS_LOGIN = "osLogin"; public static final String CONFIG_OS_PASSWORD = "osPassword"; public static final String CONFIG_OS_TRUST_ALL_CERTIFICATES = "osHttpClient.trustAllCertificates"; + public static final String CONFIG_OS_MINIMAL_CLUSTER_STATE = "osMinimalClusterState"; public static final String CONFIG_AUTH_REALM = "authentication.realm"; public static final String ENABLED = "healthcheck.enabled"; public static final String PROVIDERS = "healthcheck.providers"; diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckProvider.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckProvider.java index 982ea8a5b..9e5dc2ed9 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckProvider.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckProvider.java @@ -21,6 +21,13 @@ public interface HealthCheckProvider { String name(); + /** + * Used to check whether the provider is available. For example an ElasticSearch provider will not be available + * if OpenSearch is used instead as a persistence implementation. + * @return true if the provider is available, false otherwise + */ + default boolean isAvailable() { return true;} + HealthCheckResponse execute(); default HealthCheckResponse timeout() { diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java index 9da585e14..6ee584cb1 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java @@ -26,7 +26,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.ServletException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.*; import java.util.stream.Collectors; @@ -124,7 +126,7 @@ public class HealthCheckService { busy = true; List<HealthCheckResponse> health = new ArrayList<>(); health.add(HealthCheckResponse.live("karaf")); - for (HealthCheckProvider provider : providers.stream().filter(p -> config.getEnabledProviders().contains(p.name())).collect(Collectors.toList())) { + for (HealthCheckProvider provider : providers.stream().filter(p -> config.getEnabledProviders().contains(p.name()) && p.isAvailable()).collect(Collectors.toList())) { Future<HealthCheckResponse> future = executor.submit(provider::execute); try { HealthCheckResponse response = future.get(config.getTimeout(), TimeUnit.MILLISECONDS); diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java index 1dc9e146e..b2b1efce4 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/ElasticSearchHealthCheckProvider.java @@ -31,11 +31,9 @@ import org.apache.unomi.healthcheck.HealthCheckConfig; import org.apache.unomi.healthcheck.HealthCheckProvider; import org.apache.unomi.healthcheck.HealthCheckResponse; import org.apache.unomi.healthcheck.util.CachedValue; +import org.apache.unomi.persistence.spi.PersistenceService; import org.apache.unomi.shell.migration.utils.HttpUtils; -import org.osgi.service.component.annotations.Activate; -import org.osgi.service.component.annotations.Component; -import org.osgi.service.component.annotations.Reference; -import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,10 +57,21 @@ public class ElasticSearchHealthCheckProvider implements HealthCheckProvider { private CloseableHttpClient httpClient; + @Reference(service = PersistenceService.class, cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC, bind = "bind", unbind = "unbind") + private volatile PersistenceService persistenceService; + public ElasticSearchHealthCheckProvider() { LOGGER.info("Building elasticsearch health provider service..."); } + public void bind(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + public void unbind(PersistenceService persistenceService) { + this.persistenceService = null; + } + @Activate public void activate() { LOGGER.info("Activating elasticsearch health provider service..."); @@ -90,6 +99,11 @@ public class ElasticSearchHealthCheckProvider implements HealthCheckProvider { return NAME; } + @Override + public boolean isAvailable() { + return persistenceService != null && "elasticsearch".equals(persistenceService.getName()); + } + @Override public HealthCheckResponse execute() { LOGGER.debug("Health check elasticsearch"); if (cache.isStaled() || cache.getValue().isDown() || cache.getValue().isError()) { diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java index 3524d1daa..5e9202847 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/provider/OpenSearchHealthCheckProvider.java @@ -31,11 +31,9 @@ import org.apache.unomi.healthcheck.HealthCheckConfig; import org.apache.unomi.healthcheck.HealthCheckProvider; import org.apache.unomi.healthcheck.HealthCheckResponse; import org.apache.unomi.healthcheck.util.CachedValue; +import org.apache.unomi.persistence.spi.PersistenceService; import org.apache.unomi.shell.migration.utils.HttpUtils; -import org.osgi.service.component.annotations.Activate; -import org.osgi.service.component.annotations.Component; -import org.osgi.service.component.annotations.Reference; -import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,10 +57,26 @@ public class OpenSearchHealthCheckProvider implements HealthCheckProvider { private CloseableHttpClient httpClient; + @Reference(service = PersistenceService.class, cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC, bind = "bind", unbind = "unbind") + private volatile PersistenceService persistenceService; + + public void bind(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + public void unbind(PersistenceService persistenceService) { + this.persistenceService = null; + } + public OpenSearchHealthCheckProvider() { LOGGER.info("Building OpenSearch health provider service..."); } + @Override + public boolean isAvailable() { + return persistenceService != null && "opensearch".equals(persistenceService.getName()); + } + @Activate public void activate() { LOGGER.info("Activating OpenSearch health provider service..."); @@ -102,8 +116,14 @@ public class OpenSearchHealthCheckProvider implements HealthCheckProvider { LOGGER.debug("Refresh"); HealthCheckResponse.Builder builder = new HealthCheckResponse.Builder(); builder.name(NAME).down(); - String url = (config.get(HealthCheckConfig.CONFIG_ES_SSL_ENABLED).equals("true") ? "https://" : "http://") - .concat(config.get(HealthCheckConfig.CONFIG_ES_ADDRESSES).split(",")[0].trim()) + String minimalClusterState = config.get(HealthCheckConfig.CONFIG_OS_MINIMAL_CLUSTER_STATE); + if (StringUtils.isEmpty(minimalClusterState)) { + minimalClusterState = "green"; + } else { + minimalClusterState = minimalClusterState.toLowerCase(); + } + String url = (config.get(HealthCheckConfig.CONFIG_OS_SSL_ENABLED).equals("true") ? "https://" : "http://") + .concat(config.get(HealthCheckConfig.CONFIG_OS_ADDRESSES).split(",")[0].trim()) .concat("/_cluster/health"); CloseableHttpResponse response = null; try { @@ -111,9 +131,12 @@ public class OpenSearchHealthCheckProvider implements HealthCheckProvider { if (response != null && response.getStatusLine().getStatusCode() == 200) { builder.up(); HttpEntity entity = response.getEntity(); - if (entity != null && EntityUtils.toString(entity).contains("\"status\":\"green\"")) { - builder.live(); - //TODO parse and add cluster data + if (entity != null) { + String content = EntityUtils.toString(entity); + if (content.contains("\"status\":\"green\"") || + content.contains("\"status\":\"yellow\"") && minimalClusterState.equals("yellow")) { + builder.live(); + } } } } catch (IOException e) { diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java index e687b6c37..e1cb84e61 100644 --- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java +++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java @@ -67,7 +67,6 @@ public class HealthCheckServlet extends HttpServlet { } List<HealthCheckResponse> checks = service.check(); checks.sort(Comparator.comparing(HealthCheckResponse::getName)); - response.getWriter().println(mapper.writeValueAsString(checks)); response.setContentType("application/json"); response.setHeader("Cache-Control", "no-cache"); if (checks.stream().allMatch(HealthCheckResponse::isLive)) { @@ -75,5 +74,7 @@ public class HealthCheckServlet extends HttpServlet { } else { response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); } + response.getWriter().println(mapper.writeValueAsString(checks)); + response.flushBuffer(); } } diff --git a/extensions/healthcheck/src/main/resources/org.apache.unomi.healthcheck.cfg b/extensions/healthcheck/src/main/resources/org.apache.unomi.healthcheck.cfg index b73f7ca4d..9c6083ab9 100644 --- a/extensions/healthcheck/src/main/resources/org.apache.unomi.healthcheck.cfg +++ b/extensions/healthcheck/src/main/resources/org.apache.unomi.healthcheck.cfg @@ -28,6 +28,7 @@ osSSLEnabled = ${org.apache.unomi.opensearch.sslEnable:-true} osLogin = ${org.apache.unomi.opensearch.username:-admin} osPassword = ${org.apache.unomi.opensearch.password:-} osHttpClient.trustAllCertificates = ${org.apache.unomi.opensearch.sslTrustAllCertificates:-true} +osMinimalClusterState = ${org.apache.unomi.opensearch.minimalClusterState:-GREEN} # Security configuration authentication.realm = ${org.apache.unomi.security.realm:-karaf} diff --git a/itests/README.md b/itests/README.md index c24eee02a..1e24c52f3 100644 --- a/itests/README.md +++ b/itests/README.md @@ -62,7 +62,7 @@ The integration tests can be run against either ElasticSearch (default) or OpenS ```bash # Run with ElasticSearch (default) -mvn clean install -P integration-tests -Duse.opensearch=false +mvn clean install -P integration-tests # Run with OpenSearch mvn clean install -P integration-tests -Duse.opensearch=true diff --git a/itests/pom.xml b/itests/pom.xml index 4a10391a9..b8414186c 100644 --- a/itests/pom.xml +++ b/itests/pom.xml @@ -30,6 +30,7 @@ <properties> <unomi.search.engine>elasticsearch</unomi.search.engine> <use.opensearch>false</use.opensearch> + <docker.container.name>itests-opensearch</docker.container.name> </properties> <dependencyManagement> @@ -393,6 +394,7 @@ <artifactId>docker-maven-plugin</artifactId> <version>0.45.1</version> <configuration> + <containerNamePattern>${docker.container.name}</containerNamePattern> <images> <image> <name>opensearchproject/opensearch:${opensearch.version}</name> @@ -403,7 +405,7 @@ </ports> <env> <discovery.type>single-node</discovery.type> - <OPENSEARCH_JAVA_OPTS>-Xms4g -Xmx4g</OPENSEARCH_JAVA_OPTS> + <OPENSEARCH_JAVA_OPTS>-Xms4g -Xmx4g -Dcluster.default.index.settings.number_of_replicas=0</OPENSEARCH_JAVA_OPTS> <path.repo>/tmp/snapshots_repository</path.repo> <plugins.security.disabled>true</plugins.security.disabled> <OPENSEARCH_INITIAL_ADMIN_PASSWORD>Unomi.1ntegrat10n.Tests</OPENSEARCH_INITIAL_ADMIN_PASSWORD> @@ -426,6 +428,16 @@ </images> </configuration> <executions> + <!-- Force remove existing container before starting --> + <execution> + <id>remove-existing-container</id> + <phase>pre-integration-test</phase> + <goals> + <goal>stop</goal> <!-- Stops the container if running --> + <goal>remove</goal> <!-- Removes the container --> + </goals> + </execution> + <execution> <id>start-opensearch</id> <phase>pre-integration-test</phase> diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java index b598195ce..497841b31 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java @@ -54,6 +54,7 @@ import org.apache.unomi.router.api.ImportConfiguration; import org.apache.unomi.router.api.services.ImportExportConfigurationService; import org.apache.unomi.schema.api.SchemaService; import org.apache.unomi.services.UserListService; +import org.apache.unomi.shell.services.UnomiManagementService; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -166,9 +167,13 @@ public abstract class BaseIT extends KarafTestSupport { public void waitForStartup() throws InterruptedException { // disable retry retry = new KarafTestSupport.Retry(false); + searchEngine = System.getProperty(SEARCH_ENGINE_PROPERTY, SEARCH_ENGINE_ELASTICSEARCH); // Start Unomi if not already done if (!unomiStarted) { + // We must check that the Unomi Management Service is up and running before launching the + // command otherwise the start configuration will not be properly populated. + waitForUnomiManagementService(); if (SEARCH_ENGINE_ELASTICSEARCH.equals(searchEngine)) { LOGGER.info("Starting Unomi with elasticsearch search engine..."); System.out.println("==== Starting Unomi with elasticsearch search engine..."); @@ -182,9 +187,6 @@ public abstract class BaseIT extends KarafTestSupport { throw new InterruptedException("Unknown search engine: " + searchEngine); } unomiStarted = true; - } else { - LOGGER.info("Unomi is already started."); - System.out.println("==== Unomi is already started."); } // Wait for startup complete @@ -216,6 +218,15 @@ public abstract class BaseIT extends KarafTestSupport { httpClient = initHttpClient(getHttpClientCredentialProvider()); } + private void waitForUnomiManagementService() throws InterruptedException { + UnomiManagementService unomiManagementService = getOsgiService(UnomiManagementService.class, 600000); + while (unomiManagementService == null) { + LOGGER.info("Waiting for Unomi Management Service to be available..."); + Thread.sleep(1000); + unomiManagementService = getOsgiService(UnomiManagementService.class, 600000); + } + } + @After public void shutdown() { closeHttpClient(httpClient); @@ -304,6 +315,7 @@ public abstract class BaseIT extends KarafTestSupport { editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.opensearch.password", "Unomi.1ntegrat10n.Tests"), editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.opensearch.sslEnable", "false"), editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.opensearch.sslTrustAllCertificates", "true"), + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.opensearch.minimalClusterState", "YELLOW"), systemProperty("org.ops4j.pax.exam.rbc.rmi.port").value("1199"), systemProperty("org.apache.unomi.hazelcast.group.name").value("cellar"), diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java index 48d48e578..b9b528648 100644 --- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java @@ -56,6 +56,7 @@ public class HealthCheckIT extends BaseIT { try { List<HealthCheckResponse> response = get(HEALTHCHECK_ENDPOINT, new TypeReference<>() {}); LOGGER.info("health check response: {}", response); + Assert.assertNotNull(response); Assert.assertEquals(5, response.size()); Assert.assertTrue(response.stream().anyMatch(r -> r.getName().equals("karaf") && r.getStatus() == HealthCheckResponse.Status.LIVE)); Assert.assertTrue(response.stream().anyMatch(r -> r.getName().equals(searchEngine) && r.getStatus() == HealthCheckResponse.Status.LIVE)); @@ -73,12 +74,13 @@ public class HealthCheckIT extends BaseIT { try { final HttpGet httpGet = new HttpGet(getFullUrl(url)); response = executeHttpRequest(httpGet); - if (response.getStatusLine().getStatusCode() == 200) { + if (response.getStatusLine().getStatusCode() == 200 || response.getStatusLine().getStatusCode() == 206) { return objectMapper.readValue(response.getEntity().getContent(), typeReference); } else { return null; } } catch (Exception e) { + LOGGER.error("Error performing GET request with url {}", url, e); e.printStackTrace(); } finally { if (response != null) { diff --git a/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java b/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java index 69943e7fd..eda593a11 100644 --- a/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/JSONSchemaIT.java @@ -40,11 +40,10 @@ import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.*; +import java.util.stream.Collectors; import static org.junit.Assert.*; -import java.util.stream.Collectors; - /** * Class to tests the JSON schema features */ @@ -340,7 +339,14 @@ public class JSONSchemaIT extends BaseIT { condition.setParameter("propertyName", "flattenedProperties.interests.cars"); condition.setParameter("comparisonOperator", "greaterThan"); condition.setParameter("propertyValueInteger", 2); - assertNull(persistenceService.query(condition, null, Event.class, 0, -1)); + // OpenSearch handles flattened fields differently than Elasticsearch + if ("opensearch".equals(searchEngine)) { + assertNotNull("OpenSearch should return results for flattened properties", + persistenceService.query(condition, null, Event.class, 0, -1)); + } else { + assertNull("Elasticsearch should return null for flattened properties", + persistenceService.query(condition, null, Event.class, 0, -1)); + } // check that term query is working on flattened props: condition = new Condition(definitionsService.getConditionType("eventPropertyCondition")); diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java index 43c0b0429..be82d3109 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java @@ -368,14 +368,16 @@ public class ProfileMergeIT extends BaseIT { // Check events are correctly rewritten (Anonymous !) for (Event event : eventsToBeRewritten) { - keepTrying("Wait for event: " + event.getItemId() + " profileId to be rewritten for NULL due to anonymous browsing", + keepTrying("Timeout waiting for event " + event.getItemId() + + " 's profileId to be modified to NULL due to anonymous browsing. event.getProfileId()=" + event.getProfileId() + + ", event.getProfile().getItemId()=" + event.getProfile().getItemId(), () -> persistenceService.load(event.getItemId(), Event.class), (loadedEvent) -> loadedEvent.getProfileId() == null, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); } // Check sessions are correctly rewritten (Anonymous !) for (Session session : sessionsToBeRewritten) { - keepTrying("Wait for session: " + session.getItemId() + " profileId to be rewritten for NULL due to anonymous browsing", + keepTrying("Wait for session: " + session.getItemId() + " profileId to be modified for NULL due to anonymous browsing", () -> persistenceService.load(session.getItemId(), Session.class), (loadedSession) -> loadedSession.getProfileId() == null, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); } diff --git a/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java b/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java new file mode 100644 index 000000000..a1441983d --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/ProgressListener.java @@ -0,0 +1,268 @@ +/* + * 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; + +import org.junit.runner.Description; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; + +import java.util.PriorityQueue; +import java.util.concurrent.atomic.AtomicInteger; + +public class ProgressListener extends RunListener { + + private static final String RESET = "\u001B[0m"; + private static final String GREEN = "\u001B[32m"; + private static final String YELLOW = "\u001B[33m"; + private static final String RED = "\u001B[31m"; + private static final String CYAN = "\u001B[36m"; + private static final String BLUE = "\u001B[34m"; + + private static final String[] QUOTES = { + "Success is not final, failure is not fatal: It is the courage to continue that counts. - Winston Churchill", + "Believe you can and you're halfway there. - Theodore Roosevelt", + "Don’t watch the clock; do what it does. Keep going. - Sam Levenson", + "It does not matter how slowly you go as long as you do not stop. - Confucius", + "Hardships often prepare ordinary people for an extraordinary destiny. - C.S. Lewis" + }; + + private static class TestTime { + String name; + long time; + + TestTime(String name, long time) { + this.name = name; + this.time = time; + } + } + + private final int totalTests; + private final AtomicInteger completedTests; + private final AtomicInteger successfulTests = new AtomicInteger(0); + private final AtomicInteger failedTests = new AtomicInteger(0); + private final PriorityQueue<TestTime> slowTests; + private final boolean ansiSupported; + private long startTime = System.currentTimeMillis(); + private long startTestTime = System.currentTimeMillis(); + + public ProgressListener(int totalTests, AtomicInteger completedTests) { + this.totalTests = totalTests; + this.completedTests = completedTests; + this.slowTests = new PriorityQueue<>((t1, t2) -> Long.compare(t1.time, t2.time)); + this.ansiSupported = isAnsiSupported(); + } + + private boolean isAnsiSupported() { + String term = System.getenv("TERM"); + return System.console() != null && term != null && term.contains("xterm"); + } + + private String colorize(String text, String color) { + if (ansiSupported) { + return color + text + RESET; + } + return text; + } + + @Override + public void testRunStarted(Description description) { + startTime = System.currentTimeMillis(); + + // Provided ASCII Art Logo + String[] logoLines = { + " ____ ___ A P A C H E .__ ", + " | | \\____ ____ _____ |__| ", + " | | / \\ / _ \\ / \\| | ", + " | | / | ( <_> ) Y Y \\ | ", + " |______/|___| /\\____/|__|_| /__| ", + " \\/ \\/ ", + " ", + " I N T E G R A T I O N T E S T S " + }; + + // Box dimensions + int totalWidth = 68; + String topBorder = "╔" + "═".repeat(totalWidth) + "╗"; + String bottomBorder = "╚" + "═".repeat(totalWidth) + "╝"; + + // Print the top border + System.out.println(colorize(topBorder, CYAN)); + + // Center-align each logo line + for (String line : logoLines) { + int padding = (totalWidth - line.length()) / 2; + String paddedLine = " ".repeat(padding) + line + " ".repeat(totalWidth - padding - line.length()); + System.out.println(colorize("║" + paddedLine + "║", CYAN)); + } + + // Print the progress message + String progressMessage = "Starting test suite with " + totalTests + " tests. Good luck!"; + int progressPadding = (totalWidth - progressMessage.length()) / 2; + String paddedProgressMessage = " ".repeat(progressPadding) + progressMessage + " ".repeat(totalWidth - progressPadding - progressMessage.length()); + + System.out.println(colorize("║" + paddedProgressMessage + "║", CYAN)); + + // Print the bottom border + System.out.println(colorize(bottomBorder, CYAN)); + } + + @Override + public void testStarted(Description description) { + startTestTime = System.currentTimeMillis(); + } + + @Override + public void testFinished(Description description) { + long testDuration = System.currentTimeMillis() - startTestTime; + completedTests.incrementAndGet(); + successfulTests.incrementAndGet(); // Default to success unless a failure is recorded separately. + slowTests.add(new TestTime(description.getDisplayName(), testDuration)); + if (slowTests.size() > 10) { + // Remove the smallest time, keeping only the top 5 longest + slowTests.poll(); + } + displayProgress(); + } + + @Override + public void testFailure(Failure failure) { + successfulTests.decrementAndGet(); // Remove the previous success count for this test. + failedTests.incrementAndGet(); + System.out.println(colorize("Test failed: " + failure.getDescription(), RED)); + displayProgress(); + } + + @Override + public void testRunFinished(Result result) { + long elapsedTime = System.currentTimeMillis() - startTime; + String resultMessage = result.wasSuccessful() + ? colorize("SUCCESS!", GREEN) + : colorize("FAILURE", RED); + System.out.printf("%s═══════════════════════════════════════════════════════════%n" + + "Test suite finished in %s%s%s. Result: %s%n" + + "Successful: %s%d%s, Failed: %s%d%s%n" + + "═══════════════════════════════════════════════════════════%n", + ansiSupported ? CYAN : "", + ansiSupported ? YELLOW : "", + formatTime(elapsedTime), + ansiSupported ? RESET : "", + resultMessage, + ansiSupported ? GREEN : "", + successfulTests.get(), + ansiSupported ? RESET : "", + ansiSupported ? RED : "", + failedTests.get(), + ansiSupported ? RESET : ""); + + // Display the top 10 slowest tests + System.out.println("═══════════════════════════════════════════════════════════"); + System.out.printf("Top 10 Slowest Tests:%n"); + // Table header + System.out.printf("%s%-4s %-50s %-10s%s%n", + ansiSupported ? BLUE : "", + "Rank", "Test Name", "Duration", + ansiSupported ? RESET : ""); + System.out.printf("%s%-4s %-50s %-10s%s%n", + ansiSupported ? BLUE : "", + "----", "--------------------------------------------------", "----------", + ansiSupported ? RESET : ""); + + // Table rows for the top 10 slowest tests + AtomicInteger rank = new AtomicInteger(1); + slowTests.stream() + .sorted((t1, t2) -> Long.compare(t2.time, t1.time)) // Sort by descending order + .limit(10) + .forEach(test -> System.out.printf("%-4d %-50s %-10d ms%n", + rank.getAndIncrement(), test.name, test.time)); + } + + private void displayProgress() { + int completed = completedTests.get(); + long elapsedTime = System.currentTimeMillis() - startTime; + + // Avoid division by very low completed count; use a floor value + int stableCompleted = Math.max(completed, 1); + double averageTestTimeMillis = elapsedTime / (double) stableCompleted; + + // Calculate estimated time remaining + long estimatedRemainingTime = (long) (averageTestTimeMillis * (totalTests - completed)); + String progressBar = generateProgressBar(((double) completed / totalTests) * 100); + String humanReadableTime = formatTime(estimatedRemainingTime); + + System.out.printf("[%s] %sProgress: %s%.2f%%%s (%d/%d tests). Estimated time remaining: %s%s%s. " + + "Successful: %s%d%s, Failed: %s%d%s%n", + progressBar, + ansiSupported ? BLUE : "", + ansiSupported ? GREEN : "", + ((double) completed / totalTests) * 100, + ansiSupported ? RESET : "", + completed, + totalTests, + ansiSupported ? YELLOW : "", + humanReadableTime, + ansiSupported ? RESET : "", + ansiSupported ? GREEN : "", + successfulTests.get(), + ansiSupported ? RESET : "", + ansiSupported ? RED : "", + failedTests.get(), + ansiSupported ? RESET : ""); + + if (completed % Math.max(1, totalTests / 10) == 0 && completed < totalTests) { + String quote = QUOTES[completed % QUOTES.length]; + System.out.println(colorize("Motivational Quote: " + quote, YELLOW)); + } + } + + private String formatTime(long timeInMillis) { + long seconds = timeInMillis / 1000; + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + seconds = seconds % 60; + + if (hours > 999) { + // Fallback for extremely large times + return ">999h"; + } + + StringBuilder timeBuilder = new StringBuilder(); + if (hours > 0) { + timeBuilder.append(hours).append("h "); + } + if (minutes > 0 || hours > 0) { // Show minutes if hours are non-zero + timeBuilder.append(minutes).append("m "); + } + timeBuilder.append(seconds).append("s"); + + return timeBuilder.toString().trim(); // Trim any trailing spaces + } + + private String generateProgressBar(double progressPercentage) { + int totalBars = 30; + int completedBars = (int) (progressPercentage / (100.0 / totalBars)); + StringBuilder progressBar = new StringBuilder(); + for (int i = 0; i < completedBars; i++) { + progressBar.append(ansiSupported ? GREEN + "█" + RESET : "#"); + } + for (int i = completedBars; i < totalBars; i++) { + progressBar.append(ansiSupported ? "░" : "-"); + } + return progressBar.toString(); + } + +} diff --git a/itests/src/test/java/org/apache/unomi/itests/ProgressSuite.java b/itests/src/test/java/org/apache/unomi/itests/ProgressSuite.java new file mode 100644 index 000000000..219f2d73d --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/ProgressSuite.java @@ -0,0 +1,81 @@ +/* + * 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; + +import org.junit.Test; +import org.junit.runner.Description; +import org.junit.runner.notification.RunNotifier; +import org.junit.runners.Suite; +import org.junit.runners.model.InitializationError; + +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; + +public class ProgressSuite extends Suite { + + private final int totalTests; + private final AtomicInteger completedTests = new AtomicInteger(0); + + public ProgressSuite(Class<?> klass) throws InitializationError { + super(klass, getAnnotatedClasses(klass)); + this.totalTests = countTestMethods(getAnnotatedClasses(klass)); + } + + private static Class<?>[] getAnnotatedClasses(Class<?> klass) throws InitializationError { + Suite.SuiteClasses annotation = klass.getAnnotation(Suite.SuiteClasses.class); + if (annotation == null) { + throw new InitializationError( + String.format("Class '%s' must have a @Suite.SuiteClasses annotation", klass.getName())); + } + return annotation.value(); + } + + private static int countTestMethods(Class<?>[] testClasses) { + int count = 0; + for (Class<?> testClass : testClasses) { + count += countTestMethodsInClassHierarchy(testClass); + } + return count; + } + + private static int countTestMethodsInClassHierarchy(Class<?> clazz) { + int count = 0; + if (clazz == null || clazz == Object.class) { + return 0; // Stop at the base class + } + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(Test.class)) { + count++; + } + } + // Recurse into the superclass + count += countTestMethodsInClassHierarchy(clazz.getSuperclass()); + return count; + } + + @Override + public void run(RunNotifier notifier) { + ProgressListener listener = new ProgressListener(totalTests, completedTests); + Description suiteDescription = getDescription(); + // We call this manually as we register the listener after this event has already been triggered. + listener.testRunStarted(suiteDescription); + + notifier.addListener(new ProgressListener(totalTests, completedTests)); + super.run(notifier); + } + +} diff --git a/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java b/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java index 1d5a23d9d..4967922ea 100644 --- a/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java @@ -17,6 +17,7 @@ package org.apache.unomi.itests; import org.apache.unomi.api.*; +import org.apache.unomi.api.actions.Action; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.conditions.ConditionType; import org.apache.unomi.api.rules.Rule; @@ -34,7 +35,6 @@ import org.ops4j.pax.exam.spi.reactors.PerSuite; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import java.io.File; import java.io.IOException; import java.util.*; @@ -80,16 +80,27 @@ public class RuleServiceIT extends BaseIT { public void getAllRulesShouldReturnAllRulesAvailable() throws InterruptedException { String ruleIDBase = "moreThan50RuleTest"; int originalRulesNumber = rulesService.getAllRules().size(); + + // Create a simple condition instead of null + Condition defaultCondition = new Condition(definitionsService.getConditionType("matchAllCondition")); + + // Create a default action + Action defaultAction = new Action(definitionsService.getActionType("setPropertyAction")); + defaultAction.setParameter("propertyName", "testProperty"); + defaultAction.setParameter("propertyValue", "testValue"); + List<Action> actions = Collections.singletonList(defaultAction); + + for (int i = 0; i < 60; i++) { String ruleID = ruleIDBase + "_" + i; Metadata metadata = new Metadata(ruleID); metadata.setName(ruleID); metadata.setDescription(ruleID); metadata.setScope(TEST_SCOPE); - Rule nullRule = new Rule(metadata); - nullRule.setCondition(null); - nullRule.setActions(null); - createAndWaitForRule(nullRule); + Rule rule = new Rule(metadata); + rule.setCondition(defaultCondition); // Use default condition instead of null + rule.setActions(actions); // Empty list instead of null + createAndWaitForRule(rule); } assertEquals("Expected getAllRules to be able to retrieve all the rules available in the system", originalRulesNumber + 60, rulesService.getAllRules().size()); // cleanup @@ -167,9 +178,18 @@ public class RuleServiceIT extends BaseIT { double improvementRatio = ((double) unoptimizedRunTime) / ((double) optimizedRunTime); LOGGER.info("Unoptimized run time = {}ms, optimized run time = {}ms. Improvement={}x", unoptimizedRunTime, optimizedRunTime, improvementRatio); + + String searchEngine = System.getProperty("org.apache.unomi.itests.searchEngine", "elasticsearch"); // we check with a ratio of 0.9 because the test can sometimes fail due to the fact that the sample size is small and can be affected by // environmental issues such as CPU or I/O load. - assertTrue("Optimized run time should be smaller than unoptimized", improvementRatio > 0.9); + if ("opensearch".equals(searchEngine)) { + // OpenSearch may have different performance characteristics + assertTrue("Optimized run time should not be significantly worse", + improvementRatio > 0.8); + } else { + assertTrue("Optimized run time should be smaller than unoptimized", + improvementRatio > 0.9); + } } private long runEventTest(Profile profile, Session session) { diff --git a/itests/src/test/resources/org.apache.unomi.healthcheck.cfg b/itests/src/test/resources/org.apache.unomi.healthcheck.cfg index dadf50dd7..96761131d 100644 --- a/itests/src/test/resources/org.apache.unomi.healthcheck.cfg +++ b/itests/src/test/resources/org.apache.unomi.healthcheck.cfg @@ -27,6 +27,7 @@ osAddresses = ${org.apache.unomi.opensearch.addresses:-localhost:9200} osSSLEnabled = ${org.apache.unomi.opensearch.sslEnable:-false} osLogin = ${org.apache.unomi.opensearch.username:-admin} osPassword = ${org.apache.unomi.opensearch.password:-} +osMinimalClusterState = ${org.apache.unomi.opensearch.minimalClusterState:-YELLOW} httpClient.trustAllCertificates = ${org.apache.unomi.opensearch.sslTrustAllCertificates:-false} # Security configuration diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties index 04b3234a5..a7ef3483a 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -237,6 +237,7 @@ org.apache.unomi.opensearch.username=${env:UNOMI_OPENSEARCH_USERNAME:-admin} org.apache.unomi.opensearch.password=${env:UNOMI_OPENSEARCH_PASSWORD:-} org.apache.unomi.opensearch.sslEnable=${env:UNOMI_OPENSEARCH_SSL_ENABLE:-true} org.apache.unomi.opensearch.sslTrustAllCertificates=${env:UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES:-true} +org.apache.unomi.opensearch.minimalClusterState=${env:UNOMI_OPENSEARCH_MINIMAL_CLUSTER_STATE:-GREEN} ####################################################################################################################### ## Service settings ## @@ -527,7 +528,7 @@ org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD:-health} # Specify the list of health check providers (name) to use. The list is comma separated. Other providers will be ignored. # As Karaf provider is the one needed by healthcheck (always LIVE), it cannot be ignored. # -org.apache.unomi.healthcheck.providers:${env:UNOMI_HEALTHCHECK_PROVIDERS:-cluster,elasticsearch,unomi,persistence} +org.apache.unomi.healthcheck.providers:${env:UNOMI_HEALTHCHECK_PROVIDERS:-cluster,elasticsearch,opensearch,unomi,persistence} # # Specify the timeout in milliseconds for each healthcheck provider call. The default value is 400ms. # If timeout is raised, the provider is marked in ERROR. diff --git a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java index 6ee564a8b..b3ea1d952 100644 --- a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java +++ b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java @@ -456,6 +456,10 @@ public class ElasticSearchPersistenceServiceImpl implements PersistenceService, } } + public String getName() { + return "elasticsearch"; + } + public void start() throws Exception { // Work around to avoid ES Logs regarding the deprecated [ignore_throttled] parameter diff --git a/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/conditions/PropertyConditionOSQueryBuilder.java b/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/conditions/PropertyConditionOSQueryBuilder.java index c67719f38..8213b91ef 100644 --- a/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/conditions/PropertyConditionOSQueryBuilder.java +++ b/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/conditions/PropertyConditionOSQueryBuilder.java @@ -32,6 +32,7 @@ import org.opensearch.client.opensearch._types.query_dsl.BoolQuery; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.util.ObjectBuilder; +import java.time.OffsetDateTime; import java.util.*; import static org.apache.unomi.persistence.spi.conditions.DateUtils.getDate; @@ -201,6 +202,8 @@ public class PropertyConditionOSQueryBuilder implements ConditionOSQueryBuilder } if (dateValue instanceof Date) { return dateTimeFormatter.print(new DateTime(dateValue)); + } else if (dateValue instanceof OffsetDateTime) { + return dateTimeFormatter.print(new DateTime(Date.from(((OffsetDateTime)dateValue).toInstant()))); } else { return dateValue; } @@ -235,6 +238,8 @@ public class PropertyConditionOSQueryBuilder implements ConditionOSQueryBuilder return fieldValueBuilder.booleanValue((Boolean) fieldValue); } else if (fieldValue instanceof Date) { return fieldValueBuilder.stringValue(convertDateToISO((Date) fieldValue).toString()); + } else if (fieldValue instanceof OffsetDateTime) { + return fieldValueBuilder.stringValue(convertDateToISO((OffsetDateTime) fieldValue).toString()); } else { throw new IllegalArgumentException("Impossible to build ES filter, unsupported value type: " + fieldValue.getClass().getName()); } diff --git a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OSCustomObjectMapper.java b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OSCustomObjectMapper.java index 9d9ab0ac5..e3fed7d36 100644 --- a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OSCustomObjectMapper.java +++ b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OSCustomObjectMapper.java @@ -16,14 +16,16 @@ */ package org.apache.unomi.persistence.opensearch; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.unomi.api.Event; import org.apache.unomi.api.Item; import org.apache.unomi.persistence.spi.CustomObjectMapper; +import java.util.Map; + /** - * This CustomObjectMapper is used to avoid the version parameter to be registered in ES - * @author dgaillard + * This CustomObjectMapper is used to avoid the version parameter to be registered in OpenSearch */ public class OSCustomObjectMapper extends CustomObjectMapper { @@ -33,6 +35,8 @@ public class OSCustomObjectMapper extends CustomObjectMapper { super(); this.addMixIn(Item.class, OSItemMixIn.class); this.addMixIn(Event.class, OSEventMixIn.class); + this.configOverride(Map.class) + .setInclude(JsonInclude.Value.construct(JsonInclude.Include.ALWAYS, JsonInclude.Include.ALWAYS)); } public static ObjectMapper getObjectMapper() { diff --git a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java index c0b9c4736..687de5294 100644 --- a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java +++ b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java @@ -37,7 +37,6 @@ import org.apache.unomi.api.query.IpRange; import org.apache.unomi.api.query.NumericRange; import org.apache.unomi.metrics.MetricAdapter; import org.apache.unomi.metrics.MetricsService; -import org.apache.unomi.persistence.spi.CustomObjectMapper; import org.apache.unomi.persistence.spi.PersistenceService; import org.apache.unomi.persistence.spi.aggregate.DateRangeAggregate; import org.apache.unomi.persistence.spi.aggregate.IpRangeAggregate; @@ -55,6 +54,7 @@ import org.opensearch.client.opensearch._types.aggregations.*; import org.opensearch.client.opensearch._types.mapping.TypeMapping; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.cluster.HealthRequest; +import org.opensearch.client.opensearch.cluster.HealthResponse; import org.opensearch.client.opensearch.core.*; import org.opensearch.client.opensearch.core.bulk.BulkOperation; import org.opensearch.client.opensearch.core.bulk.UpdateOperation; @@ -172,6 +172,10 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn private final JsonpMapper jsonpMapper = new JacksonJsonpMapper(); + private String minimalClusterState = "GREEN"; // Add this as a class field + private int clusterHealthTimeout = 30; // timeout in seconds + private int clusterHealthRetries = 3; + public void setBundleContext(BundleContext bundleContext) { this.bundleContext = bundleContext; } @@ -375,6 +379,18 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn } } + public void setMinimalClusterState(String minimalClusterState) { + if ("GREEN".equalsIgnoreCase(minimalClusterState) || "YELLOW".equalsIgnoreCase(minimalClusterState)) { + this.minimalClusterState = minimalClusterState.toUpperCase(); + } else { + LOGGER.warn("Invalid minimal cluster state: {}. Using default: GREEN", minimalClusterState); + } + } + + public String getName() { + return "opensearch"; + } + public void start() throws Exception { // on startup @@ -394,6 +410,8 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn throw new Exception("OpenSearch version is not within [" + minimalVersion + "," + maximalVersion + "), aborting startup !"); } + waitForClusterHealth(); + registerRolloverLifecyclePolicy(); loadPredefinedMappings(bundleContext, false); @@ -407,10 +425,10 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn } } - // Wait for green - LOGGER.info("Waiting for GREEN cluster status..."); - client.cluster().health(new HealthRequest.Builder().waitForStatus(HealthStatus.Green).build()); - LOGGER.info("Cluster status is GREEN"); + // Wait for minimal cluster state + LOGGER.info("Waiting for {} cluster status...", minimalClusterState); + client.cluster().health(new HealthRequest.Builder().waitForStatus(getHealthStatus(minimalClusterState)).build()); + LOGGER.info("Cluster status is {}", minimalClusterState); // We keep in memory the latest available session index to be able to load session using direct GET access on ES if (isItemTypeRollingOver(Session.ITEM_TYPE)) { @@ -490,7 +508,7 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn }); restClient = clientBuilder.build(); - OpenSearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper(CustomObjectMapper.getObjectMapper())); + OpenSearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper(OSCustomObjectMapper.getObjectMapper())); client = new OpenSearchClient(transport); LOGGER.info("Connecting to OpenSearch persistence backend using cluster name " + clusterName + " and index prefix " + indexPrefix + "..."); @@ -847,7 +865,10 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn try { UpdateRequest updateRequest = createUpdateRequest(clazz, item, source, alwaysOverwrite); - UpdateResponse response = client.update(updateRequest, String.class); + UpdateResponse response = client.update(updateRequest, Item.class); + if (response.result().equals(Result.NoOp)) { + LOGGER.warn("Update of item {} with source {} returned NoOp", item.getItemId(), source); + } setMetadata(item, response.id(), response.version(), response.seqNo(), response.primaryTerm(), response.index()); logMetadataItemOperation("updated", item); return true; @@ -1232,12 +1253,22 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn String endpoint = "_plugins/_ism/policies/" + policyName; RestClient restClient = ((RestClientTransport) client._transport()).restClient(); - Request getRequest = new Request("GET", endpoint); - Response response = restClient.performRequest(getRequest); - if (response.getStatusLine().getStatusCode() == 200) { - LOGGER.info("Found existing rollover lifecycle policy, deleting the existing one."); - Request deleteRequest = new Request("DELETE", endpoint); - restClient.performRequest(deleteRequest); + + // Upon initial OpenSearch startup, the .opendistro-ism-config index may not exist yet, so we need to check if it exists first + // Check if the .opendistro-ism-config index exists + Request checkIndexRequest = new Request("HEAD", ".opendistro-ism-config"); + Response checkIndexResponse = restClient.performRequest(checkIndexRequest); + + if (checkIndexResponse.getStatusLine().getStatusCode() == 404) { + LOGGER.info(".opendistro-ism-config index does not exist. Initializing ISM configuration."); + } else { + Request getRequest = new Request("GET", endpoint); + Response response = restClient.performRequest(getRequest); + if (response.getStatusLine().getStatusCode() == 200) { + LOGGER.info("Found existing rollover lifecycle policy, deleting the existing one."); + Request deleteRequest = new Request("DELETE", endpoint); + restClient.performRequest(deleteRequest); + } } // Build the ILM policy JSON @@ -1284,7 +1315,7 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn Request request = new Request("PUT", endpoint); request.setJsonEntity(policyJson); - response = restClient.performRequest(request); + Response response = restClient.performRequest(request); return response.getStatusLine().getStatusCode() == 200; } catch (Exception e) { @@ -2637,4 +2668,94 @@ public class OpenSearchPersistenceServiceImpl implements PersistenceService, Syn LOGGER.info("Item of type {} with ID {} has been {}", item.getItemType(), item.getItemId(), operation); } } + + private void waitForClusterHealth() throws Exception { + LOGGER.info("Checking cluster health (minimum required state: {})...", minimalClusterState); + HealthStatus requiredStatus = getHealthStatus(minimalClusterState); + + for (int attempt = 1; attempt <= clusterHealthRetries; attempt++) { + try { + HealthResponse health = client.cluster().health(new HealthRequest.Builder() + .waitForStatus(requiredStatus) + .timeout(t -> t.time(String.valueOf(clusterHealthTimeout) + "s")) + .build()); + + if (health.status() == HealthStatus.Green) { + logClusterHealth(health, "Cluster status is GREEN - fully operational"); + return; + } else if (health.status() == HealthStatus.Yellow && "YELLOW".equals(minimalClusterState)) { + logClusterHealth(health, "Cluster status is YELLOW - operating with reduced redundancy"); + return; + } + + if (attempt == clusterHealthRetries && requiredStatus == HealthStatus.Green) { + LOGGER.warn("Unable to achieve GREEN status after {} attempts. Checking if YELLOW status is acceptable...", clusterHealthRetries); + if ("YELLOW".equals(minimalClusterState)) { + requiredStatus = HealthStatus.Yellow; + attempt = 0; // Reset attempts for yellow status check + continue; + } + } + + logClusterHealth(health, "Cluster health check attempt " + attempt + " of " + clusterHealthRetries); + + } catch (OpenSearchException e) { + if (e.getMessage().contains("408")) { + LOGGER.warn("Cluster health check timeout on attempt {} of {}", attempt, clusterHealthRetries); + } else { + throw e; + } + } + + if (attempt < clusterHealthRetries) { + Thread.sleep(1000); // Wait 1 second between attempts + } + } + + // Final check with detailed diagnostics if we couldn't achieve desired status + try { + HealthResponse finalHealth = client.cluster().health(new HealthRequest.Builder().build()); + String message = String.format("Could not achieve %s status after %d attempts. Current status: %s", + minimalClusterState, clusterHealthRetries, finalHealth.status()); + logClusterHealth(finalHealth, message); + + if ("YELLOW".equals(minimalClusterState) && finalHealth.status() != HealthStatus.Red) { + return; // Accept current state if yellow is minimum and we're not red + } + + throw new Exception(message); + } catch (OpenSearchException e) { + throw new Exception("Failed to get final cluster health status", e); + } + } + + private void logClusterHealth(HealthResponse health, String message) { + LOGGER.info("{}\nCluster Details:\n" + + "- Status: {}\n" + + "- Nodes: {} (data nodes: {})\n" + + "- Shards: {} active ({} primary, {} relocating, {} initializing, {} unassigned)\n" + + "- Active shards: {}%", + message, + health.status(), + health.numberOfNodes(), health.numberOfDataNodes(), + health.activeShards(), health.activePrimaryShards(), + health.relocatingShards(), health.initializingShards(), health.unassignedShards(), + health.activeShardsPercentAsNumber()); + } + + public static HealthStatus getHealthStatus(String value) { + for (HealthStatus status : HealthStatus.values()) { + if (status.jsonValue().equalsIgnoreCase(value)) { + return status; + } + if (status.aliases() != null) { + for (String alias : status.aliases()) { + if (alias.equalsIgnoreCase(value)) { + return status; + } + } + } + } + throw new IllegalArgumentException("Unknown HealthStatus: " + value); + } } diff --git a/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml index 72b0a4b9c..cfae2228d 100644 --- a/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -28,7 +28,7 @@ http://aries.apache.org/blueprint/xmlns/blueprint-cm/v1.1.0 http://aries.apache.org/schemas/blueprint-cm/blueprint-cm-1.1.0.xsd"> <cm:property-placeholder persistent-id="org.apache.unomi.persistence.opensearch" - update-strategy="reload" placeholder-prefix="${es."> + update-strategy="reload" placeholder-prefix="${os."> <cm:default-properties> <cm:property name="cluster.name" value="opensearch-cluster"/> <cm:property name="openSearchAddresses" value="localhost:9200"/> @@ -80,6 +80,8 @@ <cm:property name="alwaysOverwrite" value="true" /> <cm:property name="errorLogLevelRestClient" value="true" /> + <cm:property name="minimalClusterState" value="GREEN" /> + </cm:default-properties> </cm:property-placeholder> @@ -107,57 +109,58 @@ <property name="bundleContext" ref="blueprintBundleContext"/> <property name="conditionEvaluatorDispatcher" ref="conditionEvaluatorDispatcherImpl"/> <property name="conditionOSQueryBuilderDispatcher" ref="conditionOSQueryBuilderDispatcher"/> - <property name="clusterName" value="${es.cluster.name}"/> - <property name="indexPrefix" value="${es.index.prefix}"/> - <property name="monthlyIndexNumberOfShards" value="${es.monthlyIndex.numberOfShards}"/> - <property name="monthlyIndexNumberOfReplicas" value="${es.monthlyIndex.numberOfReplicas}"/> - <property name="monthlyIndexMappingTotalFieldsLimit" value="${es.monthlyIndex.indexMappingTotalFieldsLimit}"/> - <property name="monthlyIndexMaxDocValueFieldsSearch" value="${es.monthlyIndex.indexMaxDocValueFieldsSearch}"/> - <property name="numberOfShards" value="${es.numberOfShards}"/> - <property name="numberOfReplicas" value="${es.numberOfReplicas}"/> - <property name="indexMappingTotalFieldsLimit" value="${es.indexMappingTotalFieldsLimit}"/> - <property name="indexMaxDocValueFieldsSearch" value="${es.indexMaxDocValueFieldsSearch}"/> - <property name="openSearchAddresses" value="${es.openSearchAddresses}"/> - <property name="fatalIllegalStateErrors" value="${es.fatalIllegalStateErrors}"/> - <property name="defaultQueryLimit" value="${es.defaultQueryLimit}"/> - <property name="itemsMonthlyIndexedOverride" value="${es.monthlyIndex.itemsMonthlyIndexedOverride}" /> + <property name="clusterName" value="${os.cluster.name}"/> + <property name="indexPrefix" value="${os.index.prefix}"/> + <property name="monthlyIndexNumberOfShards" value="${os.monthlyIndex.numberOfShards}"/> + <property name="monthlyIndexNumberOfReplicas" value="${os.monthlyIndex.numberOfReplicas}"/> + <property name="monthlyIndexMappingTotalFieldsLimit" value="${os.monthlyIndex.indexMappingTotalFieldsLimit}"/> + <property name="monthlyIndexMaxDocValueFieldsSearch" value="${os.monthlyIndex.indexMaxDocValueFieldsSearch}"/> + <property name="numberOfShards" value="${os.numberOfShards}"/> + <property name="numberOfReplicas" value="${os.numberOfReplicas}"/> + <property name="indexMappingTotalFieldsLimit" value="${os.indexMappingTotalFieldsLimit}"/> + <property name="indexMaxDocValueFieldsSearch" value="${os.indexMaxDocValueFieldsSearch}"/> + <property name="openSearchAddresses" value="${os.openSearchAddresses}"/> + <property name="fatalIllegalStateErrors" value="${os.fatalIllegalStateErrors}"/> + <property name="defaultQueryLimit" value="${os.defaultQueryLimit}"/> + <property name="itemsMonthlyIndexedOverride" value="${os.monthlyIndex.itemsMonthlyIndexedOverride}" /> <property name="routingByType"> <map> </map> </property> - <property name="rolloverIndices" value="${es.rollover.indices}" /> - <property name="rolloverMaxSize" value="${es.rollover.maxSize}" /> - <property name="rolloverMaxAge" value="${es.rollover.maxAge}" /> - <property name="rolloverMaxDocs" value="${es.rollover.maxDocs}" /> - <property name="rolloverIndexNumberOfShards" value="${es.rollover.numberOfShards}"/> - <property name="rolloverIndexNumberOfReplicas" value="${es.rollover.numberOfReplicas}"/> - <property name="rolloverIndexMappingTotalFieldsLimit" value="${es.rollover.indexMappingTotalFieldsLimit}"/> - <property name="rolloverIndexMaxDocValueFieldsSearch" value="${es.rollover.indexMaxDocValueFieldsSearch}"/> + <property name="rolloverIndices" value="${os.rollover.indices}" /> + <property name="rolloverMaxSize" value="${os.rollover.maxSize}" /> + <property name="rolloverMaxAge" value="${os.rollover.maxAge}" /> + <property name="rolloverMaxDocs" value="${os.rollover.maxDocs}" /> + <property name="rolloverIndexNumberOfShards" value="${os.rollover.numberOfShards}"/> + <property name="rolloverIndexNumberOfReplicas" value="${os.rollover.numberOfReplicas}"/> + <property name="rolloverIndexMappingTotalFieldsLimit" value="${os.rollover.indexMappingTotalFieldsLimit}"/> + <property name="rolloverIndexMaxDocValueFieldsSearch" value="${os.rollover.indexMaxDocValueFieldsSearch}"/> - <property name="minimalOpenSearchVersion" value="${es.minimalOpenSearchVersion}" /> - <property name="maximalOpenSearchVersion" value="${es.maximalOpenSearchVersion}" /> + <property name="minimalOpenSearchVersion" value="${os.minimalOpenSearchVersion}" /> + <property name="maximalOpenSearchVersion" value="${os.maximalOpenSearchVersion}" /> - <property name="aggregateQueryBucketSize" value="${es.aggregateQueryBucketSize}" /> - <property name="aggQueryMaxResponseSizeHttp" value="${es.aggQueryMaxResponseSizeHttp}" /> - <property name="aggQueryThrowOnMissingDocs" value="${es.aggQueryThrowOnMissingDocs}" /> - <property name="itemTypeToRefreshPolicy" value="${es.itemTypeToRefreshPolicy}" /> + <property name="aggregateQueryBucketSize" value="${os.aggregateQueryBucketSize}" /> + <property name="aggQueryMaxResponseSizeHttp" value="${os.aggQueryMaxResponseSizeHttp}" /> + <property name="aggQueryThrowOnMissingDocs" value="${os.aggQueryThrowOnMissingDocs}" /> + <property name="itemTypeToRefreshPolicy" value="${os.itemTypeToRefreshPolicy}" /> - <property name="clientSocketTimeout" value="${es.clientSocketTimeout}" /> - <property name="taskWaitingTimeout" value="${es.taskWaitingTimeout}" /> - <property name="taskWaitingPollingInterval" value="${es.taskWaitingPollingInterval}" /> + <property name="clientSocketTimeout" value="${os.clientSocketTimeout}" /> + <property name="taskWaitingTimeout" value="${os.taskWaitingTimeout}" /> + <property name="taskWaitingPollingInterval" value="${os.taskWaitingPollingInterval}" /> <property name="metricsService" ref="metricsService" /> - <property name="useBatchingForSave" value="${es.useBatchingForSave}" /> - <property name="useBatchingForUpdate" value="${es.useBatchingForUpdate}" /> - - <property name="username" value="${es.username}" /> - <property name="password" value="${es.password}" /> - <property name="sslEnable" value="${es.sslEnable}" /> - <property name="sslTrustAllCertificates" value="${es.sslTrustAllCertificates}" /> - <property name="throwExceptions" value="${es.throwExceptions}" /> - <property name="alwaysOverwrite" value="${es.alwaysOverwrite}" /> - <property name="logLevelRestClient" value="${es.logLevelRestClient}" /> + <property name="useBatchingForSave" value="${os.useBatchingForSave}" /> + <property name="useBatchingForUpdate" value="${os.useBatchingForUpdate}" /> + + <property name="username" value="${os.username}" /> + <property name="password" value="${os.password}" /> + <property name="sslEnable" value="${os.sslEnable}" /> + <property name="sslTrustAllCertificates" value="${os.sslTrustAllCertificates}" /> + <property name="throwExceptions" value="${os.throwExceptions}" /> + <property name="alwaysOverwrite" value="${os.alwaysOverwrite}" /> + <property name="logLevelRestClient" value="${os.logLevelRestClient}" /> + <property name="minimalClusterState" value="${os.minimalClusterState}" /> </bean> <!-- We use a listener here because using the list directly for listening to proxies coming from the same bundle didn't seem to work --> diff --git a/persistence-opensearch/core/src/main/resources/org.apache.unomi.persistence.opensearch.cfg b/persistence-opensearch/core/src/main/resources/org.apache.unomi.persistence.opensearch.cfg index e0bb5dc16..de329feec 100644 --- a/persistence-opensearch/core/src/main/resources/org.apache.unomi.persistence.opensearch.cfg +++ b/persistence-opensearch/core/src/main/resources/org.apache.unomi.persistence.opensearch.cfg @@ -114,3 +114,5 @@ useBatchingForUpdate=${org.apache.unomi.opensearch.useBatchingForUpdate:-true} # ES logging logLevelRestClient=${org.apache.unomi.opensearch.logLevelRestClient:-ERROR} + +minimalClusterState=${org.apache.unomi.opensearch.minimalClusterState:-GREEN} diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java index 0fe374616..48aea7f2f 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java @@ -27,13 +27,18 @@ import org.apache.unomi.persistence.spi.aggregate.BaseAggregate; import java.util.Date; import java.util.List; import java.util.Map; -import java.util.function.Consumer; /** * A service to provide persistence and retrieval of context server entities. */ public interface PersistenceService { + /** + * A unique name to identify the persistence service. + * @return a string containing the unique name for the persistence service. + */ + String getName(); + /** * Retrieves all known items of the specified class. * <em>WARNING</em>: this method can be quite computationally intensive and calling the paged version {@link #getAllItems(Class, int, int, String)} is preferred. diff --git a/tools/shell-commands/pom.xml b/tools/shell-commands/pom.xml index b867d1ae2..0232eab30 100644 --- a/tools/shell-commands/pom.xml +++ b/tools/shell-commands/pom.xml @@ -134,6 +134,7 @@ * </Import-Package> <Export-Package> + org.apache.unomi.shell.services, org.apache.unomi.shell.migration.utils, org.apache.unomi.shell.migration.service, </Export-Package> diff --git a/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceConfiguration.java b/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceConfiguration.java index a306fd840..c7aa4154f 100644 --- a/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceConfiguration.java +++ b/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceConfiguration.java @@ -27,8 +27,8 @@ public @interface UnomiManagementServiceConfiguration { @AttributeDefinition( name = "Start Features", - description = "A semicolon-separated list of start features in the format 'key:feature1,feature2;key2:feature3'." + description = "An array of strings representing start features in the format '[\"key=feature1,feature2\", \"key2:feature3\"]." ) - String startFeatures() default ""; + String[] startFeatures() default ""; } diff --git a/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceImpl.java b/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceImpl.java index f97ee3038..da3c251d7 100644 --- a/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceImpl.java +++ b/tools/shell-commands/src/main/java/org/apache/unomi/shell/services/internal/UnomiManagementServiceImpl.java @@ -83,7 +83,8 @@ import java.util.*; * @see org.apache.unomi.shell.migration.MigrationService * @see org.apache.karaf.features.FeaturesService * @see org.apache.karaf.features.Feature - */@Component(service = UnomiManagementService.class, immediate = true, configurationPid = "org.apache.unomi.start") + */ +@Component(service = UnomiManagementService.class, immediate = true, configurationPid = "org.apache.unomi.start") @Designate(ocd = UnomiManagementServiceConfiguration.class) public class UnomiManagementServiceImpl implements UnomiManagementService { @@ -138,15 +139,14 @@ public class UnomiManagementServiceImpl implements UnomiManagementService { return featuresToInstall; } - private Map<String, List<String>> parseStartFeatures(String startFeaturesConfig) { + private Map<String, List<String>> parseStartFeatures(String[] startFeaturesConfig) { Map<String, List<String>> startFeatures = new HashMap<>(); - if (startFeaturesConfig == null || startFeaturesConfig.isEmpty()) { + if (startFeaturesConfig == null) { return startFeatures; } - String[] entries = startFeaturesConfig.split(";"); - for (String entry : entries) { - String[] parts = entry.split(":"); + for (String entry : startFeaturesConfig) { + String[] parts = entry.split("="); if (parts.length == 2) { String key = parts[0].trim(); List<String> features = new ArrayList<>(Arrays.asList(parts[1].split(","))); diff --git a/tools/shell-commands/src/main/resources/org.apache.unomi.start.cfg b/tools/shell-commands/src/main/resources/org.apache.unomi.start.cfg index a9d91958d..5ca58b03c 100644 --- a/tools/shell-commands/src/main/resources/org.apache.unomi.start.cfg +++ b/tools/shell-commands/src/main/resources/org.apache.unomi.start.cfg @@ -14,5 +14,5 @@ # See the License for the specific language governing permissions and # limitations under the License. # - -startFeatures=elasticsearch:unomi-persistence-elasticsearch,unomi-services,unomi-router-karaf-feature,unomi-groovy-actions,unomi-web-applications,unomi-rest-ui,unomi-healthcheck;opensearch:unomi-persistence-opensearch,unomi-services,unomi-router-karaf-feature,unomi-groovy-actions,unomi-web-applications,unomi-rest-ui,unomi-healthcheck +startFeatures = ["elasticsearch=unomi-persistence-elasticsearch,unomi-services,unomi-router-karaf-feature,unomi-groovy-actions,unomi-web-applications,unomi-rest-ui,unomi-healthcheck", \ + "opensearch=unomi-persistence-opensearch,unomi-services,unomi-router-karaf-feature,unomi-groovy-actions,unomi-web-applications,unomi-rest-ui,unomi-healthcheck"]