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 7f4c24ff9 UNOMI-1007: Restore health check environment variables (#936)
7f4c24ff9 is described below
commit 7f4c24ff941305f0df5f1b5f8c4011322c9fa5af
Author: Serge Huber <[email protected]>
AuthorDate: Sat Oct 31 09:33:32 2026 +0100
UNOMI-1007: Restore health check environment variables (#936)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
docker/README.md | 3 +
extensions/healthcheck/README.md | 30 ++++---
.../unomi/healthcheck/HealthCheckConfig.java | 98 ++++++++++++++++++++--
.../unomi/healthcheck/HealthCheckService.java | 20 +++++
.../unomi/healthcheck/HealthCheckConfigTest.java | 82 ++++++++++++++++++
.../HealthCheckEnvironmentMappingTest.java | 69 +++++++++++++++
.../unomi/healthcheck/HealthCheckServiceTest.java | 28 +++++++
.../org/apache/unomi/itests/HealthCheckIT.java | 89 ++++++++++++++++++++
manual/src/main/asciidoc/configuration.adoc | 39 +++++++--
manual/src/main/asciidoc/whats-new.adoc | 1 +
.../main/resources/etc/custom.system.properties | 6 ++
11 files changed, 437 insertions(+), 28 deletions(-)
diff --git a/docker/README.md b/docker/README.md
index 2334f3242..72fce9918 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -126,6 +126,9 @@ Note: Linux doesn't support the host.docker.internal DNS
lookup method yet, it s
### Common Variables
- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default
- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no
known default
+- `UNOMI_HEALTHCHECK_ENABLED`: Register `/health/check` (`true` by default).
`false` turns the endpoint off; authentication cannot be disabled
+- `UNOMI_HEALTHCHECK_PROVIDERS`: Comma-separated provider list (defaults come
from the cfg for Elasticsearch or OpenSearch)
+- `UNOMI_HEALTHCHECK_TIMEOUT`: Per-provider timeout in milliseconds (default
`400`, range 1–60000)
- `UNOMI_CLUSTER_NODEID`: Optional node id, unique per container. When unset,
Unomi generates one on the first start and stores it inside the container; set
this variable to keep the same id when the container is recreated
- `UNOMI_CLUSTER_NODENAME`: Optional user friendly node name (defaults to the
container host name); it is the prefix of a generated id
- `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf
(defaults to `true`)
diff --git a/extensions/healthcheck/README.md b/extensions/healthcheck/README.md
index 14ae4c62f..defee637c 100644
--- a/extensions/healthcheck/README.md
+++ b/extensions/healthcheck/README.md
@@ -17,17 +17,17 @@
# Health Check Extension
-The Health Check extension provides a simple health check endpoint that can be
used to determine if the server is up and running.
-The health check endpoint is available at
+The Health Check extension provides a simple health check endpoint that can be
used to determine if the server is up and running.
+The health check endpoint is available at
```
/health/check
-```
-and returns a simple JSON response that includes all health check provider
responses.
+```
+and returns a simple JSON response that includes all health check provider
responses.
-Basic Http Authentication is enabled by default for the health check endpoint.
The user needs to have the role `health` to access the endpoint. Users and
roles can be configured in the etc/users.properties file. The shipped `health`
user has no default password: its password comes from the
`UNOMI_HEALTHCHECK_PASSWORD` environment variable, which must be set before
starting. An empty password is never accepted.
+Basic Http Authentication is enabled by default for the health check endpoint.
The user needs to have the role `health` to access the endpoint. Users and
roles can be configured in the etc/users.properties file. The shipped `health`
user has no default password: its password comes from the
`UNOMI_HEALTHCHECK_PASSWORD` environment variable, which must be set before
starting. An empty password is never accepted.
-The healthcheck is available even if unomi is not started. It gives health
information about :
+The healthcheck is available even if unomi is not started. It gives health
information about:
- Karaf (as soon as the karaf container is started)
- ElasticSearch or OpenSearch (connection to ElasticSearch or OpenSearch
cluster and its health)
- Unomi (unomi bundles status)
@@ -40,11 +40,11 @@ All healthcheck can have a status :
- LIVE (service is ready to serve requests)
- ERROR (an error occurred during service health check)
-Any subsystem health check have a timeout of 500ms where check is cancelled
and will be returned as error.
+Any subsystem health check uses the configured timeout (default 400 ms); a
check that does not answer in time is cancelled and returned as an error.
-Typical response to /health/check when unomi NOT started is :
+Typical response to /health/check when unomi NOT started is :
-```json
+```json
[
{
"name":"karaf",
@@ -76,10 +76,14 @@ Typical response to /health/check when unomi NOT started is
:
## Configuration
-Configuration is located in the file etc/org.apache.unomi.healthcheck.cfg
+Configuration is located in the file `etc/org.apache.unomi.healthcheck.cfg`
(installed by the Elasticsearch or OpenSearch healthcheck feature).
-Extension can be disabled by setting the property `enabled` to `false`. An
environment variable can be used to set this property :
UNOMI_HEALTHCHECK_ENABLED
+| Property | Environment variable | Default | Notes |
+|---|---|---|---|
+| `healthcheck.enabled` | `UNOMI_HEALTHCHECK_ENABLED` | `true` | Any value
other than `true` unregisters the endpoint. Never disables authentication. |
+| `healthcheck.providers` | `UNOMI_HEALTHCHECK_PROVIDERS` |
distribution-specific (see cfg) | Comma-separated: `cluster`, `elasticsearch`
or `opensearch`, `unomi`, `persistence`, or the `name()` of a custom provider.
Names no installed bundle provides are skipped with a warning. `karaf` is
always reported. |
+| `healthcheck.timeout` | `UNOMI_HEALTHCHECK_TIMEOUT` | `400` | Milliseconds,
allowed range 1–60000. |
-By default, all healthcheck providers are included but the list of those
included providers can be customized by setting the property `providers` with a
comma separated list of provider names. An environment variable can be used to
set this property : UNOMI_HEALTHCHECK_PROVIDERS
+These environment variables map through `custom.system.properties` to
`org.apache.unomi.healthcheck.*`, which the cfg file resolves. An invalid
timeout falls back to the default above with a warning.
-The timeout used for each health check can be set by setting the property
`timeout` to the desired value in milliseconds. An environment variable can be
used to set this property : UNOMI_HEALTHCHECK_TIMEOUT
+Persistence backends that are not packaged with Unomi (PostgreSQL, for
example) register their own `HealthCheckProvider` and ship their own
healthcheck feature and cfg, like the Elasticsearch and OpenSearch ones. See
"Health check for another persistence backend" in the configuration manual.
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 19fce3170..8ba3422f1 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
@@ -25,11 +25,19 @@ import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
/**
- * Health check configuration.
+ * Health check configuration (UNOMI-1007). Values usually come from
+ * {@code etc/org.apache.unomi.healthcheck.cfg}, which resolves
+ * {@code org.apache.unomi.healthcheck.*} system properties — those can be set
from
+ * {@code UNOMI_HEALTHCHECK_ENABLED}, {@code UNOMI_HEALTHCHECK_PROVIDERS} and
+ * {@code UNOMI_HEALTHCHECK_TIMEOUT}. Any enabled value other than {@code
true} disables the endpoint;
+ * an invalid timeout falls back to the default with a warning.
+ * None of these settings can turn authentication off.
*/
@Component(immediate = true, service = HealthCheckConfig.class,
configurationPid = {"org.apache.unomi.healthcheck"})
public class HealthCheckConfig {
@@ -53,19 +61,93 @@ public class HealthCheckConfig {
public static final String PROVIDERS = "healthcheck.providers";
public static final String TIMEOUT = "healthcheck.timeout";
+ /** Default when enabled is missing. */
+ public static final boolean DEFAULT_ENABLED = true;
+ /** Default provider timeout in milliseconds. */
+ public static final int DEFAULT_TIMEOUT_MS = 400;
+ /** Lowest accepted timeout (milliseconds). */
+ public static final int MIN_TIMEOUT_MS = 1;
+ /** Highest accepted timeout (milliseconds). */
+ public static final int MAX_TIMEOUT_MS = 60_000;
+ /** JAAS realm used when the cfg does not set {@link #CONFIG_AUTH_REALM}.
*/
+ public static final String DEFAULT_AUTH_REALM = "karaf";
+
private Map<String, String> config = new HashMap<>();
- private boolean enabled = true;
+ private boolean enabled = DEFAULT_ENABLED;
private List<String> enabledProviders = new ArrayList<>();
- private int timeout = 400;
+ private int timeout = DEFAULT_TIMEOUT_MS;
@Activate
@Modified
public void modified(Map<String, String> config) {
- LOGGER.info("Updating healthcheck configuration, config size: {}",
config.size());
- this.setConfig(config);
- this.setEnabled(config.getOrDefault(ENABLED,
"true").equalsIgnoreCase("true"));
- this.setEnabledProviders(config.getOrDefault(PROVIDERS, "").isEmpty()
? new ArrayList<>() : List.of(config.get(PROVIDERS).split(",")));
- this.setTimeout(Integer.parseInt(config.getOrDefault(TIMEOUT, "400")));
+ LOGGER.info("Updating healthcheck configuration, config size: {}",
config == null ? 0 : config.size());
+ apply(config == null ? Map.of() : config);
+ }
+
+ /**
+ * Applies a configuration map with validation. Package-visible for unit
tests.
+ *
+ * @param config OSGi configuration entries; may be empty but not {@code
null}
+ */
+ void apply(Map<String, String> config) {
+ Map<String, String> values = new HashMap<>(config);
+ String realm = values.get(CONFIG_AUTH_REALM);
+ if (realm == null || realm.trim().isEmpty()) {
+ values.put(CONFIG_AUTH_REALM, DEFAULT_AUTH_REALM);
+ }
+ this.setConfig(values);
+ this.setEnabled(parseEnabled(config.get(ENABLED)));
+ this.setEnabledProviders(parseProviders(config.get(PROVIDERS)));
+ this.setTimeout(parseTimeout(config.get(TIMEOUT)));
+ }
+
+ static boolean parseEnabled(String raw) {
+ if (raw == null || raw.trim().isEmpty()) {
+ return DEFAULT_ENABLED;
+ }
+ String value = raw.trim().toLowerCase(Locale.ROOT);
+ if ("true".equals(value)) {
+ return true;
+ }
+ if ("false".equals(value)) {
+ return false;
+ }
+ LOGGER.warn("Invalid healthcheck.enabled value '{}'; disabling the
health check endpoint", raw);
+ return false;
+ }
+
+ static List<String> parseProviders(String raw) {
+ if (raw == null || raw.trim().isEmpty()) {
+ return new ArrayList<>();
+ }
+ LinkedHashSet<String> providers = new LinkedHashSet<>();
+ for (String part : raw.split(",")) {
+ String name = part.trim();
+ if (name.isEmpty()) {
+ continue;
+ }
+ providers.add(name);
+ }
+ return new ArrayList<>(providers);
+ }
+
+ static int parseTimeout(String raw) {
+ if (raw == null || raw.trim().isEmpty()) {
+ return DEFAULT_TIMEOUT_MS;
+ }
+ final int parsed;
+ try {
+ parsed = Integer.parseInt(raw.trim());
+ } catch (NumberFormatException e) {
+ LOGGER.warn("Ignoring invalid healthcheck.timeout value '{}';
using {}", raw, DEFAULT_TIMEOUT_MS);
+ return DEFAULT_TIMEOUT_MS;
+ }
+ if (parsed < MIN_TIMEOUT_MS || parsed > MAX_TIMEOUT_MS) {
+ LOGGER.warn("Ignoring healthcheck.timeout {} outside {}-{} ms;
using {}",
+ parsed, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS);
+ return DEFAULT_TIMEOUT_MS;
+ }
+ return parsed;
}
public String get(String configKey) {
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 9d13c4052..f3f7d25e9 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
@@ -30,6 +30,8 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -59,6 +61,8 @@ public class HealthCheckService {
private volatile boolean initialized = false;
private volatile boolean busy = false;
private boolean registered = false;
+ /** Configured provider names already reported as not registered, so the
warning is logged once. */
+ private final Set<String> missingProvidersReported =
ConcurrentHashMap.newKeySet();
@Reference
protected HttpService httpService;
@@ -80,6 +84,7 @@ public class HealthCheckService {
@Reference(service = HealthCheckConfig.class, policy =
ReferencePolicy.DYNAMIC, updated = "setConfig")
protected void setConfig(HealthCheckConfig config) throws
ServletException, NamespaceException {
this.config = config;
+ missingProvidersReported.clear();
if (httpService == null ) {
LOGGER.info("Healthcheck config with {} entrie(s) did not update
the service as not fully started yet.", config.getSize());
return;
@@ -120,6 +125,7 @@ public class HealthCheckService {
protected void bind(HealthCheckProvider provider) {
LOGGER.info("Binding provider {}", provider.name());
providers.add(provider);
+ missingProvidersReported.remove(provider.name());
}
protected void unbind(HealthCheckProvider provider) {
@@ -154,6 +160,19 @@ public class HealthCheckService {
return healthCache;
}
+ /**
+ * Logs once per name a configured provider that no bundle registers, for
example a typo or a
+ * backend-specific provider whose bundle is not installed (or not started
yet). It is skipped.
+ */
+ private void reportMissingProviders(List<HealthCheckProvider>
enabledProviders) {
+ for (String name : config.getEnabledProviders()) {
+ if (!"karaf".equals(name) && enabledProviders.stream().noneMatch(p
-> p.name().equals(name))
+ && missingProvidersReported.add(name)) {
+ LOGGER.warn("Healthcheck provider '{}' is configured but no
provider with that name is registered; skipping it", name);
+ }
+ }
+ }
+
private boolean shouldRefreshCache() {
return !busy && isCacheExpired();
}
@@ -169,6 +188,7 @@ public class HealthCheckService {
List<HealthCheckProvider> enabledProviders = providers.stream()
.filter(p ->
config.getEnabledProviders().contains(p.name()))
.toList();
+ reportMissingProviders(enabledProviders);
List<Future<HealthCheckResponse>> futures = new ArrayList<>();
for (HealthCheckProvider provider : enabledProviders) {
futures.add(executor.submit(provider::execute));
diff --git
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckConfigTest.java
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckConfigTest.java
new file mode 100644
index 000000000..d7a6eb525
--- /dev/null
+++
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckConfigTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.healthcheck;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HealthCheckConfigTest {
+
+ @Test
+ void enabledParsesTrueAndFalse() {
+ assertTrue(HealthCheckConfig.parseEnabled("true"));
+ assertTrue(HealthCheckConfig.parseEnabled("TRUE"));
+ assertFalse(HealthCheckConfig.parseEnabled("false"));
+ assertTrue(HealthCheckConfig.parseEnabled(null));
+ assertTrue(HealthCheckConfig.parseEnabled(""));
+ assertFalse(HealthCheckConfig.parseEnabled("no"));
+ }
+
+ @Test
+ void providersAreTrimmedDeduplicatedAndKeepCustomNames() {
+ assertEquals(List.of("cluster", "elasticsearch", "myCustomCheck"),
+ HealthCheckConfig.parseProviders("cluster, elasticsearch,,
myCustomCheck, cluster"));
+ assertEquals(List.of(), HealthCheckConfig.parseProviders(""));
+ assertEquals(List.of("karaf", "opensearch"),
+ HealthCheckConfig.parseProviders("karaf,opensearch"));
+ }
+
+ @Test
+ void timeoutRejectsOutOfBoundsAndInvalid() {
+ assertEquals(400, HealthCheckConfig.parseTimeout(null));
+ assertEquals(250, HealthCheckConfig.parseTimeout("250"));
+ assertEquals(400, HealthCheckConfig.parseTimeout("0"));
+ assertEquals(400, HealthCheckConfig.parseTimeout("999999"));
+ assertEquals(400, HealthCheckConfig.parseTimeout("nope"));
+ }
+
+ @Test
+ void applyMapsEnvironmentStyleKeys() {
+ HealthCheckConfig config = new HealthCheckConfig();
+ config.apply(Map.of(
+ HealthCheckConfig.ENABLED, "false",
+ HealthCheckConfig.PROVIDERS, "cluster,custom,persistence",
+ HealthCheckConfig.TIMEOUT, "1500"));
+
+ assertFalse(config.isEnabled());
+ assertEquals(List.of("cluster", "custom", "persistence"),
config.getEnabledProviders());
+ assertEquals(1500, config.getTimeout());
+ assertEquals(HealthCheckConfig.DEFAULT_AUTH_REALM,
config.get(HealthCheckConfig.CONFIG_AUTH_REALM));
+ }
+
+ @Test
+ void minimalBackendCfgOnlyNeedsTheProviderList() {
+ HealthCheckConfig config = new HealthCheckConfig();
+ config.apply(Map.of(HealthCheckConfig.PROVIDERS,
"cluster,postgresql,unomi,persistence"));
+
+ assertTrue(config.isEnabled());
+ assertEquals(List.of("cluster", "postgresql", "unomi", "persistence"),
config.getEnabledProviders());
+ assertEquals(HealthCheckConfig.DEFAULT_TIMEOUT_MS,
config.getTimeout());
+ assertEquals("karaf", config.get(HealthCheckConfig.CONFIG_AUTH_REALM));
+ }
+}
diff --git
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckEnvironmentMappingTest.java
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckEnvironmentMappingTest.java
new file mode 100644
index 000000000..a3dd00992
--- /dev/null
+++
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckEnvironmentMappingTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.healthcheck;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Checks the chain UNOMI_HEALTHCHECK_* environment variable →
org.apache.unomi.healthcheck.* system
+ * property (custom.system.properties) → healthcheck.* key in both shipped cfg
files (UNOMI-1007).
+ */
+class HealthCheckEnvironmentMappingTest {
+
+ private static final String CUSTOM_SYSTEM_PROPERTIES =
"package/src/main/resources/etc/custom.system.properties";
+ private static final List<String> CFG_FILES = List.of(
+
"extensions/healthcheck/src/main/resources/org.apache.unomi.healthcheck-elasticsearch.cfg",
+
"extensions/healthcheck/src/main/resources/org.apache.unomi.healthcheck-opensearch.cfg");
+
+ @ParameterizedTest
+ @CsvSource({"ENABLED,enabled", "PROVIDERS,providers", "TIMEOUT,timeout"})
+ void environmentVariableReachesTheHealthCheckConfiguration(String
variable, String key) throws IOException {
+ Path root = locateRepoRoot();
+ String systemProperty = "org.apache.unomi.healthcheck." + key;
+
+ String mapping = systemProperty + "=${env:UNOMI_HEALTHCHECK_" +
variable + ":-";
+
assertTrue(Files.readAllLines(root.resolve(CUSTOM_SYSTEM_PROPERTIES)).stream().anyMatch(l
-> l.startsWith(mapping)),
+ CUSTOM_SYSTEM_PROPERTIES + " should map UNOMI_HEALTHCHECK_" +
variable + " to " + systemProperty);
+
+ String cfgKey = "healthcheck." + key;
+ for (String cfg : CFG_FILES) {
+ assertTrue(Files.readAllLines(root.resolve(cfg)).stream()
+ .map(l -> l.replace(" ", ""))
+ .anyMatch(l -> l.startsWith(cfgKey + "=${" +
systemProperty + ":-")),
+ cfg + " should resolve " + cfgKey + " from " +
systemProperty);
+ }
+ }
+
+ private static Path locateRepoRoot() {
+ Path start = Paths.get("").toAbsolutePath().normalize();
+ for (Path candidate = start; candidate != null; candidate =
candidate.getParent()) {
+ if
(Files.isRegularFile(candidate.resolve(CUSTOM_SYSTEM_PROPERTIES))) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("Could not locate the Unomi repository
root from " + start);
+ }
+}
diff --git
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
index c4ebdfc9b..0fe4e0bb3 100644
---
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
+++
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
@@ -71,6 +71,34 @@ class HealthCheckServiceTest {
assertEquals("timeout", blocked.getData().get("error.cause"));
}
+ @Test
+ void disabledServiceReturnsNoResults() throws Exception {
+ HealthCheckConfig config = new HealthCheckConfig();
+ config.setEnabled(false);
+ config.setEnabledProviders(List.of("fast"));
+ config.setTimeout(TIMEOUT);
+ service.setConfig(config);
+ service.bind(new TestProvider("fast", () ->
HealthCheckResponse.live("fast")));
+
+ assertEquals(List.of(), service.check());
+ }
+
+ @Test
+ void externalBackendProviderRunsAndUnregisteredNamesAreSkipped() throws
Exception {
+ HealthCheckConfig config = new HealthCheckConfig();
+ config.setEnabledProviders(List.of("postgresql", "notInstalled"));
+ config.setTimeout(TIMEOUT);
+ service.setConfig(config);
+ service.bind(new TestProvider("postgresql", () ->
HealthCheckResponse.live("postgresql")));
+ service.bind(new TestProvider("elasticsearch", () ->
HealthCheckResponse.live("elasticsearch")));
+
+ List<HealthCheckResponse> health = service.check();
+
+ assertEquals(2, health.size());
+ assertTrue(response(health, "karaf").isLive());
+ assertTrue(response(health, "postgresql").isLive());
+ }
+
@Test
void secondCallWithinOneSecondReturnsTheCachedResults() {
TestProvider fast = new TestProvider("fast", () ->
HealthCheckResponse.live("fast"));
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 f7179a7f4..6fcf81798 100644
--- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
@@ -34,8 +34,13 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.Hashtable;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
import java.util.concurrent.*;
import static org.junit.Assert.fail;
@@ -55,6 +60,7 @@ public class HealthCheckIT extends BaseIT {
private final static Logger LOGGER =
LoggerFactory.getLogger(HealthCheckIT.class);
protected static final String HEALTHCHECK_ENDPOINT = "/health/check";
+ private static final String HEALTHCHECK_PID =
"org.apache.unomi.healthcheck";
@Test
public void testHealthCheck() {
@@ -139,6 +145,89 @@ public class HealthCheckIT extends BaseIT {
}
}
+ @Test
+ public void testHealthCheck_disabledUnregistersEndpoint() throws Exception
{
+ Dictionary<String, Object> original = healthCheckConfiguration();
+ try {
+ updateHealthCheckConfiguration(original,
Map.of("healthcheck.enabled", "false"));
+ keepTrying("/health/check should be unregistered when the health
check is disabled",
+ () -> healthCheckStatus(AuthType.CUSTOM_BASIC,
HEALTHCHECK_AUTH_PASSWORD), status -> status == 404,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertNotEquals(200, healthCheckStatus(AuthType.NONE,
null));
+ } finally {
+ restoreHealthCheckConfiguration(original);
+ }
+ }
+
+ @Test
+ public void
testHealthCheck_reducedProviderListAndCredentialsStillRequired() throws
Exception {
+ Dictionary<String, Object> original = healthCheckConfiguration();
+ try {
+ updateHealthCheckConfiguration(original,
Map.of("healthcheck.providers", "unomi,persistence,notInstalled"));
+ keepTrying("Health check should only report karaf and the
configured providers",
+ () -> probeNames(get(HEALTHCHECK_ENDPOINT, new
TypeReference<List<HealthCheckResponse>>() {})),
+ names -> Set.of("karaf", "unomi",
"persistence").equals(names),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertEquals(401, healthCheckStatus(AuthType.NONE, null));
+ Assert.assertEquals(401, healthCheckStatus(AuthType.CUSTOM_BASIC,
"wrong-password"));
+ } finally {
+ restoreHealthCheckConfiguration(original);
+ }
+ }
+
+ private Dictionary<String, Object> healthCheckConfiguration() throws
IOException {
+ Dictionary<String, Object> properties =
configurationAdmin.getConfiguration(HEALTHCHECK_PID, null).getProperties();
+ Assert.assertNotNull("Missing " + HEALTHCHECK_PID + " configuration",
properties);
+ Dictionary<String, Object> copy = new Hashtable<>();
+ for (Enumeration<String> keys = properties.keys();
keys.hasMoreElements(); ) {
+ String key = keys.nextElement();
+ copy.put(key, properties.get(key));
+ }
+ return copy;
+ }
+
+ private void updateHealthCheckConfiguration(Dictionary<String, Object>
original, Map<String, String> changes) throws IOException {
+ Dictionary<String, Object> updated = new Hashtable<>();
+ for (Enumeration<String> keys = original.keys();
keys.hasMoreElements(); ) {
+ String key = keys.nextElement();
+ updated.put(key, original.get(key));
+ }
+ changes.forEach(updated::put);
+ configurationAdmin.getConfiguration(HEALTHCHECK_PID,
null).update(updated);
+ }
+
+ private void restoreHealthCheckConfiguration(Dictionary<String, Object>
original) throws IOException, InterruptedException {
+ configurationAdmin.getConfiguration(HEALTHCHECK_PID,
null).update(original);
+ keepTrying("Health check should be back to its original configuration",
+ () -> get(HEALTHCHECK_ENDPOINT, new
TypeReference<List<HealthCheckResponse>>() {}),
+ response -> {
+ try {
+ assertHealthCheckLive(response);
+ return true;
+ } catch (AssertionError e) {
+ return false;
+ }
+ },
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ }
+
+ private int healthCheckStatus(AuthType authType, String password) {
+ try {
+ HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT));
+ try (CloseableHttpResponse response = executeHttpRequest(httpGet,
authType,
+ authType == AuthType.NONE ? null :
HEALTHCHECK_AUTH_USER_NAME, password)) {
+ return response.getStatusLine().getStatusCode();
+ }
+ } catch (Exception e) {
+ LOGGER.warn("Health check request failed", e);
+ return -1;
+ }
+ }
+
+ private static Set<String> probeNames(List<HealthCheckResponse> response) {
+ return response == null ? null :
response.stream().map(HealthCheckResponse::getName).collect(Collectors.toSet());
+ }
+
protected <T> T get(final String url, TypeReference<T> typeReference) {
CloseableHttpResponse response = null;
try {
diff --git a/manual/src/main/asciidoc/configuration.adoc
b/manual/src/main/asciidoc/configuration.adoc
index c96978bdd..a5803449e 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -1987,7 +1987,7 @@ Existing health checks give information about:
* Persistence (Unomi binding to the search/persistence backend)
* Cluster health (unomi cluster status and nodes information)
-Set `UNOMI_HEALTHCHECK_PROVIDERS` (or `providers` in the cfg file) to the
comma-separated list that matches your deployment, for example
`karaf,elasticsearch,unomi,persistence,cluster` or
`karaf,opensearch,unomi,persistence,cluster`.
+Set `UNOMI_HEALTHCHECK_PROVIDERS` (or `healthcheck.providers` in the cfg file)
to the comma-separated list that matches your deployment, for example
`cluster,elasticsearch,unomi,persistence` or
`cluster,opensearch,unomi,persistence`. The `karaf` probe is always reported
and does not need to be listed.
All healthcheck can have a status :
- DOWN (service is not available)
@@ -1995,7 +1995,7 @@ All healthcheck can have a status :
- LIVE (service is ready to serve requests)
- ERROR (an error occurred during service health check)
-Any subsystem health check have a timeout of 400ms where check is cancelled
and will be returned as error.
+Any subsystem health check uses the configured timeout (default 400 ms); a
check that does not answer in time is cancelled and returned as an error.
Typical response to /health/check when unomi NOT started is :
@@ -2044,15 +2044,40 @@ public interface HealthCheckProvider {
Calls to provider are supposed to be done at a regular rate (every 15 seconds
for example) and should be fast to execute. Feel free to include any caching
strategy if needed.
+===== Health check for another persistence backend
+
+A persistence backend that is not packaged with Unomi (PostgreSQL, for
example) integrates with the health check the same way as Elasticsearch and
OpenSearch:
+
+. Register a `HealthCheckProvider` OSGi service whose `name()` is the backend
id (for example `postgresql`). Import `org.apache.unomi.healthcheck` from the
healthcheck bundle. The generic `persistence` provider already works with any
`PersistenceService`.
+. Ship a Karaf feature (for example `unomi-healthcheck-postgresql`) with the
healthcheck bundle and a `configfile` for
`/etc/org.apache.unomi.healthcheck.cfg`. Only the provider list is required;
`healthcheck.enabled`, `healthcheck.timeout` and `authentication.realm` default
to `true`, `400` and `karaf`. Keep the placeholders so the
`UNOMI_HEALTHCHECK_*` variables still apply:
++
+[source]
+----
+healthcheck.enabled = ${org.apache.unomi.healthcheck.enabled:-true}
+healthcheck.providers =
${org.apache.unomi.healthcheck.providers:-cluster,postgresql,unomi,persistence}
+healthcheck.timeout = ${org.apache.unomi.healthcheck.timeout:-400}
+authentication.realm = ${org.apache.unomi.security.realm:-karaf}
+----
+. Use that feature instead of `unomi-healthcheck-elasticsearch` or
`unomi-healthcheck-opensearch` in the backend's distribution feature.
+
+The Elasticsearch and OpenSearch providers stay inactive when they are not
listed, so they do not report errors on other backends.
+
==== Configuration
Healthcheck extension configuration is located in the file
etc/org.apache.unomi.healthcheck.cfg
-Extension can be enabled by setting the property `enabled` to `true`. An
environment variable can be used to set this property :
UNOMI_HEALTHCHECK_ENABLED.
-You must restart the bundle for that config to take effect.
+Extension can be enabled by setting the property `healthcheck.enabled` to
`true` (default).
+Environment variable: `UNOMI_HEALTHCHECK_ENABLED` (maps to
`org.apache.unomi.healthcheck.enabled`).
+Any other value than `true` (for example `false`) unregisters `/health/check`.
It never makes the endpoint answer without authentication.
-By default, all healthcheck providers are included but the list of those
included providers can be customized by setting the property `providers` with a
comma separated list of provider names. An environment variable can be used to
set this property : UNOMI_HEALTHCHECK_PROVIDERS.
-Karaf provider is the one needed by healthcheck (always LIVE), it cannot be
ignored.
+By default the cfg file lists the providers for the active distribution
+(`cluster,elasticsearch,unomi,persistence` or
`cluster,opensearch,unomi,persistence`).
+Override with `healthcheck.providers` or `UNOMI_HEALTHCHECK_PROVIDERS`
+(maps to `org.apache.unomi.healthcheck.providers`). Names must match the
`name()` of a registered provider, including custom ones. A name that no
installed bundle provides is skipped with a warning.
+The `karaf` probe is always reported as LIVE and does not need to be listed.
-The timeout used for each health check can be set by setting the property
`timeout` to the desired value in milliseconds. An environment variable can be
used to set this property : UNOMI_HEALTHCHECK_TIMEOUT
+The per-provider timeout is `healthcheck.timeout` in milliseconds (default
`400`, allowed range 1–60000).
+Environment variable: `UNOMI_HEALTHCHECK_TIMEOUT` (maps to
`org.apache.unomi.healthcheck.timeout`).
+Out-of-range or invalid values fall back to `400` with a warning.
+None of these settings can turn authentication off; a blank health-check
password is still refused.
diff --git a/manual/src/main/asciidoc/whats-new.adoc
b/manual/src/main/asciidoc/whats-new.adoc
index a511248d2..df268e6fa 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -25,6 +25,7 @@ Complete tenant isolation for profiles, events, segments,
rules, and schemas. Pu
* Migrating from Unomi 2.x: <<_v2_compatibility_mode,single-tenant
compatibility mode>> (`singletenantcompatibility.enabled` in
`org.apache.unomi.rest.authentication.cfg`)
* Public and GraphQL API key checks no longer read every tenant on each
request, and refuse revoked and expired keys
(https://issues.apache.org/jira/browse/UNOMI-1016[UNOMI-1016])
* JAAS admin requests without `X-Unomi-Tenant-Id` use a configurable default
tenant (default `default`), so 3.0 admin scripts keep seeing migrated data
(https://issues.apache.org/jira/browse/UNOMI-1008[UNOMI-1008])
+* Health check environment variables `UNOMI_HEALTHCHECK_ENABLED`,
`UNOMI_HEALTHCHECK_PROVIDERS` and `UNOMI_HEALTHCHECK_TIMEOUT` work again
(https://issues.apache.org/jira/browse/UNOMI-1007[UNOMI-1007])
==== Security hardening (credentials, profile binding, privileged APIs)
diff --git a/package/src/main/resources/etc/custom.system.properties
b/package/src/main/resources/etc/custom.system.properties
index 524aaaf8c..7960fa1ea 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -29,6 +29,12 @@ ${optionals}=unomi.custom.system.properties
# if you set the matching property another way.
org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD}
org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD}
+# Health check endpoint (UNOMI-1007). Resolved by
etc/org.apache.unomi.healthcheck.cfg.
+# Disabling the endpoint never turns authentication off. Empty PROVIDERS keeps
the cfg default
+# for the active distribution (Elasticsearch or OpenSearch).
+org.apache.unomi.healthcheck.enabled=${env:UNOMI_HEALTHCHECK_ENABLED:-true}
+org.apache.unomi.healthcheck.providers=${env:UNOMI_HEALTHCHECK_PROVIDERS:-}
+org.apache.unomi.healthcheck.timeout=${env:UNOMI_HEALTHCHECK_TIMEOUT:-400}
# These parameters control the list of classes that are allowed or forbidden
when executing expressions.
# The forbid list is applied first. No org.mvel2 class is on the allow list,
so expressions cannot name