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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new c97e8dff feat: add NameServer configuration drift detection (#979)
c97e8dff is described below

commit c97e8dffc9c6465b07ae26a9a8c95071c80beb79
Author: yx9o <[email protected]>
AuthorDate: Wed Aug 5 17:38:30 2026 +0800

    feat: add NameServer configuration drift detection (#979)
    
    Co-authored-by: yx9o <[email protected]>
---
 docs/api-spec.md                                   |  69 +++++-
 .../nameserver/NameServerConfigDiffService.java    | 205 ++++++++++++++++
 .../cluster/nameserver/NameServerConfigDiffVO.java |  68 ++++++
 .../ai/tool/NameServerConfigDiffToolHandler.java   |  43 ++++
 .../cluster/nameserver-safe-config-keys.yml        |  22 ++
 .../src/main/resources/tool-catalog/rmq-tools.yaml |  91 ++++++++
 .../NameServerConfigDiffServiceTest.java           | 257 +++++++++++++++++++++
 .../studio/ops/ai/tool/ToolCatalogTest.java        |   3 +-
 .../studio/ops/ai/tool/ToolGatewayServiceTest.java |  84 ++++++-
 9 files changed, 833 insertions(+), 9 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 879add48..dafe277f 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -111,7 +111,8 @@
 | 68 | POST | `/api/ai/chat` | AI 对话(SSE) |
 | 69 | POST | `/api/ai/execute` | 执行 AI 指令 |
 | 70 | GET | `/api/ai/tools` | 可用工具列表 |
-| 71 | POST | `/api/metrics/query` | 查询监控指标数据 |
+| 71 | POST | `/api/ai/tools/:name/execute` | 执行只读 AI 工具 |
+| 72 | POST | `/api/metrics/query` | 查询监控指标数据 |
 
 ## 通用响应格式
 
@@ -1690,6 +1691,72 @@ GET /api/ai/tools
 | `description` | `string` | 工具描述 |
 | `parameters` | `object` | 参数 Schema |
 
+### 15.4 执行只读工具
+
+```
+POST /api/ai/tools/:name/execute
+```
+
+**Path Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `name` | `string` | 是 | 工具名称;NameServer 配置漂移检查使用 
`rmq.nameserver.config.diff` |
+
+**`rmq.nameserver.config.diff` Request Body:**
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `cluster` | `string` | 是 | Studio 集群 ID |
+
+该工具逐个读取集群内的 NameServer 
配置,只比较预定义的非敏感运行参数白名单。完整配置、路径、密码和凭据不会返回。单节点失败不会丢弃其他节点的检查结果。
+
+**Response `data`:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `cluster` | `string` | 集群 ID |
+| `complete` | `boolean` | 是否成功读取全部 NameServer;为 `false` 时漂移结论不完整 |
+| `driftDetected` | `boolean` | 可达节点之间是否发现白名单配置差异 |
+| `nodeCount` | `number` | NameServer 节点总数 |
+| `reachableNodeCount` | `number` | 成功读取配置的节点数 |
+| `comparedKeys` | `string[]` | 本次允许比较的配置键白名单 |
+| `nodes` | `object[]` | 节点地址及可达状态 |
+| `nodes[].address` | `string` | NameServer 地址 |
+| `nodes[].reachable` | `boolean` | 是否成功读取该节点配置 |
+| `differences` | `object[]` | 可达节点之间存在差异的配置项 |
+| `differences[].key` | `string` | 配置键 |
+| `differences[].values` | `object[]` | 各可达节点的配置值 |
+| `differences[].values[].address` | `string` | NameServer 地址 |
+| `differences[].values[].configured` | `boolean` | 节点是否显式包含该配置键 |
+| `differences[].values[].value` | `string?` | 白名单配置值;未配置时为 `null` |
+
+**示例:**
+
+```json
+{
+  "cluster": "cluster-prod",
+  "complete": true,
+  "driftDetected": true,
+  "nodeCount": 2,
+  "reachableNodeCount": 2,
+  "comparedKeys": ["listenPort", "serverWorkerThreads"],
+  "nodes": [
+    {"address": "10.0.0.1:9876", "reachable": true},
+    {"address": "10.0.0.2:9876", "reachable": true}
+  ],
+  "differences": [
+    {
+      "key": "serverWorkerThreads",
+      "values": [
+        {"address": "10.0.0.1:9876", "configured": true, "value": "8"},
+        {"address": "10.0.0.2:9876", "configured": true, "value": "16"}
+      ]
+    }
+  ]
+}
+```
+
 ---
 
 ## 16. 监控指标 Metrics
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffService.java
new file mode 100644
index 00000000..bf23212d
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffService.java
@@ -0,0 +1,205 @@
+/*
+ * 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.rocketmq.studio.cluster.nameserver;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.stream.Stream;
+
+@Service
+@RequiredArgsConstructor
+public class NameServerConfigDiffService {
+
+    static final String SAFE_CONFIG_KEYS_RESOURCE = 
"/cluster/nameserver-safe-config-keys.yml";
+
+    private static final List<String> SAFE_CONFIG_KEYS = loadSafeConfigKeys();
+
+    private static List<String> loadSafeConfigKeys() {
+        try (InputStream input = NameServerConfigDiffService.class
+                .getResourceAsStream(SAFE_CONFIG_KEYS_RESOURCE)) {
+            if (input == null) {
+                throw new IllegalStateException(
+                        "Missing NameServer config diff keys resource: " + 
SAFE_CONFIG_KEYS_RESOURCE);
+            }
+            JsonNode keysNode = new ObjectMapper(new YAMLFactory())
+                    .readTree(input).path("keys");
+            if (!keysNode.isArray() || keysNode.isEmpty()) {
+                throw new IllegalStateException(
+                        "NameServer config diff keys resource must define a 
non-empty 'keys' list: "
+                                + SAFE_CONFIG_KEYS_RESOURCE);
+            }
+            List<String> keys = new ArrayList<>();
+            keysNode.forEach(node -> keys.add(node.asText()));
+            return List.copyOf(keys);
+        } catch (IOException exception) {
+            throw new IllegalStateException(
+                    "Failed to load NameServer config diff keys resource: " + 
SAFE_CONFIG_KEYS_RESOURCE,
+                    exception);
+        }
+    }
+
+    private final ClusterService clusterService;
+    private final MqAdminExtFactory adminFactory;
+
+    public NameServerConfigDiffVO compare(String clusterId) {
+        String normalizedClusterId = requireClusterId(clusterId);
+        ClusterVO cluster = clusterService.getCluster(normalizedClusterId);
+        List<String> addresses = collectNameServerAddresses(cluster);
+        if (addresses.isEmpty()) {
+            throw new BusinessException(409,
+                    "Cluster has no NameServer endpoints: " + 
normalizedClusterId);
+        }
+
+        String connectionEndpoint = connectionEndpoint(cluster, addresses);
+        Map<String, Properties> reachableConfigs = new LinkedHashMap<>();
+        List<NameServerConfigDiffVO.NodeStatusVO> nodes = new ArrayList<>();
+
+        for (String address : addresses) {
+            try {
+                Properties config = readConfig(connectionEndpoint, address);
+                reachableConfigs.put(address, config);
+                nodes.add(NameServerConfigDiffVO.NodeStatusVO.builder()
+                        .address(address)
+                        .reachable(true)
+                        .build());
+            } catch (BusinessException exception) {
+                nodes.add(NameServerConfigDiffVO.NodeStatusVO.builder()
+                        .address(address)
+                        .reachable(false)
+                        .build());
+            }
+        }
+
+        List<NameServerConfigDiffVO.ConfigDifferenceVO> differences =
+                findDifferences(reachableConfigs);
+        int reachableNodeCount = reachableConfigs.size();
+        return NameServerConfigDiffVO.builder()
+                .cluster(normalizedClusterId)
+                .complete(reachableNodeCount == addresses.size())
+                .driftDetected(!differences.isEmpty())
+                .nodeCount(addresses.size())
+                .reachableNodeCount(reachableNodeCount)
+                .comparedKeys(SAFE_CONFIG_KEYS)
+                .nodes(nodes)
+                .differences(differences)
+                .build();
+    }
+
+    private Properties readConfig(String connectionEndpoint, String address) {
+        return adminFactory.execute(connectionEndpoint, null, admin -> {
+            Map<String, Properties> configs = 
admin.getNameServerConfig(List.of(address));
+            Properties config = configs == null ? null : configs.get(address);
+            if (config == null) {
+                throw new BusinessException(502,
+                        "NameServer returned no configuration: " + address);
+            }
+            return config;
+        });
+    }
+
+    private List<NameServerConfigDiffVO.ConfigDifferenceVO> findDifferences(
+            Map<String, Properties> configs) {
+        if (configs.size() < 2) {
+            return List.of();
+        }
+
+        List<NameServerConfigDiffVO.ConfigDifferenceVO> differences = new 
ArrayList<>();
+        for (String key : SAFE_CONFIG_KEYS) {
+            List<NameServerConfigDiffVO.ConfigValueVO> values = 
configs.entrySet().stream()
+                    .map(entry -> configValue(entry.getKey(), 
entry.getValue(), key))
+                    .toList();
+            long distinctValues = values.stream()
+                    .map(value -> value.isConfigured() ? value.getValue() : 
null)
+                    .distinct()
+                    .count();
+            if (distinctValues > 1) {
+                
differences.add(NameServerConfigDiffVO.ConfigDifferenceVO.builder()
+                        .key(key)
+                        .values(values)
+                        .build());
+            }
+        }
+        return differences;
+    }
+
+    private NameServerConfigDiffVO.ConfigValueVO configValue(
+            String address,
+            Properties config,
+            String key) {
+        String value = config.getProperty(key);
+        return NameServerConfigDiffVO.ConfigValueVO.builder()
+                .address(address)
+                .configured(value != null)
+                .value(value)
+                .build();
+    }
+
+    private List<String> collectNameServerAddresses(ClusterVO cluster) {
+        Stream<String> declared = cluster.getNameServers() == null
+                ? Stream.empty()
+                : cluster.getNameServers().stream()
+                        .filter(node -> node != null)
+                        .map(NameServerVO::getAddr);
+        Stream<String> endpointAddresses = 
splitEndpoint(cluster.getEndpoint()).stream();
+        return Stream.concat(declared, endpointAddresses)
+                .filter(address -> address != null && !address.isBlank())
+                .map(String::trim)
+                .distinct()
+                .sorted()
+                .toList();
+    }
+
+    private String connectionEndpoint(ClusterVO cluster, List<String> 
addresses) {
+        if (cluster.getEndpoint() != null && !cluster.getEndpoint().isBlank()) 
{
+            return cluster.getEndpoint().trim();
+        }
+        return String.join(";", addresses);
+    }
+
+    private List<String> splitEndpoint(String endpoint) {
+        if (endpoint == null || endpoint.isBlank()) {
+            return List.of();
+        }
+        return Stream.of(endpoint.split("[;,]"))
+                .map(String::trim)
+                .filter(address -> !address.isEmpty())
+                .toList();
+    }
+
+    private String requireClusterId(String clusterId) {
+        if (clusterId == null || clusterId.isBlank()) {
+            throw new BusinessException(400, "cluster is required");
+        }
+        return clusterId.trim();
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffVO.java
new file mode 100644
index 00000000..a6295eda
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffVO.java
@@ -0,0 +1,68 @@
+/*
+ * 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.rocketmq.studio.cluster.nameserver;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class NameServerConfigDiffVO {
+
+    private String cluster;
+    private boolean complete;
+    private boolean driftDetected;
+    private int nodeCount;
+    private int reachableNodeCount;
+    private List<String> comparedKeys;
+    private List<NodeStatusVO> nodes;
+    private List<ConfigDifferenceVO> differences;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class NodeStatusVO {
+        private String address;
+        private boolean reachable;
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class ConfigDifferenceVO {
+        private String key;
+        private List<ConfigValueVO> values;
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class ConfigValueVO {
+        private String address;
+        private boolean configured;
+        private String value;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/NameServerConfigDiffToolHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/NameServerConfigDiffToolHandler.java
new file mode 100644
index 00000000..44b4a2fb
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/NameServerConfigDiffToolHandler.java
@@ -0,0 +1,43 @@
+/*
+ * 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.rocketmq.studio.ops.ai.tool;
+
+import 
org.apache.rocketmq.studio.cluster.nameserver.NameServerConfigDiffService;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class NameServerConfigDiffToolHandler implements ToolHandler {
+
+    private static final String NAME = "rmq.nameserver.config.diff";
+
+    private final NameServerConfigDiffService configDiffService;
+
+    @Override
+    public String name() {
+        return NAME;
+    }
+
+    @Override
+    public Object execute(Map<String, Object> input) {
+        return configDiffService.compare((String) input.get("cluster"));
+    }
+}
diff --git a/server/src/main/resources/cluster/nameserver-safe-config-keys.yml 
b/server/src/main/resources/cluster/nameserver-safe-config-keys.yml
new file mode 100644
index 00000000..aaa29510
--- /dev/null
+++ b/server/src/main/resources/cluster/nameserver-safe-config-keys.yml
@@ -0,0 +1,22 @@
+# NameServer 配置漂移检测(NameServerConfigDiffService)比对的配置项白名单。
+# 只比对在全部节点上语义必须一致的键;新增/删除键直接改本文件即可,无需改代码。
+keys:
+  - listenPort
+  - serverWorkerThreads
+  - serverCallbackExecutorThreads
+  - serverSelectorThreads
+  - serverOnewaySemaphoreValue
+  - serverAsyncSemaphoreValue
+  - serverChannelMaxIdleTimeSeconds
+  - serverSocketSndBufSize
+  - serverSocketRcvBufSize
+  - serverPooledByteBufAllocatorEnable
+  - useEpollNativeSelector
+  - orderMessageEnable
+  - returnOrderTopicConfigToBroker
+  - enableControllerInNamesrv
+  - enableUncleanMasterElection
+  - notifyMinBrokerIdChanged
+  - supportActingMaster
+  - scanNotActiveBrokerInterval
+  - unRegisterBrokerQueueCapacity
diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml 
b/server/src/main/resources/tool-catalog/rmq-tools.yaml
index 4b36f087..42885442 100644
--- a/server/src/main/resources/tool-catalog/rmq-tools.yaml
+++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml
@@ -366,3 +366,94 @@ tools:
             type: string
     viewHint: table
     deprecated: false
+  - name: rmq.nameserver.config.diff
+    cli:
+      resource: nameserver-config
+      verb: diff
+    description: Compare safe runtime configuration across NameServers in one 
cluster.
+    riskLevel: L1
+    permission: cluster:read
+    requiredCapabilities:
+      - REMOTING
+    inputSchema:
+      type: object
+      required:
+        - cluster
+      additionalProperties: false
+      properties:
+        cluster:
+          type: string
+          minLength: 1
+    outputSchema:
+      type: object
+      required:
+        - cluster
+        - complete
+        - driftDetected
+        - nodeCount
+        - reachableNodeCount
+        - comparedKeys
+        - nodes
+        - differences
+      additionalProperties: false
+      properties:
+        cluster:
+          type: string
+        complete:
+          type: boolean
+        driftDetected:
+          type: boolean
+        nodeCount:
+          type: integer
+          minimum: 1
+        reachableNodeCount:
+          type: integer
+          minimum: 0
+        comparedKeys:
+          type: array
+          items:
+            type: string
+        nodes:
+          type: array
+          items:
+            type: object
+            required:
+              - address
+              - reachable
+            additionalProperties: false
+            properties:
+              address:
+                type: string
+              reachable:
+                type: boolean
+        differences:
+          type: array
+          items:
+            type: object
+            required:
+              - key
+              - values
+            additionalProperties: false
+            properties:
+              key:
+                type: string
+              values:
+                type: array
+                items:
+                  type: object
+                  required:
+                    - address
+                    - configured
+                    - value
+                  additionalProperties: false
+                  properties:
+                    address:
+                      type: string
+                    configured:
+                      type: boolean
+                    value:
+                      type:
+                        - string
+                        - 'null'
+    viewHint: object
+    deprecated: false
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffServiceTest.java
new file mode 100644
index 00000000..7637eb56
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/nameserver/NameServerConfigDiffServiceTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.rocketmq.studio.cluster.nameserver;
+
+import org.apache.rocketmq.studio.cluster.broker.ClusterService;
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class NameServerConfigDiffServiceTest {
+
+    @Mock
+    private ClusterService clusterService;
+
+    @Mock
+    private MqAdminExtFactory adminFactory;
+
+    @Mock
+    private MQAdminExt admin;
+
+    private NameServerConfigDiffService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new NameServerConfigDiffService(clusterService, 
adminFactory);
+    }
+
+    private void stubAdminFactory() {
+        when(adminFactory.execute(anyString(), isNull(), 
any())).thenAnswer(invocation -> {
+            MqAdminExtFactory.AdminAction<Object> action = 
invocation.getArgument(2);
+            try {
+                return action.apply(admin);
+            } catch (BusinessException exception) {
+                throw exception;
+            } catch (Exception exception) {
+                throw new BusinessException(502, "RocketMQ admin call failed");
+            }
+        });
+    }
+
+    @Test
+    void compareShouldReportConsistentSafeConfiguration() throws Exception {
+        stubAdminFactory();
+        when(clusterService.getCluster("cluster-a")).thenReturn(cluster(
+                "ns-b:9876;ns-a:9876",
+                List.of(nameServer("ns-a:9876"), nameServer("ns-b:9876"))));
+        Properties first = properties(
+                "listenPort", "9876",
+                "serverWorkerThreads", "8",
+                "password", "first-secret");
+        Properties second = properties(
+                "listenPort", "9876",
+                "serverWorkerThreads", "8",
+                "password", "second-secret");
+        when(admin.getNameServerConfig(List.of("ns-a:9876")))
+                .thenReturn(Map.of("ns-a:9876", first));
+        when(admin.getNameServerConfig(List.of("ns-b:9876")))
+                .thenReturn(Map.of("ns-b:9876", second));
+
+        NameServerConfigDiffVO result = service.compare(" cluster-a ");
+
+        assertThat(result.isComplete()).isTrue();
+        assertThat(result.isDriftDetected()).isFalse();
+        assertThat(result.getNodeCount()).isEqualTo(2);
+        assertThat(result.getReachableNodeCount()).isEqualTo(2);
+        assertThat(result.getComparedKeys()).contains("listenPort", 
"serverWorkerThreads");
+        assertThat(result.getComparedKeys()).doesNotContain("password");
+        assertThat(result.getDifferences()).isEmpty();
+        assertThat(result.getNodes())
+                .extracting(
+                        NameServerConfigDiffVO.NodeStatusVO::getAddress,
+                        NameServerConfigDiffVO.NodeStatusVO::isReachable)
+                .containsExactly(
+                        tuple("ns-a:9876", true),
+                        tuple("ns-b:9876", true));
+    }
+
+    @Test
+    void compareShouldExposeChangedAndMissingSafeValues() throws Exception {
+        stubAdminFactory();
+        when(clusterService.getCluster("cluster-a")).thenReturn(cluster(
+                "ns-a:9876;ns-b:9876",
+                List.of(nameServer("ns-a:9876"), nameServer("ns-b:9876"))));
+        Properties first = properties(
+                "listenPort", "9876",
+                "serverWorkerThreads", "8");
+        Properties second = properties("listenPort", "19876");
+        when(admin.getNameServerConfig(List.of("ns-a:9876")))
+                .thenReturn(Map.of("ns-a:9876", first));
+        when(admin.getNameServerConfig(List.of("ns-b:9876")))
+                .thenReturn(Map.of("ns-b:9876", second));
+
+        NameServerConfigDiffVO result = service.compare("cluster-a");
+
+        assertThat(result.isComplete()).isTrue();
+        assertThat(result.isDriftDetected()).isTrue();
+        assertThat(result.getDifferences())
+                .extracting(NameServerConfigDiffVO.ConfigDifferenceVO::getKey)
+                .containsExactly("listenPort", "serverWorkerThreads");
+        NameServerConfigDiffVO.ConfigDifferenceVO workerDifference =
+                result.getDifferences().get(1);
+        assertThat(workerDifference.getValues())
+                .extracting(
+                        NameServerConfigDiffVO.ConfigValueVO::getAddress,
+                        NameServerConfigDiffVO.ConfigValueVO::isConfigured,
+                        NameServerConfigDiffVO.ConfigValueVO::getValue)
+                .containsExactly(
+                        tuple("ns-a:9876", true, "8"),
+                        tuple("ns-b:9876", false, null));
+    }
+
+    @Test
+    void compareShouldKeepPartialResultsWhenOneNodeIsUnavailable() throws 
Exception {
+        stubAdminFactory();
+        when(clusterService.getCluster("cluster-a")).thenReturn(cluster(
+                "ns-a:9876;ns-b:9876",
+                List.of(nameServer("ns-a:9876"), nameServer("ns-b:9876"))));
+        Properties first = properties("listenPort", "9876");
+        when(admin.getNameServerConfig(List.of("ns-a:9876")))
+                .thenReturn(Map.of("ns-a:9876", first));
+        when(admin.getNameServerConfig(List.of("ns-b:9876")))
+                .thenThrow(new IllegalStateException("unreachable"));
+
+        NameServerConfigDiffVO result = service.compare("cluster-a");
+
+        assertThat(result.isComplete()).isFalse();
+        assertThat(result.isDriftDetected()).isFalse();
+        assertThat(result.getReachableNodeCount()).isEqualTo(1);
+        assertThat(result.getDifferences()).isEmpty();
+        assertThat(result.getNodes())
+                .extracting(
+                        NameServerConfigDiffVO.NodeStatusVO::getAddress,
+                        NameServerConfigDiffVO.NodeStatusVO::isReachable)
+                .containsExactly(
+                        tuple("ns-a:9876", true),
+                        tuple("ns-b:9876", false));
+    }
+
+    @Test
+    void compareShouldTreatOneReachableNodeAsACompleteCheck() throws Exception 
{
+        stubAdminFactory();
+        when(clusterService.getCluster("cluster-a"))
+                .thenReturn(cluster("ns-a:9876", List.of()));
+        when(admin.getNameServerConfig(List.of("ns-a:9876")))
+                .thenReturn(Map.of("ns-a:9876", properties("listenPort", 
"9876")));
+
+        NameServerConfigDiffVO result = service.compare("cluster-a");
+
+        assertThat(result.isComplete()).isTrue();
+        assertThat(result.isDriftDetected()).isFalse();
+        assertThat(result.getNodeCount()).isEqualTo(1);
+        assertThat(result.getReachableNodeCount()).isEqualTo(1);
+        assertThat(result.getDifferences()).isEmpty();
+    }
+
+    @Test
+    void compareShouldMarkTheCheckIncompleteWhenEveryNodeIsUnavailable() 
throws Exception {
+        stubAdminFactory();
+        when(clusterService.getCluster("cluster-a")).thenReturn(cluster(
+                "ns-a:9876;ns-b:9876",
+                List.of(nameServer("ns-a:9876"), nameServer("ns-b:9876"))));
+        when(admin.getNameServerConfig(List.of("ns-a:9876")))
+                .thenThrow(new IllegalStateException("unreachable"));
+        when(admin.getNameServerConfig(List.of("ns-b:9876")))
+                .thenThrow(new IllegalStateException("unreachable"));
+
+        NameServerConfigDiffVO result = service.compare("cluster-a");
+
+        assertThat(result.isComplete()).isFalse();
+        assertThat(result.isDriftDetected()).isFalse();
+        assertThat(result.getReachableNodeCount()).isZero();
+        assertThat(result.getDifferences()).isEmpty();
+        assertThat(result.getNodes())
+                .extracting(
+                        NameServerConfigDiffVO.NodeStatusVO::getAddress,
+                        NameServerConfigDiffVO.NodeStatusVO::isReachable)
+                .containsExactly(
+                        tuple("ns-a:9876", false),
+                        tuple("ns-b:9876", false));
+    }
+
+    @Test
+    void compareShouldRejectClusterWithoutNameServers() {
+        when(clusterService.getCluster("cluster-a"))
+                .thenReturn(cluster(null, List.of()));
+
+        assertThatThrownBy(() -> service.compare("cluster-a"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Cluster has no NameServer endpoints: cluster-a")
+                .satisfies(exception -> assertThat(((BusinessException) 
exception).getCode())
+                        .isEqualTo(409));
+    }
+
+    @Test
+    void compareShouldRejectBlankClusterId() {
+        assertThatThrownBy(() -> service.compare(" "))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("cluster is required")
+                .satisfies(exception -> assertThat(((BusinessException) 
exception).getCode())
+                        .isEqualTo(400));
+    }
+
+    private ClusterVO cluster(String endpoint, List<NameServerVO> nameServers) 
{
+        ClusterVO cluster = ClusterVO.builder()
+                .name("cluster-a")
+                .endpoint(endpoint)
+                .nameServers(nameServers)
+                .build();
+        cluster.setId("cluster-a");
+        return cluster;
+    }
+
+    private NameServerVO nameServer(String address) {
+        return NameServerVO.builder().addr(address).build();
+    }
+
+    private Properties properties(String... entries) {
+        Properties properties = new Properties();
+        for (int index = 0; index < entries.length; index += 2) {
+            properties.setProperty(entries[index], entries[index + 1]);
+        }
+        return properties;
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
index 5c098e8c..0a46bb20 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java
@@ -44,7 +44,8 @@ class ToolCatalogTest {
                         "rmq.dashboard.summary",
                         "rmq.topic.list",
                         "rmq.group.list",
-                        "rmq.alert.rule.list");
+                        "rmq.alert.rule.list",
+                        "rmq.nameserver.config.diff");
         assertThat(catalog.find("rmq.cluster.list")).isPresent();
         assertThat(catalog.find("rmq.unknown")).isEmpty();
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
index ae5ef118..8e96db86 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java
@@ -19,6 +19,8 @@ package org.apache.rocketmq.studio.ops.ai.tool;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import 
org.apache.rocketmq.studio.cluster.nameserver.NameServerConfigDiffService;
+import org.apache.rocketmq.studio.cluster.nameserver.NameServerConfigDiffVO;
 import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
 import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
@@ -49,6 +51,7 @@ import java.util.Map;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
@@ -59,6 +62,7 @@ class ToolGatewayServiceTest {
     private DashboardService dashboardService;
     private MetadataService metadataService;
     private AlertService alertService;
+    private NameServerConfigDiffService nameServerConfigDiffService;
     private CapabilityResolver capabilityResolver;
     private ClusterListToolHandler clusterListHandler;
     private CapabilitiesToolHandler capabilitiesHandler;
@@ -66,6 +70,7 @@ class ToolGatewayServiceTest {
     private TopicListToolHandler topicListHandler;
     private ConsumerGroupListToolHandler consumerGroupListHandler;
     private AlertRuleListToolHandler alertRuleListHandler;
+    private NameServerConfigDiffToolHandler nameServerConfigDiffHandler;
     private ToolGatewayService gateway;
 
     @BeforeEach
@@ -75,6 +80,7 @@ class ToolGatewayServiceTest {
         dashboardService = mock(DashboardService.class);
         metadataService = mock(MetadataService.class);
         alertService = mock(AlertService.class);
+        nameServerConfigDiffService = mock(NameServerConfigDiffService.class);
         capabilityResolver = new CapabilityResolver(clusterService);
         clusterListHandler = new ClusterListToolHandler(clusterService);
         capabilitiesHandler = new CapabilitiesToolHandler(clusterService, 
capabilityResolver);
@@ -82,6 +88,8 @@ class ToolGatewayServiceTest {
         topicListHandler = new TopicListToolHandler(metadataService);
         consumerGroupListHandler = new 
ConsumerGroupListToolHandler(metadataService);
         alertRuleListHandler = new AlertRuleListToolHandler(alertService);
+        nameServerConfigDiffHandler = new NameServerConfigDiffToolHandler(
+                nameServerConfigDiffService);
         gateway = gateway(
                 catalog,
                 clusterListHandler,
@@ -89,7 +97,8 @@ class ToolGatewayServiceTest {
                 dashboardSummaryHandler,
                 topicListHandler,
                 consumerGroupListHandler,
-                alertRuleListHandler);
+                alertRuleListHandler,
+                nameServerConfigDiffHandler);
     }
 
     @Test
@@ -112,7 +121,63 @@ class ToolGatewayServiceTest {
                         "rmq.dashboard.summary",
                         "rmq.topic.list",
                         "rmq.group.list",
-                        "rmq.alert.rule.list");
+                        "rmq.alert.rule.list",
+                        "rmq.nameserver.config.diff");
+    }
+
+    @Test
+    void executesNameServerConfigDiffWithAValidatedOutputContract() {
+        when(clusterService.getCluster("cluster-v5"))
+                .thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER));
+        when(nameServerConfigDiffService.compare("cluster-v5"))
+                .thenReturn(NameServerConfigDiffVO.builder()
+                        .cluster("cluster-v5")
+                        .complete(true)
+                        .driftDetected(true)
+                        .nodeCount(2)
+                        .reachableNodeCount(2)
+                        .comparedKeys(List.of("listenPort"))
+                        .nodes(List.of(
+                                NameServerConfigDiffVO.NodeStatusVO.builder()
+                                        .address("ns-a:9876")
+                                        .reachable(true)
+                                        .build(),
+                                NameServerConfigDiffVO.NodeStatusVO.builder()
+                                        .address("ns-b:9876")
+                                        .reachable(true)
+                                        .build()))
+                        .differences(List.of(
+                                
NameServerConfigDiffVO.ConfigDifferenceVO.builder()
+                                        .key("listenPort")
+                                        .values(List.of(
+                                                
NameServerConfigDiffVO.ConfigValueVO.builder()
+                                                        .address("ns-a:9876")
+                                                        .configured(true)
+                                                        .value("9876")
+                                                        .build(),
+                                                
NameServerConfigDiffVO.ConfigValueVO.builder()
+                                                        .address("ns-b:9876")
+                                                        .configured(false)
+                                                        .build()))
+                                        .build()))
+                        .build());
+
+        Object output = gateway.execute(
+                "rmq.nameserver.config.diff",
+                Map.of("cluster", "cluster-v5"));
+
+        assertThat(output).isInstanceOf(NameServerConfigDiffVO.class);
+        assertThat(((NameServerConfigDiffVO) 
output).isDriftDetected()).isTrue();
+        verify(nameServerConfigDiffService).compare("cluster-v5");
+    }
+
+    @Test
+    void rejectsNameServerConfigDiffWithoutAClusterBeforeHandlerRuns() {
+        assertThatThrownBy(() -> gateway.execute(
+                "rmq.nameserver.config.diff", Map.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("input validation failed");
+        verifyNoInteractions(nameServerConfigDiffService);
     }
 
     @Test
@@ -415,7 +480,8 @@ class ToolGatewayServiceTest {
                 dashboardSummaryHandler,
                 topicListHandler,
                 consumerGroupListHandler,
-                alertRuleListHandler);
+                alertRuleListHandler,
+                nameServerConfigDiffHandler);
 
         assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list", 
Map.of()))
                 .isInstanceOf(BusinessException.class)
@@ -433,7 +499,8 @@ class ToolGatewayServiceTest {
                 dashboardSummaryHandler,
                 topicListHandler,
                 consumerGroupListHandler,
-                alertRuleListHandler))
+                alertRuleListHandler,
+                nameServerConfigDiffHandler))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("duplicate handler");
     }
@@ -468,7 +535,8 @@ class ToolGatewayServiceTest {
                 dashboardSummaryHandler,
                 topicListHandler,
                 consumerGroupListHandler,
-                alertRuleListHandler))
+                alertRuleListHandler,
+                nameServerConfigDiffHandler))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("input schema")
                 .hasMessageContaining("rmq.cluster.list");
@@ -496,7 +564,8 @@ class ToolGatewayServiceTest {
                 dashboardSummaryHandler,
                 topicListHandler,
                 consumerGroupListHandler,
-                alertRuleListHandler))
+                alertRuleListHandler,
+                nameServerConfigDiffHandler))
                 .isInstanceOf(IllegalStateException.class)
                 .hasMessageContaining("input schema")
                 .hasMessageContaining("rmq.cluster.list");
@@ -522,7 +591,8 @@ class ToolGatewayServiceTest {
                 dashboardSummaryHandler,
                 topicListHandler,
                 consumerGroupListHandler,
-                alertRuleListHandler);
+                alertRuleListHandler,
+                nameServerConfigDiffHandler);
 
         assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list", 
Map.of()))
                 .isInstanceOf(IllegalStateException.class)

Reply via email to