This is an automated email from the ASF dual-hosted git repository.

oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 5bcb48b79755 CAMEL-24751: camel-opa - unregister the OpaSecurityPolicy 
health check when its routes stop (#26667)
5bcb48b79755 is described below

commit 5bcb48b797550cbf20b98d5a9c0466162a36bcce
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 21 18:40:44 2026 +0200

    CAMEL-24751: camel-opa - unregister the OpaSecurityPolicy health check when 
its routes stop (#26667)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../component/opa/security/OpaSecurityPolicy.java  |  48 +++++++-
 .../opa/security/OpaSecurityProcessor.java         |  15 +++
 .../OpaSecurityPolicyHealthCheckLifecycleTest.java | 128 +++++++++++++++++++++
 3 files changed, 185 insertions(+), 6 deletions(-)

diff --git 
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
 
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
index a8dfc21cb742..54054bad0361 100644
--- 
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
+++ 
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
@@ -76,6 +76,8 @@ public class OpaSecurityPolicy implements AuthorizationPolicy 
{
     private volatile OpaSecurityPolicyHealthCheck healthCheck;
     private volatile boolean ownsClient;
     private volatile SSLContext sslContext;
+    private volatile CamelContext camelContext;
+    private int activeProcessors;
 
     public OpaSecurityPolicy() {
     }
@@ -87,6 +89,7 @@ public class OpaSecurityPolicy implements AuthorizationPolicy 
{
 
     @Override
     public void beforeWrap(Route route, NamedNode definition) {
+        this.camelContext = route.getCamelContext();
         if (evaluator == null) {
             StringHelper.notEmpty(policyPath, "policyPath", this);
             OpaHttpClient transport = null;
@@ -109,9 +112,9 @@ public class OpaSecurityPolicy implements 
AuthorizationPolicy {
                 throw new RuntimeCamelException("Could not register the 
evaluator for policy " + policyPath, e);
             }
         }
-        // after validation, so a policy that is missing its policyPath fails 
without leaving a ".../null" check
-        // behind in the registry
-        registerHealthCheck(route);
+        // The health check is registered and unregistered from the wrapped 
processors' lifecycle
+        // (onProcessorStart/onProcessorStop), not here: beforeWrap does not 
run again when a route is merely
+        // restarted, so a check registered here would be left behind when the 
guarded routes stop (CAMEL-24751).
     }
 
     /**
@@ -122,11 +125,12 @@ public class OpaSecurityPolicy implements 
AuthorizationPolicy {
      * may never talk to - hence {@code ownsClient} rather than a null check 
on {@code opaClient}, which by the time
      * this runs is set either way.
      */
-    private void registerHealthCheck(Route route) {
-        if (!healthCheckEnabled || healthCheck != null || !ownsClient || 
ObjectHelper.isEmpty(serverUrl)) {
+    private synchronized void registerHealthCheck() {
+        if (!healthCheckEnabled || healthCheck != null || !ownsClient || 
ObjectHelper.isEmpty(serverUrl)
+                || camelContext == null) {
             return;
         }
-        HealthCheckRegistry registry = 
HealthCheckRegistry.get(route.getCamelContext());
+        HealthCheckRegistry registry = HealthCheckRegistry.get(camelContext);
         if (registry == null) {
             return;
         }
@@ -134,6 +138,38 @@ public class OpaSecurityPolicy implements 
AuthorizationPolicy {
         registry.register(healthCheck);
     }
 
+    private synchronized void unregisterHealthCheck() {
+        if (healthCheck == null || camelContext == null) {
+            return;
+        }
+        HealthCheckRegistry registry = HealthCheckRegistry.get(camelContext);
+        if (registry != null) {
+            registry.unregister(healthCheck);
+        }
+        healthCheck = null;
+    }
+
+    /**
+     * Called by {@link OpaSecurityProcessor} when a route this policy guards 
starts. The readiness check is registered
+     * on the first start and, after a stop/restart cycle, restored here - 
{@link #beforeWrap} does not run again when a
+     * route is merely restarted.
+     */
+    synchronized void onProcessorStart() {
+        activeProcessors++;
+        registerHealthCheck();
+    }
+
+    /**
+     * Called by {@link OpaSecurityProcessor} when a route this policy guards 
stops. The check is unregistered once the
+     * last guarded route has gone, so a policy shared by several routes keeps 
its check until all of them stop, and a
+     * route reload does not leave a check behind reporting on a policy that 
no longer enforces anything (CAMEL-24751).
+     */
+    synchronized void onProcessorStop() {
+        if (activeProcessors > 0 && --activeProcessors == 0) {
+            unregisterHealthCheck();
+        }
+    }
+
     @Override
     public Processor wrap(Route route, final Processor processor) {
         if (LOG.isDebugEnabled()) {
diff --git 
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
 
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
index 8091bc091c1f..c0a0150cac78 100644
--- 
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
+++ 
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
@@ -38,6 +38,21 @@ public class OpaSecurityProcessor extends 
DelegateAsyncProcessor {
         this.policy = policy;
     }
 
+    @Override
+    protected void doStart() throws Exception {
+        super.doStart();
+        // drives the policy's readiness-check registration; a policy shared 
by several routes counts them
+        policy.onProcessorStart();
+    }
+
+    @Override
+    protected void doStop() throws Exception {
+        // remove the readiness check once the last guarded route stops, so a 
stopped or reloaded route does not
+        // leave a check behind reporting on a policy that is no longer 
enforcing anything (CAMEL-24751)
+        policy.onProcessorStop();
+        super.doStop();
+    }
+
     @Override
     public boolean process(Exchange exchange, AsyncCallback callback) {
         try {
diff --git 
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyHealthCheckLifecycleTest.java
 
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyHealthCheckLifecycleTest.java
new file mode 100644
index 000000000000..5d38efbb13f2
--- /dev/null
+++ 
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyHealthCheckLifecycleTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.camel.component.opa.security;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.health.HealthCheckRegistry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The readiness check registered by {@link OpaSecurityPolicy} must follow the 
lifecycle of the routes it guards: it is
+ * kept while any guarded route runs, removed once the last one stops, and 
restored when a route starts again. Otherwise
+ * a stopped or reloaded route leaves a check behind reporting on a policy 
that is no longer enforcing anything
+ * (CAMEL-24751).
+ */
+public class OpaSecurityPolicyHealthCheckLifecycleTest extends 
CamelTestSupport {
+
+    private static HttpServer server;
+    private static String serverUrl;
+
+    private final OpaSecurityPolicy policy = new OpaSecurityPolicy();
+
+    @AfterEach
+    void stopServer() {
+        if (server != null) {
+            server.stop(0);
+            server = null;
+        }
+    }
+
+    private static String startHealthyServer() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+        server.createContext("/health", exchange -> {
+            exchange.sendResponseHeaders(200, -1);
+            try (OutputStream out = exchange.getResponseBody()) {
+                out.flush();
+            }
+        });
+        server.start();
+        return "http://localhost:"; + server.getAddress().getPort();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() throws Exception {
+        serverUrl = startHealthyServer();
+        policy.setPolicyPath("authz/allow");
+        policy.setServerUrl(serverUrl);
+        // two routes share the same policy instance, so the check is 
deduplicated by id and its removal is
+        // ref-counted against the routes that are still running
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("direct:one").routeId("guarded1").policy(policy).to("mock:result");
+                
from("direct:two").routeId("guarded2").policy(policy).to("mock:result");
+            }
+        };
+    }
+
+    private long registeredChecks() {
+        HealthCheckRegistry registry = HealthCheckRegistry.get(context);
+        assertThat(registry).isNotNull();
+        return registry.stream()
+                .filter(hc -> hc.getId().startsWith("security-policy:opa-"))
+                .count();
+    }
+
+    @Test
+    void keepsTheCheckWhileAnyGuardedRouteRunsAndRestoresItOnRestart() throws 
Exception {
+        assertThat(registeredChecks()).isEqualTo(1);
+
+        // stopping one of the two routes must not remove the check - the 
other still enforces the policy
+        context.getRouteController().stopRoute("guarded1");
+        assertThat(registeredChecks()).isEqualTo(1);
+
+        // once the last guarded route stops, the check is gone
+        context.getRouteController().stopRoute("guarded2");
+        assertThat(registeredChecks()).isEqualTo(0);
+
+        // starting a route again restores it (beforeWrap does not run on a 
plain restart, so this proves the
+        // registration is driven by the processor lifecycle rather than the 
wrap)
+        context.getRouteController().startRoute("guarded1");
+        assertThat(registeredChecks()).isEqualTo(1);
+    }
+
+    @Test
+    void doesNotRegisterACheckWhenHealthCheckIsDisabled() throws Exception {
+        // the healthCheckEnabled guard now runs on processor start, not in 
beforeWrap, so it has to be exercised
+        // through a started route. A distinct serverUrl keeps the would-be 
check from deduplicating against the one
+        // the enabled routes share, so a missing guard would show up as a 
second registration.
+        OpaSecurityPolicy disabled = new OpaSecurityPolicy();
+        disabled.setPolicyPath("authz/allow");
+        disabled.setServerUrl("http://disabled-unused:8181";);
+        disabled.setHealthCheckEnabled(false);
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("direct:disabled").routeId("disabled").policy(disabled).to("mock:result");
+            }
+        });
+        context.getRouteController().startRoute("disabled");
+
+        // still only the one check the two enabled routes share; the disabled 
policy registered nothing
+        assertThat(registeredChecks()).isEqualTo(1);
+    }
+}

Reply via email to