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)