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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new d1e09c408eb [feat](fe) Add ClusterGuard SPI interface for 
cluster-level policy enforcement (#62031)
d1e09c408eb is described below

commit d1e09c408eb563da913343f85b47200b130c9479
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Apr 3 10:08:23 2026 -0700

    [feat](fe) Add ClusterGuard SPI interface for cluster-level policy 
enforcement (#62031)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Problem Summary:
    Add a ClusterGuard SPI (Service Provider Interface) abstraction in
    fe-core to
    decouple cluster-level policy enforcement (e.g., node limits, time
    validity checks)
    from business logic. The open-source fe-core depends only on the
    interface, while
    commercial implementations can be plugged in at runtime via Java
    ServiceLoader
    without any compile-time dependency on fe-core.
    
    Key changes:
    - Add `ClusterGuard` interface with `onStartup`, `checkTimeValidity`,
    `checkNodeLimit` and `getGuardInfo` methods
    - Add `ClusterGuardFactory` using Java ServiceLoader to discover
    implementations at runtime
    - Add `NoOpClusterGuard` as the open-source no-op default implementation
    - Add `ClusterGuardException` for guard-related error reporting
    - Add `ClusterGuardAction` HTTP API at `/api/cluster_guard/` for status
    query
    - Integrate `ClusterGuardFactory.getGuard()` into `DorisFE.java`
    (startup) and `SystemInfoService.java` (addBackend/dropBackend)
---
 .../src/main/java/org/apache/doris/DorisFE.java    |   7 +
 .../org/apache/doris/cluster/ClusterGuard.java     |  65 ++++
 .../doris/cluster/ClusterGuardException.java       |  32 ++
 .../apache/doris/cluster/ClusterGuardFactory.java  |  88 +++++
 .../org/apache/doris/cluster/NoOpClusterGuard.java |  52 +++
 .../doris/httpv2/rest/manager/HttpUtils.java       |   8 +-
 .../doris/httpv2/restv2/ClusterGuardAction.java    | 161 +++++++++
 .../org/apache/doris/system/SystemInfoService.java |  20 ++
 .../doris/cluster/ClusterGuardExceptionTest.java   |  48 +++
 .../doris/cluster/ClusterGuardFactoryTest.java     | 392 +++++++++++++++++++++
 .../apache/doris/cluster/NoOpClusterGuardTest.java |  55 +++
 11 files changed, 924 insertions(+), 4 deletions(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java 
b/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
index f456d87f29f..8131bb34732 100755
--- a/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/DorisFE.java
@@ -18,6 +18,7 @@
 package org.apache.doris;
 
 import org.apache.doris.catalog.Env;
+import org.apache.doris.cluster.ClusterGuardFactory;
 import org.apache.doris.common.CommandLineOptions;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.FeConstants;
@@ -192,6 +193,7 @@ public class DorisFE {
             }
 
             fuzzyConfigs();
+            initClusterGuard(dorisHomeDir);
 
             LOG.info("Doris FE starting...");
 
@@ -595,6 +597,11 @@ public class DorisFE {
         }
     }
 
+    private static void initClusterGuard(String dorisHomeDir) throws Exception 
{
+        ClusterGuardFactory.getGuard().onStartup(dorisHomeDir);
+        LOG.info("Cluster guard initialized successfully.");
+    }
+
     public static void overwriteConfigs() {
         if (Config.isCloudMode() && Config.enable_feature_binlog) {
             Config.enable_feature_binlog = false;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuard.java 
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuard.java
new file mode 100644
index 00000000000..b7038fc6888
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuard.java
@@ -0,0 +1,65 @@
+// 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.doris.cluster;
+
+/**
+ * Cluster guard interface for enforcing cluster-level policies such as
+ * node limits and time-based validity.
+ * <p>
+ * Business code in fe-core should only depend on this interface.
+ * Implementations are discovered via {@link java.util.ServiceLoader}.
+ * When no implementation is found on the classpath, a no-op default is used.
+ * </p>
+ */
+public interface ClusterGuard {
+
+    /**
+     * Perform initialization during FE startup.
+     *
+     * @param dorisHomeDir the DORIS_HOME directory
+     * @throws ClusterGuardException if initialization fails
+     */
+    void onStartup(String dorisHomeDir) throws ClusterGuardException;
+
+    /**
+     * Check time validity of the cluster guard policy.
+     *
+     * @throws ClusterGuardException if the time-based policy is violated
+     */
+    void checkTimeValidity() throws ClusterGuardException;
+
+    /**
+     * Check whether the current node count is within allowed limits.
+     *
+     * @param currentNodeCount the current number of nodes in the cluster
+     * @throws ClusterGuardException if the node count exceeds the limit
+     */
+    void checkNodeLimit(int currentNodeCount) throws ClusterGuardException;
+
+    /**
+     * Get the guard information as a JSON string.
+     * <p>
+     * The content and structure of the returned JSON is entirely
+     * determined by the implementation. The core code treats it
+     * as an opaque payload.
+     * </p>
+     *
+     * @return a JSON string with guard details, or "{}" if not available
+     */
+    String getGuardInfo();
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardException.java 
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardException.java
new file mode 100644
index 00000000000..7d0dcaf74c4
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardException.java
@@ -0,0 +1,32 @@
+// 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.doris.cluster;
+
+/**
+ * Exception thrown when a cluster guard policy is violated.
+ */
+public class ClusterGuardException extends Exception {
+
+    public ClusterGuardException(String message) {
+        super(message);
+    }
+
+    public ClusterGuardException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardFactory.java 
b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardFactory.java
new file mode 100644
index 00000000000..875d56cc952
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/cluster/ClusterGuardFactory.java
@@ -0,0 +1,88 @@
+// 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.doris.cluster;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.Iterator;
+import java.util.ServiceLoader;
+
+/**
+ * Factory that discovers a {@link ClusterGuard} implementation via
+ * {@link ServiceLoader}.
+ * <p>
+ * If no provider is found on the classpath and the sentinel resource
+ * {@code META-INF/cluster-guard-required} is absent, a {@link 
NoOpClusterGuard}
+ * is returned so the open-source edition runs without restrictions.
+ * <p>
+ * When the sentinel resource <em>is</em> present but no implementation is 
found,
+ * startup is aborted by throwing a {@link RuntimeException}. The sentinel is
+ * only shipped by builds that require guard enforcement; it is never included
+ * in the open-source distribution.
+ */
+public class ClusterGuardFactory {
+    private static final Logger LOG = 
LogManager.getLogger(ClusterGuardFactory.class);
+
+    static final String SENTINEL_RESOURCE = "META-INF/cluster-guard-required";
+
+    private static volatile ClusterGuard instance;
+
+    /**
+     * Get the singleton ClusterGuard instance.
+     * On first call, discovers the implementation via ServiceLoader.
+     */
+    public static ClusterGuard getGuard() {
+        if (instance == null) {
+            synchronized (ClusterGuardFactory.class) {
+                if (instance == null) {
+                    instance = loadGuard();
+                }
+            }
+        }
+        return instance;
+    }
+
+    private static ClusterGuard loadGuard() {
+        return loadGuard(ClusterGuardFactory.class.getClassLoader());
+    }
+
+    /**
+     * Visible for testing — allows injecting a custom {@link ClassLoader} so 
unit
+     * tests can simulate the sentinel file being present or absent without 
touching
+     * the real classpath.
+     */
+    static ClusterGuard loadGuard(ClassLoader classLoader) {
+        ServiceLoader<ClusterGuard> loader = 
ServiceLoader.load(ClusterGuard.class, classLoader);
+        Iterator<ClusterGuard> it = loader.iterator();
+        if (it.hasNext()) {
+            ClusterGuard guard = it.next();
+            LOG.info("Loaded ClusterGuard implementation: {}", 
guard.getClass().getName());
+            return guard;
+        }
+
+        if (classLoader.getResource(SENTINEL_RESOURCE) != null) {
+            throw new RuntimeException(
+                    "ClusterGuard is required but no implementation was found 
on the classpath. "
+                    + "Ensure the appropriate extension module is included in 
the deployment.");
+        }
+
+        LOG.info("No ClusterGuard implementation found, using 
NoOpClusterGuard");
+        return NoOpClusterGuard.INSTANCE;
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cluster/NoOpClusterGuard.java 
b/fe/fe-core/src/main/java/org/apache/doris/cluster/NoOpClusterGuard.java
new file mode 100644
index 00000000000..d49444940ac
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/cluster/NoOpClusterGuard.java
@@ -0,0 +1,52 @@
+// 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.doris.cluster;
+
+/**
+ * No-op implementation of {@link ClusterGuard} used when no SPI provider is 
found.
+ * All policy checks pass unconditionally — this is the default behavior
+ * for the open-source edition.
+ */
+public class NoOpClusterGuard implements ClusterGuard {
+
+    public static final NoOpClusterGuard INSTANCE = new NoOpClusterGuard();
+
+    private NoOpClusterGuard() {
+        // singleton
+    }
+
+    @Override
+    public void onStartup(String dorisHomeDir) throws ClusterGuardException {
+        // no-op
+    }
+
+    @Override
+    public void checkTimeValidity() throws ClusterGuardException {
+        // no-op: always valid
+    }
+
+    @Override
+    public void checkNodeLimit(int currentNodeCount) throws 
ClusterGuardException {
+        // no-op: unlimited
+    }
+
+    @Override
+    public String getGuardInfo() {
+        return "{}";
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
index 694e5572952..6cda1ac4f37 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/HttpUtils.java
@@ -56,10 +56,10 @@ import java.util.stream.Collectors;
 public class HttpUtils {
     private static final Logger LOG = LogManager.getLogger(HttpUtils.class);
 
-    static final int REQUEST_SUCCESS_CODE = 0;
+    public static final int REQUEST_SUCCESS_CODE = 0;
     static final int DEFAULT_TIME_OUT_MS = 2000;
 
-    static List<Pair<String, Integer>> getFeList() {
+    public static List<Pair<String, Integer>> getFeList() {
         return Env.getCurrentEnv().getFrontends(null)
                 .stream().filter(Frontend::isAlive).map(fe -> 
Pair.of(fe.getHost(), Config.http_port))
                 .collect(Collectors.toList());
@@ -70,7 +70,7 @@ public class HttpUtils {
         return hostInfo.isSame(new HostInfo(ip, port));
     }
 
-    static String concatUrl(Pair<String, Integer> ipPort, String path, 
Map<String, String> arguments) {
+    public static String concatUrl(Pair<String, Integer> ipPort, String path, 
Map<String, String> arguments) {
         StringBuilder url = new StringBuilder("http://";)
                 
.append(ipPort.first).append(":").append(ipPort.second).append(path);
         boolean isFirst = true;
@@ -98,7 +98,7 @@ public class HttpUtils {
         return doGet(url, headers, DEFAULT_TIME_OUT_MS);
     }
 
-    static String doPost(String url, Map<String, String> headers, Object body) 
throws IOException {
+    public static String doPost(String url, Map<String, String> headers, 
Object body) throws IOException {
         HttpPost httpPost = new HttpPost(url);
         if (Objects.nonNull(body)) {
             String jsonString = GsonUtils.GSON.toJson(body);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ClusterGuardAction.java
 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ClusterGuardAction.java
new file mode 100644
index 00000000000..a41509fd0a8
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ClusterGuardAction.java
@@ -0,0 +1,161 @@
+// 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.doris.httpv2.restv2;
+
+import org.apache.doris.cluster.ClusterGuard;
+import org.apache.doris.cluster.ClusterGuardException;
+import org.apache.doris.cluster.ClusterGuardFactory;
+import org.apache.doris.common.Pair;
+import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
+import org.apache.doris.httpv2.rest.RestBaseController;
+import org.apache.doris.httpv2.rest.manager.HttpUtils;
+import org.apache.doris.httpv2.rest.manager.NodeAction;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * HTTP API to check and manage the cluster guard status.
+ *
+ * GET  /rest/v2/api/cluster_guard/status  — query current cluster guard status
+ * POST /rest/v2/api/cluster_guard/reload  — reload cluster guard on all FE 
nodes (or single node)
+ */
+@RestController
+@RequestMapping("/rest/v2")
+public class ClusterGuardAction extends RestBaseController {
+    private static final Logger LOG = 
LogManager.getLogger(ClusterGuardAction.class);
+
+    private static final String IS_ALL_NODE_PARA = "is_all_node";
+
+    private static final Gson GSON = new Gson();
+    private static final Type MAP_TYPE = new TypeToken<Map<String, Object>>() 
{}.getType();
+
+    /**
+     * Query the current cluster guard status.
+     * Returns the opaque JSON from {@link ClusterGuard#getGuardInfo()}.
+     */
+    @RequestMapping(path = "/api/cluster_guard/status", method = 
{RequestMethod.GET})
+    public Object getGuardStatus(HttpServletRequest request, 
HttpServletResponse response) {
+        checkWithCookie(request, response, false);
+
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        Map<String, Object> info = GSON.fromJson(guard.getGuardInfo(), 
MAP_TYPE);
+        return ResponseEntityBuilder.ok(info);
+    }
+
+    /**
+     * Reload the cluster guard on all FE nodes (default) or just the current 
node.
+     *
+     * POST /rest/v2/api/cluster_guard/reload
+     * POST /rest/v2/api/cluster_guard/reload?is_all_node=false
+     */
+    @RequestMapping(path = "/api/cluster_guard/reload", method = 
{RequestMethod.POST})
+    public Object reloadGuard(HttpServletRequest request, HttpServletResponse 
response,
+            @RequestParam(value = IS_ALL_NODE_PARA, required = false, 
defaultValue = "true")
+                    boolean isAllNode) {
+        executeCheckPassword(request, response);
+
+        if (isAllNode) {
+            return reloadOnAllFe(request);
+        }
+        return reloadOnLocal();
+    }
+
+    private Object reloadOnLocal() {
+        String dorisHome = System.getenv("DORIS_HOME");
+        try {
+            ClusterGuardFactory.getGuard().onStartup(dorisHome);
+            LOG.info("Cluster guard reloaded successfully via HTTP API.");
+        } catch (ClusterGuardException e) {
+            LOG.warn("Failed to reload cluster guard: {}", e.getMessage(), e);
+            return ResponseEntityBuilder.okWithCommonError("Failed to reload 
cluster guard: " + e.getMessage());
+        }
+
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        Map<String, Object> info = GSON.fromJson(guard.getGuardInfo(), 
MAP_TYPE);
+        return ResponseEntityBuilder.ok(info);
+    }
+
+    private Object reloadOnAllFe(HttpServletRequest request) {
+        List<Pair<String, Integer>> frontends = HttpUtils.getFeList();
+        String authorization = request.getHeader(NodeAction.AUTHORIZATION);
+        ImmutableMap<String, String> header = ImmutableMap.<String, 
String>builder()
+                .put(NodeAction.AUTHORIZATION, authorization).build();
+
+        String httpPath = "/rest/v2/api/cluster_guard/reload";
+        Map<String, String> arguments = Maps.newHashMap();
+        arguments.put(IS_ALL_NODE_PARA, "false");
+
+        Map<String, Object> allResults = Maps.newLinkedHashMap();
+        boolean hasFailure = false;
+
+        for (Pair<String, Integer> ipPort : frontends) {
+            String nodeKey = ipPort.first + ":" + ipPort.second;
+            String url = HttpUtils.concatUrl(ipPort, httpPath, arguments);
+            try {
+                String resp = HttpUtils.doPost(url, header, null);
+                JsonObject jsonObj = 
JsonParser.parseString(resp).getAsJsonObject();
+                int code = jsonObj.get("code").getAsInt();
+                if (code == HttpUtils.REQUEST_SUCCESS_CODE) {
+                    Map<String, Object> nodeResult = Maps.newLinkedHashMap();
+                    nodeResult.put("status", "success");
+                    if (jsonObj.has("data") && 
!jsonObj.get("data").isJsonNull()) {
+                        nodeResult.put("data", jsonObj.get("data").toString());
+                    }
+                    allResults.put(nodeKey, nodeResult);
+                } else {
+                    hasFailure = true;
+                    Map<String, String> nodeResult = Maps.newLinkedHashMap();
+                    nodeResult.put("status", "failed");
+                    String msg = jsonObj.has("data") ? 
jsonObj.get("data").getAsString()
+                            : jsonObj.get("msg").getAsString();
+                    nodeResult.put("message", msg);
+                    allResults.put(nodeKey, nodeResult);
+                }
+            } catch (Exception e) {
+                hasFailure = true;
+                LOG.warn("Failed to reload cluster guard on FE node {}: {}", 
nodeKey, e.getMessage(), e);
+                Map<String, String> nodeResult = Maps.newLinkedHashMap();
+                nodeResult.put("status", "failed");
+                nodeResult.put("message", "Request failed: " + e.getMessage());
+                allResults.put(nodeKey, nodeResult);
+            }
+        }
+
+        if (hasFailure) {
+            return ResponseEntityBuilder.internalError(allResults);
+        }
+        return ResponseEntityBuilder.ok(allResults);
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java 
b/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
index 0a5abcf9516..2ef193cb158 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
@@ -21,6 +21,9 @@ import org.apache.doris.catalog.DiskInfo;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.ReplicaAllocation;
 import org.apache.doris.cloud.qe.ComputeGroupException;
+import org.apache.doris.cluster.ClusterGuard;
+import org.apache.doris.cluster.ClusterGuardException;
+import org.apache.doris.cluster.ClusterGuardFactory;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.DdlException;
@@ -192,6 +195,16 @@ public class SystemInfoService {
             }
         }
 
+        // check cluster guard policy: time validity and node limit
+        ClusterGuard clusterGuard = ClusterGuardFactory.getGuard();
+        try {
+            clusterGuard.checkTimeValidity();
+            int currentCount = getAllClusterBackendsNoException().size();
+            clusterGuard.checkNodeLimit(currentCount + hostInfos.size());
+        } catch (ClusterGuardException e) {
+            throw new DdlException("Cluster guard restriction: " + 
e.getMessage());
+        }
+
         for (HostInfo hostInfo : hostInfos) {
             addBackend(hostInfo.getHost(), hostInfo.getPort(), tagMap);
         }
@@ -232,6 +245,13 @@ public class SystemInfoService {
     }
 
     public void dropBackends(List<HostInfo> hostInfos) throws DdlException {
+        // check cluster guard time validity
+        try {
+            ClusterGuardFactory.getGuard().checkTimeValidity();
+        } catch (ClusterGuardException e) {
+            throw new DdlException("Cluster guard restriction: " + 
e.getMessage());
+        }
+
         for (HostInfo hostInfo : hostInfos) {
             // check is already exist
             if (getBackendWithHeartbeatPort(hostInfo.getHost(), 
hostInfo.getPort()) == null) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardExceptionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardExceptionTest.java
new file mode 100644
index 00000000000..efc95f6cc10
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardExceptionTest.java
@@ -0,0 +1,48 @@
+// 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.doris.cluster;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ClusterGuardExceptionTest {
+
+    @Test
+    public void testMessageConstructor() {
+        ClusterGuardException ex = new ClusterGuardException("policy 
violated");
+        Assert.assertEquals("policy violated", ex.getMessage());
+        Assert.assertNull(ex.getCause());
+    }
+
+    @Test
+    public void testMessageAndCauseConstructor() {
+        RuntimeException cause = new RuntimeException("root cause");
+        ClusterGuardException ex = new ClusterGuardException("wrapped", cause);
+        Assert.assertEquals("wrapped", ex.getMessage());
+        Assert.assertSame(cause, ex.getCause());
+    }
+
+    @Test
+    public void testIsCheckedException() {
+        // ClusterGuardException must be a checked exception (extends 
Exception, not RuntimeException).
+        // Cast to Object first so the compiler does not reject the instanceof 
check as always-false.
+        Object ex = new ClusterGuardException("test");
+        Assert.assertTrue(ex instanceof Exception);
+        Assert.assertFalse(ex instanceof RuntimeException);
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardFactoryTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardFactoryTest.java
new file mode 100644
index 00000000000..7726fbf0a2d
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/cluster/ClusterGuardFactoryTest.java
@@ -0,0 +1,392 @@
+// 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.doris.cluster;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+
+/**
+ * Tests for {@link ClusterGuardFactory}.
+ *
+ * <p>The decision matrix under test:
+ * <pre>
+ *  Sentinel file | SPI impl | Expected behaviour
+ *  ------------- | -------- | ------------------
+ *  absent        | absent   | NoOpClusterGuard returned
+ *  absent        | present  | real impl returned
+ *  present       | present  | real impl returned
+ *  present       | absent   | RuntimeException thrown (hard exit)
+ * </pre>
+ *
+ * <p>Because {@link ClusterGuardFactory} uses a static singleton, the
+ * {@code instance} field is reset via reflection before and after each test
+ * to ensure isolation.
+ *
+ * <p>Sentinel-file simulation is achieved by injecting a custom
+ * {@link ClassLoader} into the package-private {@code loadGuard(ClassLoader)}
+ * overload. An in-process {@link URLStreamHandler} supplies a synthetic URL
+ * that returns an empty byte stream, faithfully replicating a real zero-byte
+ * marker file on the classpath.
+ */
+public class ClusterGuardFactoryTest {
+
+    private Field instanceField;
+
+    @Before
+    public void setUp() throws Exception {
+        instanceField = ClusterGuardFactory.class.getDeclaredField("instance");
+        instanceField.setAccessible(true);
+        instanceField.set(null, null); // reset singleton
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        instanceField.set(null, null); // clean up after each test
+    }
+
+    // -----------------------------------------------------------------------
+    // Decision-matrix scenarios
+    // -----------------------------------------------------------------------
+
+    /**
+     * Scenario: sentinel absent, no SPI impl.
+     * Community build with no extensions installed.
+     * Expected: NoOpClusterGuard is returned silently.
+     */
+    @Test
+    public void testNoSentinelNoImpl_returnsNoOp() {
+        ClassLoader cl = new SentinelClassLoader(false, false);
+        ClusterGuard guard = ClusterGuardFactory.loadGuard(cl);
+        Assert.assertSame(NoOpClusterGuard.INSTANCE, guard);
+    }
+
+    /**
+     * Scenario: sentinel absent, SPI impl present.
+     * Community build with an optional cluster-guard plugin installed.
+     * Expected: real implementation is returned (not NoOpClusterGuard).
+     *
+     * <p>Note: {@link ServiceLoader} always creates a fresh instance via the 
no-arg
+     * constructor, so we verify the type rather than reference identity.
+     */
+    @Test
+    public void testNoSentinelWithImpl_returnsImpl() {
+        ClassLoader cl = new SentinelClassLoader(false, true);
+        ClusterGuard guard = ClusterGuardFactory.loadGuard(cl);
+        Assert.assertTrue(
+                "Expected a StubClusterGuard instance, got: " + 
guard.getClass(),
+                guard instanceof StubClusterGuard);
+        Assert.assertNotSame(NoOpClusterGuard.INSTANCE, guard);
+    }
+
+    /**
+     * Scenario: sentinel present, SPI impl present.
+     * Correct distribution build — guard enforcement required and satisfied.
+     * Expected: real implementation is returned, no exception thrown.
+     *
+     * <p>Note: {@link ServiceLoader} always creates a fresh instance via the 
no-arg
+     * constructor, so we verify the type rather than reference identity.
+     */
+    @Test
+    public void testSentinelPresentWithImpl_returnsImpl() {
+        ClassLoader cl = new SentinelClassLoader(true, true);
+        ClusterGuard guard = ClusterGuardFactory.loadGuard(cl);
+        Assert.assertTrue(
+                "Expected a StubClusterGuard instance, got: " + 
guard.getClass(),
+                guard instanceof StubClusterGuard);
+        Assert.assertNotSame(NoOpClusterGuard.INSTANCE, guard);
+    }
+
+    /**
+     * Scenario: sentinel present, no SPI impl.
+     * Distribution build where the implementation JAR is missing — 
misconfiguration.
+     * Expected: RuntimeException is thrown so FE startup is aborted.
+     */
+    @Test
+    public void testSentinelPresentNoImpl_throwsRuntimeException() {
+        ClassLoader cl = new SentinelClassLoader(true, false);
+        try {
+            ClusterGuardFactory.loadGuard(cl);
+            Assert.fail("Expected RuntimeException when sentinel is present 
but no impl found");
+        } catch (RuntimeException e) {
+            Assert.assertTrue(
+                    "Error message should mention ClusterGuard",
+                    e.getMessage().contains("ClusterGuard"));
+        }
+    }
+
+    // -----------------------------------------------------------------------
+    // Singleton behaviour
+    // -----------------------------------------------------------------------
+
+    @Test
+    public void testGetGuardReturnsNonNull() {
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        Assert.assertNotNull(guard);
+    }
+
+    @Test
+    public void testGetGuardReturnsSameInstance() {
+        ClusterGuard first = ClusterGuardFactory.getGuard();
+        ClusterGuard second = ClusterGuardFactory.getGuard();
+        Assert.assertSame(first, second);
+    }
+
+    @Test
+    public void testGetGuardReturnsNoOpWhenNoSpiProviderFound() {
+        // In the test classpath there is no 
META-INF/services/org.apache.doris.cluster.ClusterGuard
+        // and no sentinel file, so the factory must fall back to 
NoOpClusterGuard.
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        Assert.assertSame(NoOpClusterGuard.INSTANCE, guard);
+    }
+
+    // -----------------------------------------------------------------------
+    // NoOpClusterGuard behaviour
+    // -----------------------------------------------------------------------
+
+    @Test
+    public void testNoOpGuardAllowsUnlimitedNodes() throws 
ClusterGuardException {
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        guard.checkNodeLimit(0);
+        guard.checkNodeLimit(100);
+        guard.checkNodeLimit(Integer.MAX_VALUE);
+    }
+
+    @Test
+    public void testNoOpGuardTimeValidityAlwaysPasses() throws 
ClusterGuardException {
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        guard.checkTimeValidity();
+    }
+
+    @Test
+    public void testNoOpGuardInfoIsEmptyJson() {
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        Assert.assertEquals("{}", guard.getGuardInfo());
+    }
+
+    // -----------------------------------------------------------------------
+    // Injection-based tests (simulate SPI via reflection)
+    // -----------------------------------------------------------------------
+
+    @Test
+    public void testCustomGuardIsReturnedWhenInjected() throws Exception {
+        ClusterGuard custom = new StubClusterGuard("custom-info");
+        instanceField.set(null, custom);
+
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        Assert.assertSame(custom, guard);
+        Assert.assertEquals("custom-info", guard.getGuardInfo());
+    }
+
+    @Test
+    public void testCustomGuardCheckNodeLimitEnforcesLimit() throws Exception {
+        int limit = 3;
+        ClusterGuard limitedGuard = new StubClusterGuard("{}") {
+            @Override
+            public void checkNodeLimit(int currentNodeCount) throws 
ClusterGuardException {
+                if (currentNodeCount > limit) {
+                    throw new ClusterGuardException(
+                            "Node limit exceeded: max=" + limit + ", current=" 
+ currentNodeCount);
+                }
+            }
+        };
+        instanceField.set(null, limitedGuard);
+
+        ClusterGuard guard = ClusterGuardFactory.getGuard();
+        guard.checkNodeLimit(3); // within limit — must not throw
+
+        try {
+            guard.checkNodeLimit(4);
+            Assert.fail("Expected ClusterGuardException");
+        } catch (ClusterGuardException e) {
+            Assert.assertTrue(e.getMessage().contains("Node limit exceeded"));
+        }
+    }
+
+    @Test
+    public void testCustomGuardOnStartupPropagatesException() throws Exception 
{
+        ClusterGuard failingGuard = new StubClusterGuard("{}") {
+            @Override
+            public void onStartup(String dorisHomeDir) throws 
ClusterGuardException {
+                throw new ClusterGuardException("startup failed");
+            }
+        };
+        instanceField.set(null, failingGuard);
+
+        try {
+            ClusterGuardFactory.getGuard().onStartup("/doris/home");
+            Assert.fail("Expected ClusterGuardException");
+        } catch (ClusterGuardException e) {
+            Assert.assertEquals("startup failed", e.getMessage());
+        }
+    }
+
+    // -----------------------------------------------------------------------
+    // Helpers
+    // -----------------------------------------------------------------------
+
+    /**
+     * A {@link ClassLoader} that can:
+     * <ul>
+     *   <li>Optionally expose a synthetic {@code 
META-INF/cluster-guard-required}
+     *       resource (sentinel simulation).</li>
+     *   <li>Optionally expose a {@link StubClusterGuard} via the standard
+     *       {@code META-INF/services/} mechanism (SPI simulation).</li>
+     * </ul>
+     *
+     * <p>{@link ServiceLoader} discovers SPI implementations by calling
+     * {@link ClassLoader#getResources(String)} for the services registration 
file,
+     * then reading each returned URL as a stream. Therefore we must override 
both
+     * {@code findResources} (to inject the synthetic URL into the 
enumeration) and
+     * provide a readable stream behind that URL via a custom {@link 
URLStreamHandler}.
+     *
+     * <p>All other resource and class lookups are delegated to the parent 
loader.
+     */
+    private static class SentinelClassLoader extends ClassLoader {
+
+        private static final String SPI_FILE =
+                "META-INF/services/" + ClusterGuard.class.getName();
+
+        private final boolean hasSentinel;
+        private final boolean hasImpl;
+
+        SentinelClassLoader(boolean hasSentinel, boolean hasImpl) {
+            super(ClusterGuardFactoryTest.class.getClassLoader());
+            this.hasSentinel = hasSentinel;
+            this.hasImpl = hasImpl;
+        }
+
+        /** Intercepts the sentinel probe done by {@link 
ClusterGuardFactory#loadGuard}. */
+        @Override
+        public URL getResource(String name) {
+            if (ClusterGuardFactory.SENTINEL_RESOURCE.equals(name)) {
+                return hasSentinel ? emptyUrl(name) : null;
+            }
+            return super.getResource(name);
+        }
+
+        /**
+         * Intercepts the SPI services-file enumeration performed by {@link 
ServiceLoader}.
+         * When {@code hasImpl} is true, inject a synthetic URL that delivers 
the stub
+         * class name as its content; otherwise return only what the parent 
knows about.
+         */
+        @Override
+        public java.util.Enumeration<URL> getResources(String name) throws 
IOException {
+            if (SPI_FILE.equals(name)) {
+                if (hasImpl) {
+                    return java.util.Collections.enumeration(
+                            java.util.Collections.singletonList(spiUrl()));
+                }
+                return java.util.Collections.emptyEnumeration();
+            }
+            return super.getResources(name);
+        }
+
+        // ------------------------------------------------------------------
+        // Synthetic URL helpers
+        // ------------------------------------------------------------------
+
+        /**
+         * A zero-byte URL signalling "this resource exists" — used for the 
sentinel.
+         */
+        private static URL emptyUrl(String path) {
+            return makeUrl(path, new byte[0]);
+        }
+
+        /**
+         * A URL whose content is the fully-qualified class name of the stub 
impl,
+         * which is exactly what {@link ServiceLoader} reads from a services 
file.
+         */
+        private static URL spiUrl() {
+            byte[] content = StubClusterGuard.class.getName().getBytes();
+            return makeUrl(SPI_FILE, content);
+        }
+
+        private static URL makeUrl(String path, byte[] content) {
+            try {
+                URLStreamHandler handler = new URLStreamHandler() {
+                    @Override
+                    protected URLConnection openConnection(URL u) {
+                        return new URLConnection(u) {
+                            @Override
+                            public void connect() {
+                            }
+
+                            @Override
+                            public InputStream getInputStream() {
+                                return new ByteArrayInputStream(content);
+                            }
+                        };
+                    }
+                };
+                return new URL("synthetic", "", -1, path, handler);
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    /**
+     * Minimal stub implementation used for injection-based and SPI-simulation 
tests.
+     *
+     * <p>The no-arg constructor is required so that {@link ServiceLoader} can
+     * instantiate this class via reflection during SPI-simulation tests.
+     */
+    public static class StubClusterGuard implements ClusterGuard {
+
+        private final String guardInfo;
+
+        /** No-arg constructor required by {@link ServiceLoader}. */
+        public StubClusterGuard() {
+            this("stub");
+        }
+
+        StubClusterGuard(String guardInfo) {
+            this.guardInfo = guardInfo;
+        }
+
+        @Override
+        public void onStartup(String dorisHomeDir) throws 
ClusterGuardException {
+            // no-op by default
+        }
+
+        @Override
+        public void checkTimeValidity() throws ClusterGuardException {
+            // no-op by default
+        }
+
+        @Override
+        public void checkNodeLimit(int currentNodeCount) throws 
ClusterGuardException {
+            // unlimited by default
+        }
+
+        @Override
+        public String getGuardInfo() {
+            return guardInfo;
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/cluster/NoOpClusterGuardTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/cluster/NoOpClusterGuardTest.java
new file mode 100644
index 00000000000..15a1b89d2b8
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/cluster/NoOpClusterGuardTest.java
@@ -0,0 +1,55 @@
+// 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.doris.cluster;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class NoOpClusterGuardTest {
+
+    @Test
+    public void testOnStartupDoesNotThrow() throws ClusterGuardException {
+        NoOpClusterGuard.INSTANCE.onStartup("/some/home/dir");
+        // no exception expected
+    }
+
+    @Test
+    public void testCheckTimeValidityDoesNotThrow() throws 
ClusterGuardException {
+        NoOpClusterGuard.INSTANCE.checkTimeValidity();
+        // always valid in open-source edition
+    }
+
+    @Test
+    public void testCheckNodeLimitDoesNotThrow() throws ClusterGuardException {
+        // should pass for any node count
+        NoOpClusterGuard.INSTANCE.checkNodeLimit(0);
+        NoOpClusterGuard.INSTANCE.checkNodeLimit(1);
+        NoOpClusterGuard.INSTANCE.checkNodeLimit(Integer.MAX_VALUE);
+    }
+
+    @Test
+    public void testGetGuardInfoReturnsEmptyJson() {
+        String info = NoOpClusterGuard.INSTANCE.getGuardInfo();
+        Assert.assertEquals("{}", info);
+    }
+
+    @Test
+    public void testSingletonIdentity() {
+        Assert.assertSame(NoOpClusterGuard.INSTANCE, 
NoOpClusterGuard.INSTANCE);
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to