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

Reply via email to