feat(rocketmq): replace Java agent with native Go implementation

This commit is contained in:
t8y2
2026-08-12 23:15:20 +08:00
parent baec487a21
commit 9fb70bcfc9
44 changed files with 4872 additions and 5414 deletions
+2 -1
View File
@@ -58,9 +58,10 @@ const nativeDriverDirectories = {
neo4j: "neo4j-go",
vastbase: "vastbase-go",
rabbitmq: "rabbitmq",
rocketmq: "rocketmq",
tdengine: "tdengine",
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "tdengine"]);
const nativeDriverSharedPaths = {
hive: [
"agents/go-common/go-gssapi",
@@ -59,6 +59,18 @@ test("bumps the native RabbitMQ agent from its Go directory", () => {
assert.equal(result.versions.rabbitmq, "0.1.1");
});
test("bumps the native RocketMQ agent from its Go directory", () => {
const result = evaluateAgentVersionBump({
versions: { rocketmq: "0.1.0" },
changedFiles: ["agents/drivers/rocketmq/main.go"],
moduleExists: (path) => path === "agents/drivers/rocketmq",
readModuleFile: () => "",
});
assert.equal(result.versions.rocketmq, "0.1.1");
assert.deepEqual(result.nativeModules, ["rocketmq"]);
});
test("bumps the native Vastbase agent from its independent Go directory", () => {
const result = evaluateAgentVersionBump({
versions: { vastbase: "0.1.37" },
@@ -15,7 +15,7 @@ import { basename, join } from "node:path";
import { tmpdir } from "node:os";
const REGISTRY_ASSET = "agent-registry.json";
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "tdengine"]);
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "tdengine"]);
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
@@ -114,6 +114,24 @@ test("requires all IoTDB native platforms when reusing a release", () => {
);
});
test("requires all RocketMQ native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { rocketmq: "0.1.40" },
modules: ["rocketmq"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
const native = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
+44 -2
View File
@@ -289,6 +289,46 @@ jobs:
name: rabbitmq-native
path: "release-native/dbx-agent-rabbitmq-*"
build-rocketmq-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'rocketmq') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Test RocketMQ native agent
working-directory: agents/drivers/rocketmq
run: go test -race ./...
- name: Cross-compile RocketMQ native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/rocketmq
declare -A TARGETS=(
["macos-aarch64"]="darwin/arm64"
["macos-x64"]="darwin/amd64"
["linux-aarch64"]="linux/arm64"
["linux-x64"]="linux/amd64"
["windows-aarch64"]="windows/arm64"
["windows-x64"]="windows/amd64"
)
for platform in "${!TARGETS[@]}"; do
IFS=/ read -r goos goarch <<< "${TARGETS[$platform]}"
output="../../../release-native/dbx-agent-rocketmq-${platform}"
if [[ "$goos" == "windows" ]]; then
output="${output}.exe"
fi
echo "Building $platform ($goos/$goarch)"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
ls -lh ../../../release-native
- uses: actions/upload-artifact@v4
with:
name: rocketmq-native
path: "release-native/dbx-agent-rocketmq-*"
build-cassandra-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'cassandra') }}
@@ -868,7 +908,7 @@ jobs:
retention-days: 1
release:
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-rocketmq-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-tdengine-native, build-jre, reuse-previous-assets]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: ubuntu-latest
steps:
@@ -990,6 +1030,7 @@ jobs:
duckdb) echo "DuckDB" ;;
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
rocketmq) echo "Apache RocketMQ" ;;
cassandra) echo "Apache Cassandra" ;;
hive) echo "Apache Hive" ;;
neo4j) echo "Neo4j" ;;
@@ -1058,7 +1099,7 @@ jobs:
[ -n "$DRIVERS" ] && DRIVERS="${DRIVERS},"$'\n'
DRIVERS="${DRIVERS}$(generate_jar_entry "$name" "$label" "$f" "$jre_key" "$version" "$external_driver" "$native_json")"
done
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq cassandra hive tdengine; do
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq rocketmq cassandra hive tdengine; do
version=$(get_module_version "$name")
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
native_json=$(generate_native_platforms "$name" "$version")
@@ -1128,6 +1169,7 @@ jobs:
oracle) echo "Oracle" ;;
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
rocketmq) echo "Apache RocketMQ" ;;
cassandra) echo "Apache Cassandra" ;;
hive) echo "Apache Hive" ;;
neo4j) echo "Neo4j" ;;
+17
View File
@@ -740,6 +740,10 @@ jobs:
run: go test ./...
working-directory: agents/drivers/rabbitmq
- name: RocketMQ native agent tests
run: go test -race ./...
working-directory: agents/drivers/rocketmq
- name: Cassandra native agent tests
run: go test ./...
working-directory: agents/drivers/cassandra-go
@@ -772,6 +776,19 @@ jobs:
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rabbitmq-linux-x64 .
working-directory: agents/drivers/rabbitmq
- name: RocketMQ native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-rocketmq-linux-x64 .
working-directory: agents/drivers/rocketmq
- name: RocketMQ native agent integration tests
shell: bash
working-directory: agents/drivers/rocketmq
run: |
set -euo pipefail
for version in 4.9.8 5.3.1; do
./scripts/run-integration.sh "$version"
done
- name: Cassandra native agent build
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /tmp/dbx-agent-cassandra-linux-x64 .
working-directory: agents/drivers/cassandra-go
+6 -4
View File
@@ -45,11 +45,12 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| rabbitmq | RabbitMQ | amqp091-go native agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
## Multi-JRE Support
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, and `rocketmq`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
## JDBC Connection Pooling
@@ -75,7 +76,7 @@ Set `DBX_AGENT_JDBC_POOL_ENABLED=false` for a runtime-level compatibility fallba
For new agents, prefer a **native (Go or Rust) driver** over a Java/JDBC agent whenever a mature, license-compatible native driver is available. Native agents ship as a single self-contained executable with no JRE, which significantly reduces memory footprint and startup time — the JVM baseline that every Java agent pays even when idle is avoided entirely.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/hive-go` (native HS2), `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, and `drivers/rabbitmq` (amqp091-go) as reference implementations. No JRE download or management is needed.
- **Native (C++/Go/Rust)** — preferred when a usable native driver exists. See `drivers/cassandra-go` (Apache cassandra-gocql-driver), `drivers/duckdb`, `drivers/hive-go` (native HS2), `drivers/iotdb` (Apache IoTDB Go Client), `drivers/oracle-go` (go-ora), `drivers/kingbase-go` (gokb), `drivers/vastbase-go` (openGauss connector), `drivers/tdengine` (taos-connector-rust), `drivers/xugu`, `drivers/rabbitmq` (amqp091-go), and `drivers/rocketmq` (rocketmq-admin-go) as reference implementations. No JRE download or management is needed.
- **Java/JDBC** — the default fallback when only a JDBC driver exists for the database, or when the native driver is immature or unmaintained. Most agents still fall in this category.
Native agents implement the same JSON-RPC contract and `versions.json` registration as Java agents; they ship an `agent` executable instead of `agent.jar`. If both native and Java source implementations exist for the same database, publish only the native artifact unless the Java variant has a separately registered compatibility profile, such as `oracle-legacy` / `oracle-10g`.
@@ -95,9 +96,10 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
(cd drivers/rocketmq && go build -o agent .)
```
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/hive-go`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, and `drivers/rabbitmq`.
Output JARs are in `drivers/{module}/build/libs/`. Native agents build from `drivers/cassandra-go`, `drivers/duckdb`, `drivers/hive-go`, `drivers/iotdb`, `drivers/oracle-go`, `drivers/kingbase-go`, `drivers/vastbase-go`, `drivers/tdengine`, `drivers/xugu`, `drivers/rabbitmq`, and `drivers/rocketmq`.
### Local DBX Runtime Test
@@ -111,7 +113,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
Restart DBX or disconnect and reconnect the database so the new agent process loads the replacement JAR.
Native agents such as `cassandra`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, and `rabbitmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
Native agents such as `cassandra`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, and `rocketmq` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
## Versioning
+6 -4
View File
@@ -45,11 +45,12 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
## 多 JRE 支持
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq` 和 `rocketmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
## JDBC 连接池
@@ -75,7 +76,7 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
对于新 agent,只要存在成熟、许可证兼容的原生驱动,优先选择**原生(Go 或 Rust)驱动**而非 Java/JDBC agent。原生 agent 以单一自包含可执行文件发布,无需 JRE,可显著降低内存占用和启动时间 —— 完全避开 Java agent 即便空闲也要付出的 JVM 基线开销。
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/hive-go`(原生 HS2)、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu` 和 `drivers/rabbitmq`(amqp091-go)。无需 JRE 下载与管理。
- **原生(Go/Rust)** —— 存在可用原生驱动时首选。参考 `drivers/cassandra-go`(Apache cassandra-gocql-driver)、`drivers/duckdb`、`drivers/hive-go`(原生 HS2)、`drivers/iotdb`(Apache IoTDB Go Client)、`drivers/oracle-go`(go-ora)、`drivers/kingbase-go`(gokb)、`drivers/vastbase-go`(openGauss connector)、`drivers/tdengine`(taos-connector-rust)、`drivers/xugu`、`drivers/rabbitmq`(amqp091-go)和 `drivers/rocketmq`(rocketmq-admin-go)。无需 JRE 下载与管理。
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`。
@@ -95,9 +96,10 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
(cargo build --manifest-path drivers/tdengine/Cargo.toml --release --locked)
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
(cd drivers/rocketmq && go build -o agent .)
```
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/hive-go`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu` 和 `drivers/rabbitmq` 构建。
产物 JAR 在 `drivers/{module}/build/libs/`。原生 agent 从 `drivers/cassandra-go`、`drivers/duckdb`、`drivers/hive-go`、`drivers/iotdb`、`drivers/oracle-go`、`drivers/kingbase-go`、`drivers/vastbase-go`、`drivers/tdengine`、`drivers/xugu`、`drivers/rabbitmq` 和 `drivers/rocketmq` 构建。
### 本地 DBX 运行时测试
@@ -111,7 +113,7 @@ cp agents/drivers/<db_type>/build/libs/*-all.jar ~/.dbx/agents/drivers/<db_type>
重启 DBX 或断开重连数据库,使新 agent 进程加载替换后的 JAR。
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu` 和 `rabbitmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq` 和 `rocketmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
## 版本管理
+1 -1
View File
@@ -3,7 +3,7 @@ plugins {
}
def infrastructureProjects = ['common', 'test-support'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka', 'rocketmq'] as Set
def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
def pooledJdbcProjects = [
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
admin "github.com/amigoer/rocketmq-admin-go"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
)
type aclWire struct {
Subject string `json:"subject"`
Policies []aclPolicyWire `json:"policies"`
}
type aclPolicyWire struct {
PolicyType string `json:"policyType,omitempty"`
Entries []aclEntryWire `json:"entries"`
}
type aclEntryWire struct {
Resource string `json:"resource"`
Actions []string `json:"actions"`
SourceIPs []string `json:"sourceIps"`
Decision string `json:"decision"`
}
func (a *rocketMQAgent) listACLs(params map[string]any) (any, error) {
_, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
address, err := a.brokerAddressForName(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
response, err := invokeRemotingWithClient(ctx, address, remoting.NewRequest(remoting.ListAcl, map[string]string{
"subjectFilter": strings.TrimPrefix(stringValue(params, "principal", "subject"), "User:"),
"resourceFilter": stringValue(params, "resourceName"),
}))
if err != nil {
return nil, err
}
var wrapper struct {
Acls []aclWire `json:"acls"`
}
body := repairRocketMQJSON(response.Body)
if len(body) > 0 && body[0] == '[' {
if err := json.Unmarshal(body, &wrapper.Acls); err != nil {
return nil, fmt.Errorf("decode ACL list: %w", err)
}
} else if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, fmt.Errorf("decode ACL list: %w", err)
}
principalFilter := strings.TrimPrefix(stringValue(params, "principal", "subject"), "User:")
resourceFilter := stringValue(params, "resourceName")
rows := make([]map[string]any, 0)
for _, acl := range wrapper.Acls {
if principalFilter != "" && acl.Subject != principalFilter && acl.Subject != "User:"+principalFilter {
continue
}
for _, policy := range acl.Policies {
for _, entry := range policy.Entries {
if resourceFilter != "" && entry.Resource != resourceFilter {
continue
}
actions := entry.Actions
if len(actions) == 0 {
actions = []string{"ALL"}
}
for _, action := range actions {
host := "*"
if len(entry.SourceIPs) > 0 {
host = strings.Join(entry.SourceIPs, ",")
}
rows = append(rows, map[string]any{
"resourceType": "TOPIC", "resourceName": entry.Resource,
"patternType": "LITERAL", "principal": acl.Subject,
"host": host, "operation": normalizeACLOperation(action),
"permissionType": entry.Decision,
})
}
}
}
}
return map[string]any{"acls": rows}, nil
}
func (a *rocketMQAgent) createACLs(params map[string]any) (any, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
address, err := a.brokerAddressForName(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
entries, _ := params["acls"].([]any)
for _, raw := range entries {
entry, _ := raw.(map[string]any)
if _, hasAccessKey := entry["accessKey"]; hasAccessKey || entry["secretKey"] != nil {
if err := client.UpdatePlainAccessConfig(ctx, address, plainAccessConfig(entry)); err != nil {
return nil, err
}
continue
}
subject := strings.TrimPrefix(stringValue(entry, "principal", "subject"), "User:")
if subject == "" {
return nil, fmt.Errorf("ACL principal is required")
}
resource := stringValue(entry, "resourceName")
if resource == "" {
resource = "*"
}
host := stringValue(entry, "host")
if host == "" {
host = "*"
}
decision := stringValue(entry, "permissionType")
if decision == "" {
decision = "ALLOW"
}
acl := aclWire{Subject: subject, Policies: []aclPolicyWire{{Entries: []aclEntryWire{{
Resource: resource, Actions: []string{mapACLOperation(stringValue(entry, "operation"))},
SourceIPs: []string{host}, Decision: decision,
}}}}}
body, marshalErr := json.Marshal(acl)
if marshalErr != nil {
return nil, marshalErr
}
command := remoting.NewRequest(remoting.CreateAcl, map[string]string{"subject": subject})
command.Body = body
if _, err := invokeRemotingWithClient(ctx, address, command); err != nil {
return nil, err
}
}
return okResult(), nil
}
func (a *rocketMQAgent) deleteACLs(params map[string]any) (any, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
address, err := a.brokerAddressForName(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
filters, _ := params["filters"].([]any)
if len(filters) == 0 {
listed, listErr := a.listACLs(params)
if listErr != nil {
return nil, listErr
}
for _, row := range listed.(map[string]any)["acls"].([]map[string]any) {
filters = append(filters, row)
}
}
deleted := 0
seen := make(map[string]struct{})
for _, raw := range filters {
filter, _ := raw.(map[string]any)
if accessKey := stringValue(filter, "accessKey"); accessKey != "" {
if err := client.DeletePlainAccessConfig(ctx, address, accessKey); err != nil {
return nil, err
}
deleted++
continue
}
subject := strings.TrimPrefix(stringValue(filter, "principal", "subject"), "User:")
resource := stringValue(filter, "resourceName")
key := subject + "|" + resource
if subject == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
fields := map[string]string{"subject": subject}
if resource != "" {
fields["resource"] = resource
}
if _, err := invokeRemotingWithClient(ctx, address, remoting.NewRequest(remoting.DeleteAcl, fields)); err != nil {
return nil, err
}
deleted++
}
return map[string]any{"ok": true, "deleted": deleted}, nil
}
func plainAccessConfig(entry map[string]any) admin.PlainAccessConfig {
return admin.PlainAccessConfig{
AccessKey: stringValue(entry, "accessKey"), SecretKey: stringValue(entry, "secretKey"),
WhiteRemoteAddress: stringValue(entry, "whiteRemoteAddress"),
Admin: boolValue(entry, false, "admin"),
DefaultTopicPerm: stringValue(entry, "defaultTopicPerm"),
DefaultGroupPerm: stringValue(entry, "defaultGroupPerm"),
TopicPerms: stringSlice(entry["topicPerms"]), GroupPerms: stringSlice(entry["groupPerms"]),
}
}
func stringSlice(value any) []string {
items, _ := value.([]any)
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, fmt.Sprint(item))
}
return result
}
func mapACLOperation(operation string) string {
switch strings.ToUpper(operation) {
case "WRITE", "PRODUCE", "PUB":
return "PUB"
case "READ", "CONSUME", "SUB":
return "SUB"
default:
return "ALL"
}
}
func normalizeACLOperation(operation string) string {
switch strings.ToUpper(operation) {
case "PUB":
return "WRITE"
case "SUB":
return "READ"
default:
return strings.ToUpper(operation)
}
}
-13
View File
@@ -1,13 +0,0 @@
dependencies {
implementation 'com.google.code.gson:gson:2.12.1'
implementation 'org.apache.rocketmq:rocketmq-client:5.3.1'
implementation 'org.apache.rocketmq:rocketmq-tools:5.3.1'
runtimeOnly 'org.slf4j:slf4j-simple:1.7.36'
}
tasks.named('shadowJar') {
mergeServiceFiles()
manifest {
attributes('Agent-Label': 'Apache RocketMQ', 'Main-Class': 'com.dbx.agent.rocketmq.RocketMqAgent')
}
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"strconv"
admin "github.com/amigoer/rocketmq-admin-go"
)
func (a *rocketMQAgent) describeCluster(_ map[string]any) (any, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
info, err := client.ExamineBrokerClusterInfo(ctx)
if err != nil {
return nil, err
}
a.mu.RLock()
proxies := a.proxies
clusterName := a.clusterName
a.mu.RUnlock()
brokers := brokerNodes(info, proxies)
var controller any
if len(brokers) > 0 {
controller = brokers[0]
}
return map[string]any{
"clusterId": clusterName, "controller": controller,
"brokers": brokers, "nodeCount": len(brokers),
}, nil
}
func brokerNodes(info *admin.ClusterInfo, proxies *proxyManager) []map[string]any {
if info == nil {
return []map[string]any{}
}
nodes := make([]map[string]any, 0)
id := 0
for _, brokerName := range sortedKeys(info.BrokerAddrTable) {
broker := info.BrokerAddrTable[brokerName]
if broker == nil {
continue
}
for _, brokerID := range sortedKeys(broker.BrokerAddrs) {
address := broker.BrokerAddrs[brokerID]
if original := proxies.OriginalForLocal(address); original != "" {
address = original
}
host, portText := parseSocketAddress(address)
port, _ := strconv.Atoi(portText)
brokerIDValue, _ := strconv.ParseInt(brokerID, 10, 64)
role := "SLAVE"
if brokerIDValue == 0 {
role = "MASTER"
}
nodes = append(nodes, map[string]any{
"id": id, "host": host, "port": port, "rack": nil,
"brokerName": broker.BrokerName, "brokerId": brokerIDValue, "role": role,
})
id++
}
}
return nodes
}
+295
View File
@@ -0,0 +1,295 @@
package main
import (
"context"
"fmt"
"time"
admin "github.com/amigoer/rocketmq-admin-go"
)
const (
defaultRequestTimeout = 30 * time.Second
defaultConnectTimeout = 10 * time.Second
brokerRegistrationPollInterval = 100 * time.Millisecond
)
type socksProxyConfig struct {
Host string
Port int
Username string
Password string
}
type connectionConfig struct {
NameServers []string
ClusterName string
BrokerAddr string
AccessKey string
SecretKey string
RequestTimeout time.Duration
ConnectTimeout time.Duration
TLSSkipVerify bool
SocksProxy *socksProxyConfig
}
func parseConnection(params map[string]any) (connectionConfig, error) {
connection := nestedMap(params, "connection")
if connection == nil {
connection = params
}
nameServers := splitAddresses(stringValue(connection, "namesrv_addr", "namesrvAddr"))
if len(nameServers) == 0 {
return connectionConfig{}, fmt.Errorf("namesrv_addr is required")
}
config := connectionConfig{
NameServers: nameServers,
ClusterName: stringValue(connection, "cluster_name", "clusterName"),
BrokerAddr: stringValue(connection, "broker_addr", "brokerAddr"),
AccessKey: stringValue(connection, "access_key", "accessKey"),
SecretKey: stringValue(connection, "secret_key", "secretKey"),
RequestTimeout: time.Duration(intValue(connection, int(defaultRequestTimeout/time.Millisecond), "request_timeout_ms")) * time.Millisecond,
ConnectTimeout: time.Duration(intValue(connection, int(defaultConnectTimeout/time.Millisecond), "connect_timeout_ms")) * time.Millisecond,
TLSSkipVerify: boolValue(connection, false, "tls_skip_verify"),
}
if config.RequestTimeout <= 0 {
config.RequestTimeout = defaultRequestTimeout
}
if config.ConnectTimeout <= 0 {
config.ConnectTimeout = defaultConnectTimeout
}
if proxy := nestedMap(connection, "socks_proxy"); proxy != nil {
host := stringValue(proxy, "host")
port := intValue(proxy, 0, "port")
if host == "" || port <= 0 || port > 65535 {
return connectionConfig{}, fmt.Errorf("socks_proxy host and port are required")
}
config.SocksProxy = &socksProxyConfig{
Host: host, Port: port,
Username: stringValue(proxy, "username"),
Password: stringValue(proxy, "password"),
}
}
return config, nil
}
func (a *rocketMQAgent) connect(params map[string]any) (any, error) {
config, err := parseConnection(params)
if err != nil {
return nil, err
}
client, proxies, clusterInfo, err := buildClient(config)
if err != nil {
return nil, err
}
clusterName := resolveClusterName(clusterInfo, config.ClusterName)
brokerAddr, err := resolveBrokerAddr(clusterInfo, config, proxies)
if err != nil {
client.Close()
proxies.Close()
return nil, err
}
a.mu.Lock()
oldClient, oldProxies := a.client, a.proxies
a.client = client
a.proxies = proxies
a.connection = config
a.clusterName = clusterName
a.brokerAddr = brokerAddr
a.mu.Unlock()
if oldClient != nil {
_ = oldClient.Close()
}
if oldProxies != nil {
oldProxies.Close()
}
return clusterTestResult(client, clusterInfo, config, proxies), nil
}
func (a *rocketMQAgent) testConnection(params map[string]any) (any, error) {
config, err := parseConnection(params)
if err != nil {
return nil, err
}
a.mu.RLock()
if a.client != nil && a.connection.equal(config) {
client, proxies := a.client, a.proxies
a.mu.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
clusterInfo, err := client.ExamineBrokerClusterInfo(ctx)
if err != nil {
return nil, err
}
return clusterTestResult(client, clusterInfo, config, proxies), nil
}
a.mu.RUnlock()
client, proxies, clusterInfo, err := buildClient(config)
if err != nil {
return nil, err
}
defer client.Close()
defer proxies.Close()
return clusterTestResult(client, clusterInfo, config, proxies), nil
}
func buildClient(config connectionConfig) (*admin.Client, *proxyManager, *admin.ClusterInfo, error) {
proxies := newProxyManager(config)
localNameServers := make([]string, 0, len(config.NameServers))
for _, address := range config.NameServers {
local, err := proxies.ProxyFor(address, proxyTargetNameServer)
if err != nil {
proxies.Close()
return nil, nil, nil, err
}
localNameServers = append(localNameServers, local)
}
client, err := admin.NewClient(
admin.WithNameServers(localNameServers),
admin.WithTimeout(config.RequestTimeout),
admin.WithRetryTimes(1),
)
if err != nil {
proxies.Close()
return nil, nil, nil, err
}
if err := client.Start(); err != nil {
proxies.Close()
return nil, nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.ConnectTimeout)
defer cancel()
clusterInfo, err := waitForBrokerRegistration(ctx, brokerRegistrationPollInterval, client.ExamineBrokerClusterInfo)
if err != nil {
client.Close()
proxies.Close()
return nil, nil, nil, err
}
return client, proxies, clusterInfo, nil
}
func waitForBrokerRegistration(
ctx context.Context,
pollInterval time.Duration,
examine func(context.Context) (*admin.ClusterInfo, error),
) (*admin.ClusterInfo, error) {
if pollInterval <= 0 {
pollInterval = brokerRegistrationPollInterval
}
var lastErr error
for {
clusterInfo, err := examine(ctx)
if err == nil && hasMasterBroker(clusterInfo) {
return clusterInfo, nil
}
if err != nil {
lastErr = err
}
timer := time.NewTimer(pollInterval)
select {
case <-ctx.Done():
timer.Stop()
if lastErr != nil {
return nil, fmt.Errorf("wait for RocketMQ broker registration after %v: %w", lastErr, ctx.Err())
}
return nil, fmt.Errorf("wait for RocketMQ broker registration: %w", ctx.Err())
case <-timer.C:
}
}
}
func hasMasterBroker(info *admin.ClusterInfo) bool {
if info == nil {
return false
}
for _, broker := range info.BrokerAddrTable {
if broker != nil && broker.BrokerAddrs["0"] != "" {
return true
}
}
return false
}
func clusterTestResult(client *admin.Client, info *admin.ClusterInfo, config connectionConfig, proxies *proxyManager) map[string]any {
brokers := brokerNodes(info, proxies)
aclEnabled := false
if len(brokers) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), minDuration(config.ConnectTimeout/2, 5*time.Second))
defer cancel()
brokerAddr, err := resolveBrokerAddr(info, config, proxies)
if err == nil {
_, aclErr := client.GetBrokerClusterAclInfo(ctx, brokerAddr)
aclEnabled = aclErr == nil
}
}
var controller any
if len(brokers) > 0 {
controller = brokers[0]
}
return map[string]any{
"ok": true, "clusterId": resolveClusterName(info, config.ClusterName),
"brokers": brokers, "nodeCount": len(brokers), "controller": controller,
"aclEnabled": aclEnabled,
}
}
func (config connectionConfig) equal(other connectionConfig) bool {
if config.ClusterName != other.ClusterName || config.BrokerAddr != other.BrokerAddr ||
config.AccessKey != other.AccessKey || config.SecretKey != other.SecretKey ||
config.RequestTimeout != other.RequestTimeout || config.ConnectTimeout != other.ConnectTimeout ||
config.TLSSkipVerify != other.TLSSkipVerify ||
len(config.NameServers) != len(other.NameServers) {
return false
}
for index := range config.NameServers {
if config.NameServers[index] != other.NameServers[index] {
return false
}
}
if (config.SocksProxy == nil) != (other.SocksProxy == nil) {
return false
}
return config.SocksProxy == nil || *config.SocksProxy == *other.SocksProxy
}
func resolveClusterName(info *admin.ClusterInfo, configured string) string {
if configured != "" {
return configured
}
for _, name := range sortedKeys(info.ClusterAddrTable) {
return name
}
return "DefaultCluster"
}
func resolveBrokerAddr(info *admin.ClusterInfo, config connectionConfig, proxies *proxyManager) (string, error) {
if config.BrokerAddr != "" {
if local := proxies.LocalForOriginal(config.BrokerAddr); local != "" {
return local, nil
}
return proxies.ProxyFor(config.BrokerAddr, proxyTargetBroker)
}
for _, brokerName := range sortedKeys(info.BrokerAddrTable) {
broker := info.BrokerAddrTable[brokerName]
if address := broker.BrokerAddrs["0"]; address != "" {
return address, nil
}
for _, brokerID := range sortedKeys(broker.BrokerAddrs) {
if broker.BrokerAddrs[brokerID] != "" {
return broker.BrokerAddrs[brokerID], nil
}
}
}
return "", fmt.Errorf("no RocketMQ broker address found")
}
func minDuration(left, right time.Duration) time.Duration {
if left <= 0 || left > right {
return right
}
return left
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"context"
"errors"
"reflect"
"testing"
"time"
admin "github.com/amigoer/rocketmq-admin-go"
)
func TestParseConnectionSupportsAliasesAndMultipleNameServers(t *testing.T) {
config, err := parseConnection(map[string]any{"connection": map[string]any{
"namesrvAddr": "127.0.0.1:9876; [2001:db8::1]:9876,127.0.0.1:9876",
"clusterName": "DefaultCluster",
"brokerAddr": "broker.example.com:10911",
"accessKey": "access",
"secretKey": "secret",
"request_timeout_ms": float64(12_000),
"connect_timeout_ms": "4500",
"tls_skip_verify": true,
}})
if err != nil {
t.Fatal(err)
}
wantServers := []string{"127.0.0.1:9876", "[2001:db8::1]:9876"}
if !reflect.DeepEqual(config.NameServers, wantServers) {
t.Fatalf("NameServers = %#v, want %#v", config.NameServers, wantServers)
}
if config.ClusterName != "DefaultCluster" || config.BrokerAddr != "broker.example.com:10911" {
t.Fatalf("unexpected route config: %#v", config)
}
if config.AccessKey != "access" || config.SecretKey != "secret" {
t.Fatalf("unexpected credentials: %#v", config)
}
if config.RequestTimeout != 12*time.Second || config.ConnectTimeout != 4500*time.Millisecond {
t.Fatalf("unexpected timeouts: %#v", config)
}
if !config.TLSSkipVerify {
t.Fatal("tls_skip_verify was not parsed")
}
}
func TestParseConnectionValidatesNameServerAndSocksProxy(t *testing.T) {
if _, err := parseConnection(map[string]any{}); err == nil {
t.Fatal("expected missing namesrv_addr to fail")
}
if _, err := parseConnection(map[string]any{
"namesrv_addr": "127.0.0.1:9876",
"socks_proxy": map[string]any{"host": "127.0.0.1"},
}); err == nil {
t.Fatal("expected incomplete socks_proxy to fail")
}
}
func TestConnectionConfigEqualIncludesTLSSkipVerify(t *testing.T) {
base := connectionConfig{NameServers: []string{"127.0.0.1:9876"}}
other := base
other.TLSSkipVerify = true
if base.equal(other) {
t.Fatal("configs with different tls_skip_verify values must not be equal")
}
}
func TestWaitForBrokerRegistrationRetriesUntilMasterAppears(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
calls := 0
info, err := waitForBrokerRegistration(ctx, time.Millisecond, func(context.Context) (*admin.ClusterInfo, error) {
calls++
if calls < 3 {
return &admin.ClusterInfo{BrokerAddrTable: map[string]*admin.BrokerData{}}, nil
}
return &admin.ClusterInfo{BrokerAddrTable: map[string]*admin.BrokerData{
"broker-a": {BrokerAddrs: map[string]string{"0": "127.0.0.1:10911"}},
}}, nil
})
if err != nil {
t.Fatal(err)
}
if calls != 3 || !hasMasterBroker(info) {
t.Fatalf("calls = %d, info = %#v", calls, info)
}
}
func TestWaitForBrokerRegistrationTimesOut(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, err := waitForBrokerRegistration(ctx, time.Millisecond, func(context.Context) (*admin.ClusterInfo, error) {
return &admin.ClusterInfo{BrokerAddrTable: map[string]*admin.BrokerData{}}, nil
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want context deadline exceeded", err)
}
}
func TestParseAndFormatSocketAddress(t *testing.T) {
tests := []struct {
address string
host string
port string
formatted string
}{
{"127.0.0.1:9876", "127.0.0.1", "9876", "127.0.0.1:9876"},
{"[2001:db8::1]:9876", "2001:db8::1", "9876", "[2001:db8::1]:9876"},
{"broker.example.com", "broker.example.com", "", "broker.example.com"},
}
for _, test := range tests {
host, port := parseSocketAddress(test.address)
if host != test.host || port != test.port {
t.Fatalf("parseSocketAddress(%q) = %q, %q", test.address, host, port)
}
if formatted := formatSocketAddress(host, port); formatted != test.formatted {
t.Fatalf("formatSocketAddress(%q, %q) = %q", host, port, formatted)
}
}
}
+525
View File
@@ -0,0 +1,525 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
admin "github.com/amigoer/rocketmq-admin-go"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
)
var systemConsumerGroups = map[string]struct{}{
"TOOLS_CONSUMER": {}, "FILTERSRV_CONSUMER": {}, "SELF_TEST_C_GROUP": {},
"CID_ONS-HTTP-PROXY": {}, "CID_ONSAPI_PULL": {}, "CID_ONSAPI_PERMISSION": {},
"CID_ONSAPI_OWNER": {}, "CID_SYS_RMQ_TRANS": {}, "CID_DefaultHeartBeatSyncerTopic": {},
}
type subscriptionGroupConfig struct {
GroupName string `json:"groupName"`
ConsumeEnable bool `json:"consumeEnable"`
ConsumeFromMinEnable bool `json:"consumeFromMinEnable"`
ConsumeBroadcastEnable bool `json:"consumeBroadcastEnable"`
ConsumeMessageOrderly bool `json:"consumeMessageOrderly"`
RetryQueueNums int `json:"retryQueueNums"`
RetryMaxTimes int `json:"retryMaxTimes"`
BrokerID int64 `json:"brokerId"`
WhichBrokerWhenConsumeSlowly int64 `json:"whichBrokerWhenConsumeSlowly"`
NotifyConsumerIDsChangedEnable bool `json:"notifyConsumerIdsChangedEnable"`
}
func (a *rocketMQAgent) listConsumerGroups(params map[string]any) (any, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
configs, err := a.collectSubscriptionGroupConfigs(ctx)
if err != nil {
return nil, err
}
topicFilter := stringValue(params, "topic")
groupNames := make([]string, 0)
if topicFilter != "" {
groups, queryErr := client.QueryTopicConsumeByWho(ctx, topicFilter)
if queryErr != nil {
return nil, queryErr
}
groupNames = append(groupNames, groups...)
} else {
for name := range configs {
groupNames = append(groupNames, name)
}
}
sort.Strings(groupNames)
groupNames = uniqueStrings(groupNames)
keyword := strings.ToLower(stringValue(params, "keyword"))
rows := make([]map[string]any, 0, len(groupNames))
for _, groupID := range groupNames {
if keyword != "" && !strings.Contains(strings.ToLower(groupID), keyword) {
continue
}
row := map[string]any{
"groupId": groupID, "state": "UNKNOWN", "simpleGroup": false,
"groupType": classifyConsumerGroup(groupID, configs[groupID]), "messageModel": "CLUSTERING",
}
if topicFilter != "" {
row["topics"] = []string{topicFilter}
}
rows = append(rows, row)
}
offset := max(0, intValue(params, 0, "offset"))
limit := intValue(params, 200, "limit")
if limit <= 0 {
limit = 200
}
page := paginate(rows, offset, limit)
if boolValue(params, false, "enrich") {
a.enrichConsumerGroups(ctx, client, page)
}
if topicFilter != "" && boolValue(params, false, "includeLag") {
a.attachConsumerGroupLag(page, topicFilter)
}
return map[string]any{
"groups": page, "total": len(rows), "offset": offset, "limit": limit,
}, nil
}
func (a *rocketMQAgent) describeConsumerGroup(params map[string]any) (any, error) {
groupID, err := requireString(params, "groupId")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
connection, err := client.ExamineConsumerConnectionInfo(ctx, groupID)
if err != nil {
return nil, err
}
assignments := make([]map[string]any, 0, len(connection.SubscriptionTable))
for _, topic := range sortedKeys(connection.SubscriptionTable) {
subscription := connection.SubscriptionTable[topic]
assignments = append(assignments, map[string]any{
"topic": subscription.Topic, "subExpression": subscription.SubString,
})
}
members := make([]map[string]any, 0, len(connection.ConnectionSet))
for _, member := range connection.ConnectionSet {
members = append(members, map[string]any{
"memberId": member.ClientId, "clientId": member.ClientId,
"host": member.ClientAddr, "assignments": assignments,
})
}
return map[string]any{
"groupId": groupID, "state": valueOrDefault(connection.ConsumeType, "UNKNOWN"),
"partitionAssignor": emptyToNil(connection.MessageModel),
"messageModel": emptyToNil(connection.MessageModel), "members": members,
}, nil
}
func (a *rocketMQAgent) deleteConsumerGroup(params map[string]any) (any, error) {
groupID, err := requireString(params, "groupId")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
addresses, err := a.masterBrokerAddresses("")
if err != nil {
return nil, err
}
successCount := 0
var lastErr error
for _, address := range addresses {
if mutationErr := client.DeleteSubscriptionGroup(ctx, address, groupID); mutationErr != nil {
lastErr = mutationErr
continue
}
successCount++
}
if err := ensureMutationCoverage("delete", "consumer group "+groupID, len(addresses), successCount, lastErr); err != nil {
return nil, err
}
return okResult(), nil
}
func (a *rocketMQAgent) getSubscriptionGroupConfig(params map[string]any) (any, error) {
groupID, err := requireString(params, "groupId")
if err != nil {
return nil, err
}
_, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
configs, err := a.collectSubscriptionGroupConfigs(ctx)
if err != nil {
return nil, err
}
groupConfig := configs[groupID]
if groupConfig == nil {
return nil, fmt.Errorf("consumer group not found: %s", groupID)
}
return subscriptionGroupConfigMap(groupConfig), nil
}
func (a *rocketMQAgent) alterSubscriptionGroupConfig(params map[string]any) (any, error) {
groupID, err := requireString(params, "groupId")
if err != nil {
return nil, err
}
_, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
configs, collectErr := a.collectSubscriptionGroupConfigs(ctx)
if collectErr != nil {
return nil, collectErr
}
groupConfig := configs[groupID]
if groupConfig == nil {
groupConfig = &subscriptionGroupConfig{
GroupName: groupID, ConsumeEnable: true, ConsumeFromMinEnable: true,
RetryQueueNums: 1, RetryMaxTimes: 16, NotifyConsumerIDsChangedEnable: true,
}
}
applySubscriptionGroupUpdates(groupConfig, params)
addresses, err := a.masterBrokerAddresses("")
if err != nil {
return nil, err
}
successCount := 0
var lastErr error
for _, address := range addresses {
if mutationErr := writeSubscriptionGroupConfig(ctx, address, groupConfig); mutationErr != nil {
lastErr = mutationErr
continue
}
successCount++
}
if err := ensureMutationCoverage("update", "consumer group "+groupID, len(addresses), successCount, lastErr); err != nil {
return nil, err
}
return okResult(), nil
}
func (a *rocketMQAgent) resetConsumerGroupOffsets(params map[string]any) (any, error) {
groupID, err := requireString(params, "groupId")
if err != nil {
return nil, err
}
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
if offsets, ok := params["offsets"].([]any); ok && len(offsets) > 0 {
address, addressErr := a.brokerAddressForName(stringValue(params, "brokerName"))
if addressErr != nil {
return nil, addressErr
}
for _, raw := range offsets {
offset, _ := raw.(map[string]any)
if err := client.UpdateConsumeOffset(ctx, address, groupID, topic,
intValue(offset, 0, "partition"), int64Value(offset, 0, "offset")); err != nil {
return nil, err
}
}
return okResult(), nil
}
position := strings.ToLower(stringValue(params, "position"))
if position == "" {
position = "latest"
}
var timestamp int64
switch position {
case "earliest":
timestamp = 0
case "latest":
timestamp = time.Now().UnixMilli()
case "timestamp":
timestamp = int64Value(params, time.Now().UnixMilli(), "timestampMs")
default:
return nil, fmt.Errorf("unsupported reset position: %s", position)
}
if _, err := client.ResetOffsetByTimestamp(ctx, topic, groupID, timestamp, true); err != nil {
return nil, err
}
return okResult(), nil
}
func (a *rocketMQAgent) getConsumerLag(params map[string]any) (any, error) {
groupID, err := requireString(params, "groupId")
if err != nil {
return nil, err
}
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
stats, err := a.examineConsumeStatsByTopic(ctx, client, groupID, topic)
if err != nil {
return nil, err
}
partitions := make([]map[string]any, 0, len(stats.OffsetTable))
var totalLag int64
for key, offset := range stats.OffsetTable {
queue := parseMessageQueueKey(key)
lag := max(int64(0), offset.BrokerOffset-offset.ConsumerOffset)
totalLag += lag
partitions = append(partitions, map[string]any{
"partition": queue.QueueID, "currentOffset": offset.ConsumerOffset,
"endOffset": offset.BrokerOffset, "lag": lag, "brokerName": queue.BrokerName,
"lastTimestamp": offset.LastTimestamp, "consumerClient": "",
})
}
sort.Slice(partitions, func(i, j int) bool {
left, right := partitions[i], partitions[j]
if left["brokerName"] != right["brokerName"] {
return fmt.Sprint(left["brokerName"]) < fmt.Sprint(right["brokerName"])
}
return left["partition"].(int) < right["partition"].(int)
})
return map[string]any{"partitions": partitions, "totalLag": totalLag}, nil
}
func (a *rocketMQAgent) examineConsumeStatsByTopic(
ctx context.Context,
client *admin.Client,
groupID string,
topic string,
) (*admin.ConsumeStats, error) {
route, err := client.ExamineTopicRouteInfo(ctx, topic)
if err != nil {
return nil, err
}
addresses := masterAddressesFromRoute(route)
if len(addresses) == 0 {
return nil, fmt.Errorf("no RocketMQ master broker found for topic %s", topic)
}
merged := &admin.ConsumeStats{OffsetTable: make(map[string]*admin.OffsetWrapper)}
successCount := 0
var lastErr error
for _, address := range addresses {
response, requestErr := invokeRemotingWithClient(ctx, address, remoting.NewRequest(
remoting.GetConsumeStats,
map[string]string{"consumerGroup": groupID, "topic": topic},
))
if requestErr != nil {
lastErr = requestErr
continue
}
partial, decodeErr := decodeConsumeStats(response.Body)
if decodeErr != nil {
lastErr = decodeErr
continue
}
successCount++
for key, offset := range partial.OffsetTable {
merged.OffsetTable[key] = offset
}
merged.ConsumeTps += partial.ConsumeTps
}
if successCount == 0 {
return nil, fmt.Errorf("query consumer lag for group %s on all masters: %w", groupID, lastErr)
}
if len(merged.OffsetTable) == 0 && successCount != len(addresses) {
return nil, fmt.Errorf("consumer lag for group %s is incomplete: %w", groupID, lastErr)
}
return merged, nil
}
func decodeConsumeStats(body []byte) (*admin.ConsumeStats, error) {
var stats admin.ConsumeStats
if err := json.Unmarshal(repairRocketMQJSON(body), &stats); err != nil {
return nil, fmt.Errorf("decode consumer stats: %w", err)
}
if stats.OffsetTable == nil {
stats.OffsetTable = make(map[string]*admin.OffsetWrapper)
}
return &stats, nil
}
func (a *rocketMQAgent) collectSubscriptionGroupConfigs(ctx context.Context) (map[string]*subscriptionGroupConfig, error) {
addresses, err := a.masterBrokerAddresses("")
if err != nil {
return nil, err
}
merged := make(map[string]*subscriptionGroupConfig)
var lastErr error
for _, address := range addresses {
configs, fetchErr := fetchSubscriptionGroupConfigs(ctx, address)
if fetchErr != nil {
lastErr = fetchErr
continue
}
for name, next := range configs {
current := merged[name]
if current == nil || next.ConsumeMessageOrderly {
merged[name] = next
}
}
}
if len(merged) == 0 && lastErr != nil {
return nil, lastErr
}
return merged, nil
}
func fetchSubscriptionGroupConfigs(ctx context.Context, address string) (map[string]*subscriptionGroupConfig, error) {
response, err := invokeRemotingWithClient(ctx, address,
remoting.NewRequest(remoting.GetAllSubscriptionGroupConfig, nil))
if err != nil {
return nil, err
}
var wrapper struct {
SubscriptionGroupTable map[string]*subscriptionGroupConfig `json:"subscriptionGroupTable"`
}
if err := json.Unmarshal(repairRocketMQJSON(response.Body), &wrapper); err != nil {
return nil, fmt.Errorf("decode subscription groups: %w", err)
}
return wrapper.SubscriptionGroupTable, nil
}
func writeSubscriptionGroupConfig(ctx context.Context, address string, config *subscriptionGroupConfig) error {
body, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("encode subscription group config: %w", err)
}
command := remoting.NewRequest(remoting.UpdateAndCreateSubscriptionGroup, nil)
command.Body = body
_, err = invokeRemotingWithClient(ctx, address, command)
return err
}
func ensureMutationCoverage(action, resource string, attempted, succeeded int, lastErr error) error {
if attempted <= 0 {
return fmt.Errorf("no RocketMQ master brokers available to %s %s", action, resource)
}
if succeeded == attempted {
return nil
}
message := fmt.Sprintf("failed to %s %s on all masters: %d of %d succeeded", action, resource, succeeded, attempted)
if lastErr != nil {
return fmt.Errorf("%s: %w", message, lastErr)
}
return fmt.Errorf("%s", message)
}
func (a *rocketMQAgent) enrichConsumerGroups(ctx context.Context, client *admin.Client, rows []map[string]any) {
for _, row := range rows {
groupID := fmt.Sprint(row["groupId"])
connection, err := client.ExamineConsumerConnectionInfo(ctx, groupID)
if err != nil {
if _, ok := row["topics"]; !ok {
row["topics"] = []string{}
}
continue
}
row["consumeType"] = valueOrDefault(connection.ConsumeType, "UNKNOWN")
row["messageModel"] = valueOrDefault(connection.MessageModel, "CLUSTERING")
row["memberCount"] = len(connection.ConnectionSet)
topics := make([]string, 0, len(connection.SubscriptionTable))
for topic := range connection.SubscriptionTable {
topics = append(topics, topic)
}
sort.Strings(topics)
row["topics"] = topics
}
}
func (a *rocketMQAgent) attachConsumerGroupLag(rows []map[string]any, topic string) {
semaphore := make(chan struct{}, 8)
var waitGroup sync.WaitGroup
for _, row := range rows {
row := row
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, err := a.getConsumerLag(map[string]any{"groupId": row["groupId"], "topic": topic})
if err != nil {
row["totalLagFailed"] = true
return
}
lag := result.(map[string]any)
row["totalLag"] = lag["totalLag"]
row["partitions"] = lag["partitions"]
}()
}
waitGroup.Wait()
}
func classifyConsumerGroup(groupID string, config *subscriptionGroupConfig) string {
if _, ok := systemConsumerGroups[groupID]; ok {
return "SYSTEM"
}
if config == nil {
return "UNKNOWN"
}
if config.ConsumeMessageOrderly {
return "FIFO"
}
return "NORMAL"
}
func subscriptionGroupConfigMap(config *subscriptionGroupConfig) map[string]any {
return map[string]any{
"groupName": config.GroupName, "consumeEnable": config.ConsumeEnable,
"consumeFromMinEnable": config.ConsumeFromMinEnable,
"consumeBroadcastEnable": config.ConsumeBroadcastEnable,
"consumeMessageOrderly": config.ConsumeMessageOrderly,
"retryQueueNums": config.RetryQueueNums, "retryMaxTimes": config.RetryMaxTimes,
"brokerId": config.BrokerID, "whichBrokerWhenConsumeSlowly": config.WhichBrokerWhenConsumeSlowly,
}
}
func applySubscriptionGroupUpdates(config *subscriptionGroupConfig, params map[string]any) {
config.ConsumeEnable = boolValue(params, config.ConsumeEnable, "consumeEnable")
config.ConsumeFromMinEnable = boolValue(params, config.ConsumeFromMinEnable, "consumeFromMinEnable")
config.ConsumeBroadcastEnable = boolValue(params, config.ConsumeBroadcastEnable, "consumeBroadcastEnable")
config.ConsumeMessageOrderly = boolValue(params, config.ConsumeMessageOrderly, "consumeMessageOrderly")
config.RetryQueueNums = intValue(params, config.RetryQueueNums, "retryQueueNums")
config.RetryMaxTimes = intValue(params, config.RetryMaxTimes, "retryMaxTimes")
config.BrokerID = int64Value(params, config.BrokerID, "brokerId")
config.WhichBrokerWhenConsumeSlowly = int64Value(params, config.WhichBrokerWhenConsumeSlowly, "whichBrokerWhenConsumeSlowly")
}
func uniqueStrings(values []string) []string {
result := values[:0]
var previous string
for index, value := range values {
if index == 0 || value != previous {
result = append(result, value)
previous = value
}
}
return result
}
func valueOrDefault(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func emptyToNil(value string) any {
if value == "" {
return nil
}
return value
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestClassifyConsumerGroup(t *testing.T) {
if got := classifyConsumerGroup("TOOLS_CONSUMER", nil); got != "SYSTEM" {
t.Fatalf("system group classified as %q", got)
}
if got := classifyConsumerGroup("GID_Orders", nil); got != "UNKNOWN" {
t.Fatalf("missing config classified as %q", got)
}
if got := classifyConsumerGroup("GID_Orders", &subscriptionGroupConfig{}); got != "NORMAL" {
t.Fatalf("normal group classified as %q", got)
}
if got := classifyConsumerGroup("GID_FIFO", &subscriptionGroupConfig{ConsumeMessageOrderly: true}); got != "FIFO" {
t.Fatalf("FIFO group classified as %q", got)
}
}
func TestDecodeConsumeStatsRepairsObjectKeys(t *testing.T) {
body := []byte(`{"consumeTps":1.5,"offsetTable":{{"brokerName":"broker-a","queueId":2,"topic":"Orders"}:{"brokerOffset":20,"consumerOffset":12,"lastTimestamp":9,"pullOffset":13}}}`)
stats, err := decodeConsumeStats(body)
if err != nil {
t.Fatal(err)
}
if stats.ConsumeTps != 1.5 || len(stats.OffsetTable) != 1 {
t.Fatalf("unexpected consume stats: %#v", stats)
}
for key, offset := range stats.OffsetTable {
queue := parseMessageQueueKey(key)
if queue.Topic != "Orders" || queue.BrokerName != "broker-a" || queue.QueueID != 2 {
t.Fatalf("unexpected queue key %q: %#v", key, queue)
}
if offset.BrokerOffset != 20 || offset.ConsumerOffset != 12 {
t.Fatalf("unexpected offset: %#v", offset)
}
}
}
func TestMutationCoverageFailureMessage(t *testing.T) {
err := ensureMutationCoverage("update", "consumer group GID_Orders", 2, 1, nil)
if err == nil || !strings.Contains(err.Error(), "1 of 2") {
t.Fatalf("unexpected partial mutation error: %v", err)
}
if err := ensureMutationCoverage("update", "consumer group GID_Orders", 2, 2, nil); err != nil {
t.Fatal(err)
}
}
func TestSubscriptionGroupConfigWireShape(t *testing.T) {
config := subscriptionGroupConfig{
GroupName: "GID_Orders", ConsumeEnable: true, ConsumeMessageOrderly: true,
RetryQueueNums: 2, RetryMaxTimes: 20, NotifyConsumerIDsChangedEnable: true,
}
body, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatal(err)
}
if decoded["groupName"] != "GID_Orders" || decoded["consumeMessageOrderly"] != true {
t.Fatalf("unexpected group wire body: %#v", decoded)
}
}
+28
View File
@@ -0,0 +1,28 @@
module github.com/t8y2/dbx/agents/drivers/rocketmq
go 1.25.0
require (
github.com/amigoer/rocketmq-admin-go v1.1.1
golang.org/x/net v0.44.0
)
require (
github.com/apache/rocketmq-client-go/v2 v2.1.2 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.42.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
stathat.com/c/consistent v1.0.0 // indirect
)
+183
View File
@@ -0,0 +1,183 @@
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/amigoer/rocketmq-admin-go v1.1.1 h1:DhV7c1lukiB5Qy115/b4a7yE3XXLMRYJ/Jz77miLZoc=
github.com/amigoer/rocketmq-admin-go v1.1.1/go.mod h1:I/N1RG6IvNEP2APjKh66Tg7YbZyW9aetiEEl8ziuYPs=
github.com/apache/rocketmq-client-go/v2 v2.1.2 h1:yt73olKe5N6894Dbm+ojRf/JPiP0cxfDNNffKwhpJVg=
github.com/apache/rocketmq-client-go/v2 v2.1.2/go.mod h1:6I6vgxHR3hzrvn+6n/4mrhS+UTulzK/X9LB2Vk1U5gE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.13.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
stathat.com/c/consistent v1.0.0 h1:ezyc51EGcRPJUxfHGSgJjWzJdj3NiMU9pNfLNGiXV0c=
stathat.com/c/consistent v1.0.0/go.mod h1:QkzMWzcbB+yQBL2AttO6sgsQS/JSTapcDISJalmCDS0=
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"sort"
"strconv"
"strings"
)
func nestedMap(params map[string]any, key string) map[string]any {
value, ok := params[key]
if !ok || value == nil {
return nil
}
if result, ok := value.(map[string]any); ok {
return result
}
return nil
}
func stringValue(params map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := params[key]; ok && value != nil {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
}
}
return ""
}
func requireString(params map[string]any, keys ...string) (string, error) {
value := stringValue(params, keys...)
if value == "" {
return "", fmt.Errorf("%s is required", keys[0])
}
return value, nil
}
func intValue(params map[string]any, defaultValue int, keys ...string) int {
for _, key := range keys {
value, ok := params[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return int(typed)
case json.Number:
parsed, err := typed.Int64()
if err == nil {
return int(parsed)
}
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
if err == nil {
return parsed
}
}
}
return defaultValue
}
func int64Value(params map[string]any, defaultValue int64, keys ...string) int64 {
for _, key := range keys {
value, ok := params[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed)
case json.Number:
parsed, err := typed.Int64()
if err == nil {
return parsed
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed
}
}
}
return defaultValue
}
func boolValue(params map[string]any, defaultValue bool, keys ...string) bool {
for _, key := range keys {
value, ok := params[key]
if !ok || value == nil {
continue
}
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed
}
}
}
return defaultValue
}
func decodePayload(params map[string]any) ([]byte, error) {
encoded := stringValue(params, "payloadBase64")
if encoded != "" {
payload, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("invalid payloadBase64: %w", err)
}
return payload, nil
}
return []byte(stringValue(params, "payload", "payloadText")), nil
}
func splitAddresses(value string) []string {
fields := strings.FieldsFunc(value, func(char rune) bool {
return char == ';' || char == ',' || char == '\n' || char == '\r' || char == '\t' || char == ' '
})
seen := map[string]struct{}{}
result := make([]string, 0, len(fields))
for _, field := range fields {
address := strings.TrimSpace(field)
if address == "" {
continue
}
if _, ok := seen[address]; ok {
continue
}
seen[address] = struct{}{}
result = append(result, address)
}
return result
}
func parseSocketAddress(address string) (string, string) {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return "", ""
}
if host, port, err := net.SplitHostPort(trimmed); err == nil {
return strings.Trim(host, "[]"), port
}
colon := strings.LastIndex(trimmed, ":")
if colon <= 0 || colon == len(trimmed)-1 {
return trimmed, ""
}
if _, err := strconv.Atoi(trimmed[colon+1:]); err != nil {
return trimmed, ""
}
return strings.Trim(trimmed[:colon], "[]"), trimmed[colon+1:]
}
func formatSocketAddress(host, port string) string {
if host == "" {
return port
}
if port == "" {
return host
}
return net.JoinHostPort(strings.Trim(host, "[]"), port)
}
func isLikelyUnreachableBrokerHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" || host == "127.0.0.1" || host == "localhost" || host == "::1" {
return false
}
if strings.HasSuffix(host, ".docker") || strings.Contains(host, ".docker.") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsPrivate()
}
func sortedKeys[V any](values map[string]V) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func paginate[T any](items []T, offset, limit int) []T {
if offset < 0 {
offset = 0
}
if offset >= len(items) {
return []T{}
}
end := len(items)
if limit > 0 && offset+limit < end {
end = offset + limit
}
return items[offset:end]
}
func normalizeTopicPerm(perm int) int {
switch perm {
case 2, 4, 6:
return perm
default:
return 6
}
}
func normalizeMessageType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
return "NORMAL"
}
if value == "ORDER" {
return "FIFO"
}
return value
}
+342
View File
@@ -0,0 +1,342 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
)
type integrationTopicList struct {
Topics []struct {
Name string `json:"name"`
Partitions int `json:"partitions"`
ReadQueueNums int `json:"readQueueNums"`
WriteQueueNums int `json:"writeQueueNums"`
MessageType string `json:"messageType"`
} `json:"topics"`
}
type integrationTopicConfig struct {
Configs map[string]struct {
Value string `json:"value"`
} `json:"configs"`
}
type integrationMessages struct {
Messages []struct {
MessageID string `json:"messageId"`
Partition int `json:"partition"`
Offset int64 `json:"offset"`
PayloadText string `json:"payloadText"`
Headers map[string]string `json:"headers"`
} `json:"messages"`
}
type integrationLag struct {
TotalLag int64 `json:"totalLag"`
}
type integrationConsumerGroups struct {
Groups []struct {
GroupID string `json:"groupId"`
GroupType string `json:"groupType"`
} `json:"groups"`
}
func TestRocketMQIntegration(t *testing.T) {
if os.Getenv("ROCKETMQ_INTEGRATION") != "1" {
t.Skip("set ROCKETMQ_INTEGRATION=1 to run against a real RocketMQ broker")
}
nameServer := envString("ROCKETMQ_NAMESRV_ADDR", "127.0.0.1:9876")
version := envString("ROCKETMQ_VERSION", "unknown")
agent := newRocketMQAgent()
t.Cleanup(agent.close)
connection := map[string]any{
"namesrv_addr": nameServer, "request_timeout_ms": 20_000, "connect_timeout_ms": 20_000,
}
var connectionResult struct {
OK bool `json:"ok"`
NodeCount int `json:"nodeCount"`
}
integrationCall(t, agent, "test_connection", map[string]any{"connection": connection}, &connectionResult)
if !connectionResult.OK || connectionResult.NodeCount < 1 {
t.Fatalf("unexpected test connection result: %#v", connectionResult)
}
integrationCall(t, agent, "connect", map[string]any{"connection": connection}, &connectionResult)
if !connectionResult.OK || connectionResult.NodeCount < 1 {
t.Fatalf("unexpected connection result: %#v", connectionResult)
}
var cluster struct {
ClusterID string `json:"clusterId"`
NodeCount int `json:"nodeCount"`
Brokers []any `json:"brokers"`
}
integrationCall(t, agent, "mq_describe_cluster", map[string]any{}, &cluster)
if cluster.ClusterID == "" || cluster.NodeCount < 1 || len(cluster.Brokers) < 1 {
t.Fatalf("unexpected cluster result: %#v", cluster)
}
suffix := fmt.Sprintf("%s_%d", strings.ReplaceAll(version, ".", "_"), time.Now().UnixNano())
topic := "DBX_GO_INTEGRATION_" + suffix
groupID := "GID_DBX_GO_" + suffix
t.Cleanup(func() {
integrationCleanupCall(agent, "mq_delete_consumer_group", map[string]any{"groupId": groupID})
integrationCleanupCall(agent, "mq_delete_topic", map[string]any{"name": topic})
})
integrationCall[map[string]any](t, agent, "mq_create_topic", map[string]any{
"name": topic, "readQueueNums": 2, "writeQueueNums": 2, "perm": 6, "messageType": "NORMAL",
}, nil)
var route struct {
Topic string `json:"topic"`
BrokerDatas []any `json:"brokerDatas"`
QueueDatas []struct {
ReadQueueNums int `json:"readQueueNums"`
WriteQueueNums int `json:"writeQueueNums"`
} `json:"queueDatas"`
}
integrationCall(t, agent, "mq_get_topic_route", map[string]any{"name": topic}, &route)
if route.Topic != topic || len(route.BrokerDatas) == 0 || len(route.QueueDatas) == 0 {
t.Fatalf("unexpected route: %#v", route)
}
if route.QueueDatas[0].ReadQueueNums != 2 || route.QueueDatas[0].WriteQueueNums != 2 {
t.Fatalf("unexpected initial route queues: %#v", route.QueueDatas)
}
var topicsAfterCreate integrationTopicList
integrationCall(t, agent, "mq_list_topics", map[string]any{"keyword": topic, "limit": 10}, &topicsAfterCreate)
if len(topicsAfterCreate.Topics) != 1 || topicsAfterCreate.Topics[0].Name != topic {
t.Fatalf("unexpected topic list after creation: %#v", topicsAfterCreate.Topics)
}
createdTopic := topicsAfterCreate.Topics[0]
if createdTopic.Partitions != 2 || createdTopic.ReadQueueNums != 2 || createdTopic.WriteQueueNums != 2 {
t.Fatalf("unexpected created topic queues: %#v", createdTopic)
}
if strings.HasPrefix(version, "5.") && createdTopic.MessageType != "NORMAL" {
t.Fatalf("RocketMQ %s lost the 5.x message.type attribute: %#v", version, createdTopic)
}
var topicConfig integrationTopicConfig
integrationCall(t, agent, "mq_get_topic_config", map[string]any{"name": topic}, &topicConfig)
if topicConfig.Configs["readQueueNums"].Value != "2" || topicConfig.Configs["writeQueueNums"].Value != "2" {
t.Fatalf("unexpected topic config: %#v", topicConfig)
}
integrationCall[map[string]any](t, agent, "mq_update_partitions", map[string]any{
"name": topic, "readQueueNums": 3, "writeQueueNums": 2,
}, nil)
integrationCall(t, agent, "mq_get_topic_config", map[string]any{"name": topic}, &topicConfig)
if topicConfig.Configs["readQueueNums"].Value != "3" || topicConfig.Configs["writeQueueNums"].Value != "2" {
t.Fatalf("unexpected topic config after partition update: %#v", topicConfig)
}
integrationCall[map[string]any](t, agent, "mq_alter_topic_config", map[string]any{
"name": topic,
"configs": []map[string]any{
{"key": "readQueueNums", "value": "4"},
{"key": "writeQueueNums", "value": "3"},
{"key": "perm", "value": "6"},
},
}, nil)
integrationCall(t, agent, "mq_get_topic_config", map[string]any{"name": topic}, &topicConfig)
if topicConfig.Configs["readQueueNums"].Value != "4" || topicConfig.Configs["writeQueueNums"].Value != "3" || topicConfig.Configs["perm"].Value != "6" {
t.Fatalf("unexpected topic config after alteration: %#v", topicConfig)
}
if strings.HasPrefix(version, "5.") {
var topicsAfterAlter integrationTopicList
integrationCall(t, agent, "mq_list_topics", map[string]any{"keyword": topic, "limit": 10}, &topicsAfterAlter)
if len(topicsAfterAlter.Topics) != 1 || topicsAfterAlter.Topics[0].MessageType != "NORMAL" {
t.Fatalf("RocketMQ %s lost message.type after config updates: %#v", version, topicsAfterAlter.Topics)
}
}
payload := "RocketMQ Go Agent 世界 " + version
var sendResult struct {
OK bool `json:"ok"`
Partition int `json:"partition"`
Offset int64 `json:"offset"`
}
integrationCall(t, agent, "mq_send_message", map[string]any{
"topic": topic, "partition": 0, "key": "dbx-integration-key", "tag": "dbx-integration",
"payloadBase64": base64.StdEncoding.EncodeToString([]byte(payload)),
"headers": map[string]any{"source": "integration", "empty": "", "nil": nil},
}, &sendResult)
if !sendResult.OK || sendResult.Partition != 0 || sendResult.Offset < 0 {
t.Fatalf("unexpected send result: %#v", sendResult)
}
peeked := waitForIntegrationMessages(t, agent, "mq_peek_messages", map[string]any{
"topic": topic, "partition": 0, "count": 10,
})
if peeked.Messages[0].PayloadText != payload || peeked.Messages[0].Headers["source"] != "integration" {
t.Fatalf("unexpected peeked message: %#v", peeked.Messages[0])
}
if _, ok := peeked.Messages[0].Headers["empty"]; ok {
t.Fatalf("empty user property should be omitted: %#v", peeked.Messages[0].Headers)
}
if peeked.Messages[0].MessageID == "" {
t.Fatalf("peeked message has no message ID: %#v", peeked.Messages[0])
}
var viewed struct {
Message struct {
MessageID string `json:"messageId"`
PayloadText string `json:"payloadText"`
} `json:"message"`
}
integrationCall(t, agent, "mq_view_message", map[string]any{
"topic": topic, "msgId": peeked.Messages[0].MessageID,
"partition": peeked.Messages[0].Partition, "offset": peeked.Messages[0].Offset,
}, &viewed)
if viewed.Message.MessageID == "" || viewed.Message.PayloadText != payload {
t.Fatalf("unexpected viewed message: %#v", viewed.Message)
}
queried := waitForIntegrationMessages(t, agent, "mq_query_message_by_key", map[string]any{
"topic": topic, "key": "dbx-integration-key", "maxNum": 10,
"begin": 0, "end": time.Now().Add(time.Minute).UnixMilli(),
})
if queried.Messages[0].PayloadText != payload {
t.Fatalf("unexpected queried message: %#v", queried.Messages[0])
}
queriedByTopic := waitForIntegrationMessages(t, agent, "mq_query_message_by_topic", map[string]any{
"topic": topic, "maxNum": 10, "begin": 0, "end": time.Now().Add(time.Minute).UnixMilli(),
})
if queriedByTopic.Messages[0].PayloadText != payload {
t.Fatalf("unexpected topic query message: %#v", queriedByTopic.Messages[0])
}
var stats struct {
TotalMessages int64 `json:"totalMessages"`
}
integrationCall(t, agent, "mq_get_topic_stats", map[string]any{"name": topic}, &stats)
if stats.TotalMessages < 1 {
t.Fatalf("unexpected topic stats: %#v", stats)
}
integrationCall[map[string]any](t, agent, "mq_alter_subscription_group_config", map[string]any{
"groupId": groupID, "consumeEnable": true, "consumeMessageOrderly": true,
"retryQueueNums": 2, "retryMaxTimes": 20,
}, nil)
var groupConfig struct {
GroupName string `json:"groupName"`
ConsumeMessageOrderly bool `json:"consumeMessageOrderly"`
}
integrationCall(t, agent, "mq_get_subscription_group_config", map[string]any{"groupId": groupID}, &groupConfig)
if groupConfig.GroupName != groupID {
t.Fatalf("unexpected group config: %#v", groupConfig)
}
if strings.HasPrefix(version, "5.") && !groupConfig.ConsumeMessageOrderly {
t.Fatalf("RocketMQ %s did not preserve FIFO group config: %#v", version, groupConfig)
}
var groups integrationConsumerGroups
integrationCall(t, agent, "mq_list_consumer_groups", map[string]any{
"keyword": groupID, "limit": 10,
}, &groups)
if len(groups.Groups) != 1 || groups.Groups[0].GroupID != groupID {
t.Fatalf("unexpected consumer groups: %#v", groups)
}
if strings.HasPrefix(version, "5.") && groups.Groups[0].GroupType != "FIFO" {
t.Fatalf("RocketMQ %s did not classify FIFO group: %#v", version, groups.Groups[0])
}
var producers struct {
Producers []any `json:"producers"`
}
integrationCall(t, agent, "mq_list_producers", map[string]any{}, &producers)
if producers.Producers == nil {
t.Fatal("producer list must be an array")
}
var lagBefore integrationLag
integrationCall(t, agent, "mq_get_consumer_lag", map[string]any{"groupId": groupID, "topic": topic}, &lagBefore)
if lagBefore.TotalLag < 1 {
t.Fatalf("unexpected lag before reset: %#v", lagBefore)
}
integrationCall[map[string]any](t, agent, "mq_reset_consumer_group_offsets", map[string]any{
"groupId": groupID, "topic": topic,
"offsets": []map[string]any{{"partition": 0, "offset": sendResult.Offset + 1}, {"partition": 1, "offset": 0}},
}, nil)
var lagAfter integrationLag
integrationCall(t, agent, "mq_get_consumer_lag", map[string]any{"groupId": groupID, "topic": topic}, &lagAfter)
if lagAfter.TotalLag != 0 {
t.Fatalf("unexpected lag after reset: %#v", lagAfter)
}
var skipped struct {
OK bool `json:"ok"`
ResetGroups int `json:"resetGroups"`
}
integrationCall(t, agent, "mq_skip_topic_accumulation", map[string]any{"topic": topic}, &skipped)
if !skipped.OK || skipped.ResetGroups < 0 {
t.Fatalf("unexpected skip accumulation result: %#v", skipped)
}
integrationCall[map[string]any](t, agent, "mq_delete_consumer_group", map[string]any{"groupId": groupID}, nil)
integrationCall[map[string]any](t, agent, "mq_delete_topic", map[string]any{"name": topic}, nil)
var topics integrationTopicList
integrationCall(t, agent, "mq_list_topics", map[string]any{"keyword": topic, "limit": 10}, &topics)
if len(topics.Topics) != 0 {
t.Fatalf("topic still exists after deletion: %#v", topics.Topics)
}
integrationCall[map[string]any](t, agent, "disconnect", map[string]any{}, nil)
}
func integrationCall[T any](t *testing.T, agent *rocketMQAgent, method string, params map[string]any, target *T) {
t.Helper()
raw, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
result, err := agent.dispatch(method, raw)
if err != nil {
t.Fatalf("%s: %v", method, err)
}
if target == nil {
return
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(encoded, target); err != nil {
t.Fatalf("%s decode: %v; result=%s", method, err, encoded)
}
}
func integrationCleanupCall(agent *rocketMQAgent, method string, params map[string]any) {
raw, err := json.Marshal(params)
if err == nil {
_, _ = agent.dispatch(method, raw)
}
}
func waitForIntegrationMessages(t *testing.T, agent *rocketMQAgent, method string, params map[string]any) integrationMessages {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for {
var messages integrationMessages
integrationCall(t, agent, method, params, &messages)
if len(messages.Messages) > 0 {
return messages
}
if time.Now().After(deadline) {
t.Fatalf("%s returned no messages before timeout", method)
}
time.Sleep(250 * time.Millisecond)
}
}
func envString(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
const maxRPCMessageBytes = 32 * 1024 * 1024
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
func main() {
agent := newRocketMQAgent()
defer agent.close()
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
_ = encoder.Encode(map[string]bool{"ready": true})
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 64*1024), maxRPCMessageBytes)
for scanner.Scan() {
var request rpcRequest
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
_ = encoder.Encode(rpcResponse{
JSONRPC: "2.0",
ID: json.RawMessage("null"),
Error: &rpcError{Code: -32700, Message: normalizeError(err)},
})
continue
}
result, err := agent.dispatch(request.Method, request.Params)
response := rpcResponse{JSONRPC: "2.0", ID: request.ID}
if len(response.ID) == 0 {
response.ID = json.RawMessage("null")
}
if err != nil {
response.Error = &rpcError{Code: -1, Message: normalizeError(err)}
} else {
response.Result = result
}
if err := encoder.Encode(response); err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
if agent.shutdownRequested {
return
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"testing"
"time"
)
const rocketMQHelperProcessEnv = "DBX_ROCKETMQ_HELPER_PROCESS"
type synchronizedBuffer struct {
mu sync.Mutex
buffer bytes.Buffer
}
func (b *synchronizedBuffer) Write(data []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.Write(data)
}
func (b *synchronizedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buffer.String()
}
func TestRocketMQAgentProcessProtocol(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestRocketMQAgentHelperProcess$")
command.Env = append(os.Environ(), rocketMQHelperProcessEnv+"=1")
stdin, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
var stderr synchronizedBuffer
command.Stderr = &stderr
if err := command.Start(); err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() || scanner.Text() != `{"ready":true}` {
t.Fatalf("unexpected ready line %q: %s", scanner.Text(), stderr.String())
}
writeRPCRequest(t, stdin, `{"jsonrpc":"2.0","id":1,"method":"handshake","params":{}}`)
handshake := readRPCResponse(t, scanner, stderr.String())
if string(handshake.ID) != "1" || handshake.Error != nil {
t.Fatalf("unexpected handshake response: %#v", handshake)
}
handshakeResult, ok := handshake.Result.(map[string]any)
if !ok || int(handshakeResult["protocolVersion"].(float64)) != protocolVersion {
t.Fatalf("unexpected handshake result: %#v", handshake.Result)
}
writeRPCRequest(t, stdin, `{`)
parseFailure := readRPCResponse(t, scanner, stderr.String())
if string(parseFailure.ID) != "null" || parseFailure.Error == nil || parseFailure.Error.Code != -32700 {
t.Fatalf("unexpected parse failure response: %#v", parseFailure)
}
writeRPCRequest(t, stdin, `{"jsonrpc":"2.0","id":2,"method":"shutdown","params":{}}`)
shutdown := readRPCResponse(t, scanner, stderr.String())
if string(shutdown.ID) != "2" || shutdown.Error != nil {
t.Fatalf("unexpected shutdown response: %#v", shutdown)
}
if err := command.Wait(); err != nil {
t.Fatalf("agent process failed: %v: %s", err, stderr.String())
}
if ctx.Err() != nil {
t.Fatalf("agent process timed out: %v", ctx.Err())
}
}
func TestRocketMQAgentHelperProcess(t *testing.T) {
if os.Getenv(rocketMQHelperProcessEnv) != "1" {
return
}
main()
os.Exit(0)
}
func writeRPCRequest(t *testing.T, stdin interface{ Write([]byte) (int, error) }, request string) {
t.Helper()
if _, err := fmt.Fprintln(stdin, request); err != nil {
t.Fatal(err)
}
}
func readRPCResponse(t *testing.T, scanner *bufio.Scanner, stderr string) rpcResponse {
t.Helper()
if !scanner.Scan() {
t.Fatalf("agent closed stdout: %s", strings.TrimSpace(stderr))
}
var response rpcResponse
if err := json.Unmarshal(scanner.Bytes(), &response); err != nil {
t.Fatalf("decode response %q: %v", scanner.Text(), err)
}
return response
}
+586
View File
@@ -0,0 +1,586 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
admin "github.com/amigoer/rocketmq-admin-go"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
"github.com/apache/rocketmq-client-go/v2/primitive"
)
const sendMessageRequestCode = 10
const queryMessageNotFoundCode = 208
type messageQueueTarget struct {
BrokerName string
Address string
QueueID int
}
func (a *rocketMQAgent) listProducers(params map[string]any) (any, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
topic := stringValue(params, "topic")
producerGroup := stringValue(params, "producerGroup", "group")
rows := make([]map[string]any, 0)
if producerGroup != "" {
if topic == "" {
return nil, fmt.Errorf("topic is required when producerGroup is specified")
}
connection, queryErr := client.ExamineProducerConnectionInfo(ctx, producerGroup, topic)
if queryErr != nil {
return nil, queryErr
}
for index, connectionInfo := range connection.ConnectionSet {
rows = append(rows, producerRow(int64(index+1), producerGroup, connectionInfo))
}
return map[string]any{"producers": rows}, nil
}
addresses := make([]string, 0)
if topic != "" {
stats, statsErr := a.examineTopicStats(ctx, client, topic)
if statsErr != nil {
return nil, statsErr
}
hasMessages := false
for _, offset := range stats {
if offset.MaxOffset > offset.MinOffset {
hasMessages = true
break
}
}
if !hasMessages {
return map[string]any{"producers": rows}, nil
}
route, routeErr := client.ExamineTopicRouteInfo(ctx, topic)
if routeErr != nil {
return nil, routeErr
}
for _, broker := range route.BrokerDatas {
if address := broker.BrokerAddrs["0"]; address != "" {
addresses = append(addresses, address)
}
}
} else {
a.mu.RLock()
addresses = append(addresses, a.brokerAddr)
a.mu.RUnlock()
}
seen := make(map[string]struct{})
var producerID int64 = 1
for _, address := range uniqueStrings(addresses) {
producerTable, tableErr := client.GetAllProducerInfo(ctx, address)
if tableErr != nil {
continue
}
for _, group := range sortedKeys(producerTable) {
for _, connectionInfo := range producerTable[group] {
key := group + "|" + connectionInfo.ClientAddr
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
rows = append(rows, producerRow(producerID, group, connectionInfo))
producerID++
}
}
}
return map[string]any{"producers": rows}, nil
}
func (a *rocketMQAgent) peekMessages(params map[string]any) (any, error) {
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
count := min(max(1, intValue(params, 10, "count")), 100)
partition, hasPartition := optionalInt(params, "partition")
offset, hasOffset := optionalInt64(params, "offset")
targets, err := a.messageQueueTargets(ctx, client, topic, false)
if err != nil {
return nil, err
}
messages := make([]map[string]any, 0, count)
for _, target := range targets {
if len(messages) >= count {
break
}
if hasPartition && target.QueueID != partition {
continue
}
startOffset := offset
if !hasOffset {
startOffset, err = queueOffset(ctx, target.Address, topic, target.QueueID, remoting.GetMinOffset)
if err != nil {
continue
}
}
pullResult, pullErr := client.PullMessage(ctx, target.Address, topic, target.QueueID, startOffset, count-len(messages))
if pullErr != nil {
continue
}
for _, message := range pullResult.Messages {
message.BrokerName = target.BrokerName
messages = append(messages, messageMap(topic, message))
if len(messages) >= count {
break
}
}
}
sortMessageRows(messages)
return map[string]any{"messages": messages}, nil
}
func (a *rocketMQAgent) viewMessage(params map[string]any) (any, error) {
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
messageID, err := requireString(params, "msgId")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
messages, queryErr := queryMessagesByKey(ctx, client, config.ConnectTimeout,
topic, messageID, 32, 0, time.Now().UnixMilli())
if queryErr == nil {
for _, message := range messages {
if message.MsgId == messageID || message.OffsetMsgId == messageID || message.Properties[primitive.PropertyUniqueClientMessageIdKeyIndex] == messageID {
return map[string]any{"message": messageMap(topic, message)}, nil
}
}
if len(messages) > 0 {
return map[string]any{"message": messageMap(topic, messages[0])}, nil
}
}
partition, hasPartition := optionalInt(params, "partition")
offset, hasOffset := optionalInt64(params, "offset")
if hasPartition && hasOffset {
targets, targetErr := a.messageQueueTargets(ctx, client, topic, false)
if targetErr == nil {
for _, target := range targets {
if target.QueueID != partition {
continue
}
pullResult, pullErr := client.PullMessage(ctx, target.Address, topic, partition, offset, 1)
if pullErr == nil && len(pullResult.Messages) > 0 {
return map[string]any{"message": messageMap(topic, pullResult.Messages[0])}, nil
}
}
}
}
if queryErr != nil {
return nil, queryErr
}
return nil, fmt.Errorf("message not found: %s", messageID)
}
func (a *rocketMQAgent) queryMessageByKey(params map[string]any) (any, error) {
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
key, err := requireString(params, "key")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
maxNum := min(max(1, intValue(params, 32, "maxNum")), 200)
messages, err := queryMessagesByKey(ctx, client, config.ConnectTimeout, topic, key, maxNum,
int64Value(params, 0, "begin"), int64Value(params, time.Now().UnixMilli(), "end"))
if err != nil {
if isEmptyMessageQueryError(err) {
return emptyMessageQuery(), nil
}
return nil, err
}
return messageQueryResult(topic, messages), nil
}
func queryMessagesByKey(
ctx context.Context,
client *admin.Client,
connectTimeout time.Duration,
topic string,
key string,
maxNum int,
beginTimestamp int64,
endTimestamp int64,
) ([]*admin.MessageExt, error) {
route, err := client.ExamineTopicRouteInfo(ctx, topic)
if err != nil {
return nil, err
}
targets := make([]messageQueueTarget, 0, len(route.BrokerDatas))
for _, broker := range route.BrokerDatas {
if address := broker.BrokerAddrs["0"]; address != "" {
targets = append(targets, messageQueueTarget{BrokerName: broker.BrokerName, Address: address})
}
}
if len(targets) == 0 {
return nil, fmt.Errorf("no RocketMQ master broker found for topic %s", topic)
}
messages := make([]*admin.MessageExt, 0)
successCount := 0
var lastErr error
for _, target := range targets {
response, requestErr := invokeRemotingAllowCodes(ctx, target.Address, connectTimeout,
buildQueryMessageCommand(topic, key, maxNum, beginTimestamp, endTimestamp),
remoting.Success, queryMessageNotFoundCode)
if requestErr != nil {
lastErr = requestErr
continue
}
successCount++
if response.Code == queryMessageNotFoundCode || len(response.Body) == 0 {
continue
}
for _, message := range primitive.DecodeMessage(response.Body) {
queueID := 0
if message.Queue != nil {
queueID = message.Queue.QueueId
}
messageTopic := message.Topic
if messageTopic == "" {
messageTopic = topic
}
messages = append(messages, &admin.MessageExt{
Topic: messageTopic, QueueId: queueID, QueueOffset: message.QueueOffset,
MsgId: message.MsgId, OffsetMsgId: message.OffsetMsgId, Body: message.Body,
Flag: int(message.Flag), BornTimestamp: message.BornTimestamp,
StoreTimestamp: message.StoreTimestamp, BornHost: message.BornHost,
StoreHost: message.StoreHost, SysFlag: int(message.SysFlag),
BrokerName: target.BrokerName, Properties: message.GetProperties(),
})
}
}
if successCount == 0 {
return nil, fmt.Errorf("query messages for topic %s on all masters: %w", topic, lastErr)
}
return messages, nil
}
func buildQueryMessageCommand(topic, key string, maxNum int, beginTimestamp, endTimestamp int64) *remoting.RemotingCommand {
return remoting.NewRequest(remoting.QueryMessage, map[string]string{
"topic": topic, "key": key, "maxNum": strconv.Itoa(maxNum),
"beginTimestamp": strconv.FormatInt(beginTimestamp, 10),
"endTimestamp": strconv.FormatInt(endTimestamp, 10),
})
}
func (a *rocketMQAgent) queryMessageByTopic(params map[string]any) (any, error) {
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
maxNum := min(max(1, intValue(params, 32, "maxNum")), 200)
messages, err := client.QueryMessageByTime(ctx, topic,
int64Value(params, 0, "begin"), int64Value(params, time.Now().UnixMilli(), "end"), maxNum)
if err != nil {
return nil, err
}
return messageQueryResult(topic, messages), nil
}
func (a *rocketMQAgent) queryMessageTrace(params map[string]any) (any, error) {
messageID, err := requireString(params, "msgId")
if err != nil {
return nil, err
}
traceTopic := stringValue(params, "traceTopic")
if traceTopic == "" {
traceTopic = "RMQ_SYS_TRACE_TOPIC"
}
result, err := a.queryMessageByKey(map[string]any{
"topic": traceTopic, "key": messageID,
"maxNum": intValue(params, 64, "maxNum"),
"begin": int64Value(params, 0, "begin"),
"end": int64Value(params, time.Now().UnixMilli(), "end"),
})
if err != nil {
return nil, err
}
response := result.(map[string]any)
response["msgId"] = messageID
response["traceTopic"] = traceTopic
return response, nil
}
func (a *rocketMQAgent) sendMessage(params map[string]any) (any, error) {
topic, err := requireString(params, "topic")
if err != nil {
return nil, err
}
payload, err := decodePayload(params)
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
targets, err := a.messageQueueTargets(ctx, client, topic, true)
if err != nil {
return nil, err
}
if len(targets) == 0 {
return nil, fmt.Errorf("no writable queue for topic %s", topic)
}
partition, hasPartition := optionalInt(params, "partition")
selected := targets[0]
if hasPartition {
found := false
for _, target := range targets {
if target.QueueID == partition {
selected = target
found = true
break
}
}
if !found {
return nil, fmt.Errorf("partition %d not found for topic %s", partition, topic)
}
}
command := buildSendMessageCommand(topic, payload, selected.QueueID, params, time.Now().UnixMilli())
response, err := invokeRemotingAllowCodes(ctx, selected.Address, config.ConnectTimeout, command, 0, 10, 11, 12)
if err != nil {
return nil, err
}
queueID, _ := strconv.Atoi(response.ExtFields["queueId"])
queueOffset, _ := strconv.ParseInt(response.ExtFields["queueOffset"], 10, 64)
return map[string]any{
"ok": true, "topic": topic, "partition": queueID,
"offset": queueOffset, "timestamp": time.Now().UnixMilli(),
}, nil
}
func buildSendMessageCommand(topic string, payload []byte, queueID int, params map[string]any, bornTimestamp int64) *remoting.RemotingCommand {
message := primitive.NewMessage(topic, payload)
message.WithProperty(primitive.PropertyUniqueClientMessageIdKeyIndex, primitive.CreateUniqID())
headers := mapValue(params, "headers")
key := stringValue(params, "key")
if key == "" {
key = stringValue(headers, primitive.PropertyKeys)
}
tag := stringValue(params, "tag")
if tag == "" {
tag = stringValue(headers, primitive.PropertyTags)
}
if key != "" {
message.WithKeys([]string{key})
}
if tag != "" {
message.WithTag(tag)
}
for header, raw := range headers {
if raw == nil {
continue
}
value := strings.TrimSpace(fmt.Sprint(raw))
if value == "" || header == primitive.PropertyKeys || header == primitive.PropertyTags || isReservedMessageProperty(header) {
continue
}
message.WithProperty(header, value)
}
command := remoting.NewRequest(sendMessageRequestCode, map[string]string{
"producerGroup": "_DBX_ROCKETMQ_PRODUCER", "topic": topic,
"defaultTopic": "TBW102", "defaultTopicQueueNums": "4",
"queueId": strconv.Itoa(queueID), "sysFlag": "0",
"bornTimestamp": strconv.FormatInt(bornTimestamp, 10), "flag": "0",
"properties": message.MarshallProperties(), "reconsumeTimes": "0",
"unitMode": "false", "maxReconsumeTimes": "0", "batch": "false",
})
command.Body = payload
return command
}
func (a *rocketMQAgent) messageQueueTargets(ctx context.Context, client *admin.Client, topic string, writable bool) ([]messageQueueTarget, error) {
route, err := client.ExamineTopicRouteInfo(ctx, topic)
if err != nil {
return nil, err
}
addresses := make(map[string]string, len(route.BrokerDatas))
for _, broker := range route.BrokerDatas {
if address := broker.BrokerAddrs["0"]; address != "" {
addresses[broker.BrokerName] = address
}
}
targets := make([]messageQueueTarget, 0)
for _, queueData := range route.QueueDatas {
address := addresses[queueData.BrokerName]
if address == "" {
continue
}
queueCount := queueData.ReadQueueNums
if writable {
queueCount = queueData.WriteQueueNums
}
for queueID := 0; queueID < queueCount; queueID++ {
targets = append(targets, messageQueueTarget{
BrokerName: queueData.BrokerName, Address: address, QueueID: queueID,
})
}
}
sort.Slice(targets, func(i, j int) bool {
if targets[i].BrokerName != targets[j].BrokerName {
return targets[i].BrokerName < targets[j].BrokerName
}
return targets[i].QueueID < targets[j].QueueID
})
return targets, nil
}
func queueOffset(ctx context.Context, address, topic string, queueID, requestCode int) (int64, error) {
response, err := invokeRemotingWithClient(ctx, address, remoting.NewRequest(requestCode, map[string]string{
"topic": topic, "queueId": strconv.Itoa(queueID),
}))
if err != nil {
return 0, err
}
offset, err := strconv.ParseInt(response.ExtFields["offset"], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid queue offset: %w", err)
}
return offset, nil
}
func messageMap(topic string, message *admin.MessageExt) map[string]any {
headers := make(map[string]string, len(message.Properties))
for key, value := range message.Properties {
headers[key] = value
}
row := map[string]any{
"topic": topic, "messageId": message.MsgId, "partition": message.QueueId,
"offset": message.QueueOffset, "timestamp": message.StoreTimestamp,
"key": message.Properties[primitive.PropertyKeys], "tag": message.Properties[primitive.PropertyTags],
"headers": headers, "payloadBase64": base64.StdEncoding.EncodeToString(message.Body),
}
if utf8.Valid(message.Body) {
row["payloadText"] = string(message.Body)
}
return row
}
func messageQueryResult(topic string, messages []*admin.MessageExt) map[string]any {
rows := make([]map[string]any, 0, len(messages))
for _, message := range messages {
rows = append(rows, messageMap(topic, message))
}
sortMessageRows(rows)
return map[string]any{"messages": rows, "indexLastUpdateTimestamp": int64(0)}
}
func emptyMessageQuery() map[string]any {
return map[string]any{"messages": []map[string]any{}, "indexLastUpdateTimestamp": int64(0)}
}
func sortMessageRows(messages []map[string]any) {
sort.Slice(messages, func(i, j int) bool {
leftTime := anyInt64(messages[i]["timestamp"])
rightTime := anyInt64(messages[j]["timestamp"])
if leftTime != rightTime {
return leftTime < rightTime
}
leftPartition := int(anyInt64(messages[i]["partition"]))
rightPartition := int(anyInt64(messages[j]["partition"]))
if leftPartition != rightPartition {
return leftPartition < rightPartition
}
return anyInt64(messages[i]["offset"]) < anyInt64(messages[j]["offset"])
})
}
func producerRow(producerID int64, group string, connection admin.Connection) map[string]any {
return map[string]any{
"producerId": producerID, "producerName": group, "msgRateIn": 0.0,
"msgThroughputIn": 0.0, "clientVersion": strconv.Itoa(connection.Version),
"address": connection.ClientAddr, "lastTimestamp": int64(0),
}
}
func optionalInt(params map[string]any, key string) (int, bool) {
if _, ok := params[key]; !ok || params[key] == nil {
return 0, false
}
return intValue(params, 0, key), true
}
func optionalInt64(params map[string]any, key string) (int64, bool) {
if _, ok := params[key]; !ok || params[key] == nil {
return 0, false
}
return int64Value(params, 0, key), true
}
func mapValue(params map[string]any, key string) map[string]any {
if value, ok := params[key].(map[string]any); ok {
return value
}
return map[string]any{}
}
func anyInt64(value any) int64 {
switch typed := value.(type) {
case int:
return int64(typed)
case int64:
return typed
case float64:
return int64(typed)
case string:
parsed, _ := strconv.ParseInt(typed, 10, 64)
return parsed
default:
return 0
}
}
func isEmptyMessageQueryError(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "208") || strings.Contains(message, "no message") || strings.Contains(message, "未找到消息")
}
func isReservedMessageProperty(key string) bool {
switch key {
case primitive.PropertyUniqueClientMessageIdKeyIndex, primitive.PropertyWaitStoreMsgOk,
primitive.PropertyDelayTimeLevel, primitive.PropertyRetryTopic,
primitive.PropertyRealTopic, primitive.PropertyRealQueueId,
primitive.PropertyTransactionPrepared, primitive.PropertyProducerGroup,
primitive.PropertyMinOffset, primitive.PropertyMaxOffset,
primitive.PropertyBuyerId, primitive.PropertyOriginMessageId,
primitive.PropertyTransferFlag, primitive.PropertyCorrectionFlag,
primitive.PropertyMQ2Flag, primitive.PropertyReconsumeTime,
primitive.PropertyMsgRegion, primitive.PropertyTraceSwitch,
primitive.PropertyMaxReconsumeTimes, primitive.PropertyConsumeStartTime,
primitive.PropertyTranscationPreparedQueueOffset, primitive.PropertyTranscationCheckTimes,
primitive.PropertyCheckImmunityTimeInSeconds, primitive.PropertyShardingKey,
primitive.PropertyTransactionID, primitive.PropertyCorrelationID,
primitive.PropertyMessageReplyToClient, primitive.PropertyMessageTTL,
primitive.PropertyReplyMessageArriveTime, primitive.PropertyMsgType,
primitive.PropertyCluster:
return true
default:
return false
}
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"context"
"encoding/base64"
"net"
"strings"
"testing"
"time"
admin "github.com/amigoer/rocketmq-admin-go"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
"github.com/apache/rocketmq-client-go/v2/primitive"
)
func TestBuildSendMessageCommandCopiesOnlyUserProperties(t *testing.T) {
command := buildSendMessageCommand("Orders", []byte("payload"), 3, map[string]any{
"headers": map[string]any{
primitive.PropertyTags: "tag-a",
primitive.PropertyKeys: "key-a",
primitive.PropertyDelayTimeLevel: "18",
"Region": "Hangzhou",
"color": "blue",
"empty": " ",
"nil": nil,
},
}, 1234)
if command.Code != sendMessageRequestCode || command.ExtFields["queueId"] != "3" {
t.Fatalf("unexpected send command: %#v", command)
}
properties := command.ExtFields["properties"]
for _, expected := range []string{"TAGS\x01tag-a\x02", "KEYS\x01key-a\x02", "Region\x01Hangzhou\x02", "color\x01blue\x02"} {
if !strings.Contains(properties, expected) {
t.Fatalf("missing property %q in %q", expected, properties)
}
}
for _, forbidden := range []string{"DELAY\x01", "empty\x01", "nil\x01"} {
if strings.Contains(properties, forbidden) {
t.Fatalf("reserved/empty property %q leaked into %q", forbidden, properties)
}
}
}
func TestBuildQueryMessageCommandUsesBrokerHeaderNames(t *testing.T) {
command := buildQueryMessageCommand("Orders", "order-1", 32, 100, 200)
if command.ExtFields["beginTimestamp"] != "100" || command.ExtFields["endTimestamp"] != "200" {
t.Fatalf("unexpected query fields: %#v", command.ExtFields)
}
if _, exists := command.ExtFields["begin"]; exists {
t.Fatalf("legacy begin field leaked into query: %#v", command.ExtFields)
}
if _, exists := command.ExtFields["end"]; exists {
t.Fatalf("legacy end field leaked into query: %#v", command.ExtFields)
}
}
func TestInvokeRemotingAllowCodes(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
done <- acceptErr
return
}
defer connection.Close()
frame, readErr := readRemotingFrame(connection)
if readErr != nil {
done <- readErr
return
}
request, decodeErr := remoting.Decode(frame[4:])
if decodeErr != nil {
done <- decodeErr
return
}
response := &remoting.RemotingCommand{Code: 11, Opaque: request.Opaque, Flag: 1, ExtFields: map[string]string{}}
encoded, encodeErr := response.Encode()
if encodeErr == nil {
_, encodeErr = connection.Write(encoded)
}
done <- encodeErr
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := invokeRemotingAllowCodes(ctx, listener.Addr().String(), time.Second,
remoting.NewRequest(sendMessageRequestCode, nil), 0, 10, 11, 12); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestMessageMapPreservesBinaryAndTextPayloads(t *testing.T) {
text := &admin.MessageExt{
QueueId: 2, QueueOffset: 7, MsgId: "msg-1", Body: []byte("hello"),
StoreTimestamp: 123, Properties: map[string]string{primitive.PropertyKeys: "key-a", primitive.PropertyTags: "tag-a"},
}
row := messageMap("Orders", text)
if row["payloadText"] != "hello" || row["payloadBase64"] != base64.StdEncoding.EncodeToString([]byte("hello")) {
t.Fatalf("unexpected text message row: %#v", row)
}
if row["key"] != "key-a" || row["tag"] != "tag-a" || row["partition"] != 2 {
t.Fatalf("unexpected message metadata: %#v", row)
}
binary := &admin.MessageExt{Body: []byte{0xff, 0xfe}, Properties: map[string]string{}}
binaryRow := messageMap("Binary", binary)
if _, ok := binaryRow["payloadText"]; ok {
t.Fatalf("binary payload exposed as text: %#v", binaryRow)
}
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"context"
"fmt"
"time"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
)
func invokeRemotingWithClient(ctx context.Context, address string, command *remoting.RemotingCommand) (*remoting.RemotingCommand, error) {
return invokeRemotingAllowCodes(ctx, address, defaultRequestTimeout, command, remoting.Success)
}
func invokeRemotingAllowCodes(
ctx context.Context,
address string,
connectTimeout time.Duration,
command *remoting.RemotingCommand,
allowedCodes ...int,
) (*remoting.RemotingCommand, error) {
client := remoting.NewClient(address, connectTimeout)
if err := client.Connect(); err != nil {
return nil, err
}
defer client.Close()
response, err := client.InvokeSync(ctx, command)
if err != nil {
return nil, err
}
allowed := false
for _, code := range allowedCodes {
if response.Code == code {
allowed = true
break
}
}
if !allowed {
return nil, fmt.Errorf("RocketMQ error %d: %s", response.Code, response.Remark)
}
return response, nil
}
+526
View File
@@ -0,0 +1,526 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"regexp"
"sort"
"strings"
"sync"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
"golang.org/x/net/proxy"
)
type proxyTargetKind int
const (
proxyTargetNameServer proxyTargetKind = iota
proxyTargetBroker
)
type proxyEndpoint struct {
original string
targets []string
fallback bool
listener net.Listener
manager *proxyManager
closeOnce sync.Once
}
type proxyManager struct {
mu sync.RWMutex
config connectionConfig
byOriginal map[string]*proxyEndpoint
originalByLocal map[string]string
routedOwners map[string]string
closed bool
}
func newProxyManager(config connectionConfig) *proxyManager {
return &proxyManager{
config: config, byOriginal: map[string]*proxyEndpoint{},
originalByLocal: map[string]string{}, routedOwners: map[string]string{},
}
}
func (manager *proxyManager) ProxyFor(original string, kind proxyTargetKind) (string, error) {
original = strings.TrimSpace(original)
if original == "" {
return "", fmt.Errorf("empty RocketMQ endpoint")
}
manager.mu.RLock()
if endpoint := manager.byOriginal[original]; endpoint != nil {
address := endpoint.listener.Addr().String()
manager.mu.RUnlock()
return address, nil
}
manager.mu.RUnlock()
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.closed {
return "", fmt.Errorf("RocketMQ proxy manager is closed")
}
if endpoint := manager.byOriginal[original]; endpoint != nil {
return endpoint.listener.Addr().String(), nil
}
targets, fallback := manager.routeTargetsLocked(original, kind)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", fmt.Errorf("start RocketMQ endpoint proxy: %w", err)
}
endpoint := &proxyEndpoint{original: original, targets: targets, fallback: fallback, listener: listener, manager: manager}
local := listener.Addr().String()
manager.byOriginal[original] = endpoint
manager.originalByLocal[local] = original
go endpoint.serve()
return local, nil
}
func (manager *proxyManager) routeTargetsLocked(original string, kind proxyTargetKind) ([]string, bool) {
if kind == proxyTargetNameServer || manager.config.SocksProxy != nil {
return []string{original}, false
}
if manager.config.BrokerAddr != "" {
return []string{manager.config.BrokerAddr}, false
}
host, port := parseSocketAddress(original)
if port == "" || !isLikelyUnreachableBrokerHost(host) {
return []string{original}, false
}
nameServerHost, _ := parseSocketAddress(manager.config.NameServers[0])
routed := formatSocketAddress(nameServerHost, port)
if owner := manager.routedOwners[routed]; owner == "" || owner == original {
manager.routedOwners[routed] = original
return []string{routed, original}, false
}
// Multiple private brokers often advertise the same port. Routing a collision to the
// published host would silently contact the first broker, so keep the original only.
return []string{original}, true
}
func (manager *proxyManager) LocalForOriginal(original string) string {
manager.mu.RLock()
defer manager.mu.RUnlock()
if endpoint := manager.byOriginal[original]; endpoint != nil {
return endpoint.listener.Addr().String()
}
return ""
}
func (manager *proxyManager) OriginalForLocal(local string) string {
manager.mu.RLock()
defer manager.mu.RUnlock()
if original := manager.originalByLocal[local]; original != "" {
return original
}
host, port := parseSocketAddress(local)
if host == "localhost" {
return manager.originalByLocal[formatSocketAddress("127.0.0.1", port)]
}
return ""
}
func (manager *proxyManager) IsCollisionFallback(local string) bool {
manager.mu.RLock()
defer manager.mu.RUnlock()
original := manager.originalByLocal[local]
return original != "" && manager.byOriginal[original] != nil && manager.byOriginal[original].fallback
}
func (manager *proxyManager) Close() {
manager.mu.Lock()
if manager.closed {
manager.mu.Unlock()
return
}
manager.closed = true
endpoints := make([]*proxyEndpoint, 0, len(manager.byOriginal))
for _, endpoint := range manager.byOriginal {
endpoints = append(endpoints, endpoint)
}
manager.mu.Unlock()
for _, endpoint := range endpoints {
endpoint.closeOnce.Do(func() { _ = endpoint.listener.Close() })
}
}
func (endpoint *proxyEndpoint) serve() {
for {
clientConn, err := endpoint.listener.Accept()
if err != nil {
return
}
go endpoint.handle(clientConn)
}
}
func (endpoint *proxyEndpoint) handle(clientConn net.Conn) {
defer clientConn.Close()
remoteConn, err := endpoint.dialRemote()
if err != nil {
return
}
defer remoteConn.Close()
done := make(chan struct{}, 2)
go func() {
_ = endpoint.forward(clientConn, remoteConn, false)
done <- struct{}{}
}()
go func() {
_ = endpoint.forward(remoteConn, clientConn, true)
done <- struct{}{}
}()
<-done
}
func (endpoint *proxyEndpoint) dialRemote() (net.Conn, error) {
var lastErr error
for _, target := range endpoint.targets {
var conn net.Conn
var err error
if socks := endpoint.manager.config.SocksProxy; socks != nil {
var auth *proxy.Auth
if socks.Username != "" || socks.Password != "" {
auth = &proxy.Auth{User: socks.Username, Password: socks.Password}
}
dialer, dialErr := proxy.SOCKS5("tcp", formatSocketAddress(socks.Host, fmt.Sprintf("%d", socks.Port)), auth, &net.Dialer{Timeout: endpoint.manager.config.ConnectTimeout})
if dialErr != nil {
lastErr = dialErr
continue
}
conn, err = dialer.Dial("tcp", target)
} else {
ctx, cancel := context.WithTimeout(context.Background(), endpoint.manager.config.ConnectTimeout)
conn, err = (&net.Dialer{}).DialContext(ctx, "tcp", target)
cancel()
}
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, fmt.Errorf("connect RocketMQ endpoint %s: %w", endpoint.original, lastErr)
}
func (endpoint *proxyEndpoint) forward(source, destination net.Conn, response bool) error {
for {
frame, err := readRemotingFrame(source)
if err != nil {
return err
}
command, err := remoting.Decode(frame[4:])
if err != nil {
return err
}
if response {
if err := endpoint.manager.rewriteResponse(command); err != nil {
return err
}
} else {
signCommand(command, endpoint.manager.config.AccessKey, endpoint.manager.config.SecretKey)
}
encoded, err := command.Encode()
if err != nil {
return err
}
if _, err := destination.Write(encoded); err != nil {
return err
}
}
}
func readRemotingFrame(reader io.Reader) ([]byte, error) {
header := make([]byte, 4)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, err
}
length := int(binary.BigEndian.Uint32(header))
if length <= 0 || length > 64*1024*1024 {
return nil, fmt.Errorf("invalid RocketMQ frame length: %d", length)
}
frame := make([]byte, 4+length)
copy(frame, header)
if _, err := io.ReadFull(reader, frame[4:]); err != nil {
return nil, err
}
return frame, nil
}
func signCommand(command *remoting.RemotingCommand, accessKey, secretKey string) {
if accessKey == "" && secretKey == "" {
return
}
if command.ExtFields == nil {
command.ExtFields = map[string]string{}
}
delete(command.ExtFields, "Signature")
command.ExtFields["AccessKey"] = accessKey
keys := make([]string, 0, len(command.ExtFields))
for key := range command.ExtFields {
if key != "Signature" {
keys = append(keys, key)
}
}
sort.Strings(keys)
var content strings.Builder
for _, key := range keys {
content.WriteString(command.ExtFields[key])
}
data := append([]byte(content.String()), command.Body...)
mac := hmac.New(sha1.New, []byte(secretKey))
_, _ = mac.Write(data)
command.ExtFields["Signature"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func (manager *proxyManager) rewriteResponse(command *remoting.RemotingCommand) error {
if len(command.Body) == 0 {
return nil
}
body := repairRocketMQJSON(command.Body)
if !json.Valid(body) {
return nil
}
var value any
if err := json.Unmarshal(body, &value); err != nil {
return nil
}
changed, err := manager.rewriteJSON(value, "")
if err != nil || !changed {
return err
}
body, err = json.Marshal(value)
if err != nil {
return err
}
command.Body = body
return nil
}
var (
unquotedNumberKey = regexp.MustCompile(`([{,])(\d+):`)
unquotedStringKey = regexp.MustCompile(`([{,])([a-zA-Z_][a-zA-Z0-9_]*):`)
)
func repairRocketMQJSON(body []byte) []byte {
repaired := unquotedNumberKey.ReplaceAll(body, []byte(`$1"$2":`))
repaired = unquotedStringKey.ReplaceAll(repaired, []byte(`$1"$2":`))
return repairObjectKeyedMaps(repaired)
}
func repairObjectKeyedMaps(body []byte) []byte {
result := make([]byte, 0, len(body)+64)
for index := 0; index < len(body); {
if index+2 < len(body) && body[index] == ':' && body[index+1] == '{' && body[index+2] == '{' {
converted, next := convertObjectKeyedMap(body, index+1)
result = append(result, ':')
result = append(result, converted...)
index = next
continue
}
result = append(result, body[index])
index++
}
return result
}
func convertObjectKeyedMap(body []byte, start int) ([]byte, int) {
if start >= len(body) || body[start] != '{' {
return []byte("{}"), start
}
result := []byte{'{'}
index := start + 1
first := true
for index < len(body) {
for index < len(body) && isJSONSpace(body[index]) {
index++
}
if index >= len(body) {
break
}
if body[index] == '}' {
return append(result, '}'), index + 1
}
if body[index] == ',' {
index++
continue
}
if body[index] != '{' {
return originalJSONMap(body, start)
}
keyEnd := matchingBrace(body, index)
if keyEnd < 0 {
return body[start:], len(body)
}
key := body[index : keyEnd+1]
index = keyEnd + 1
for index < len(body) && isJSONSpace(body[index]) {
index++
}
if index >= len(body) || body[index] != ':' {
return originalJSONMap(body, start)
}
index++
for index < len(body) && isJSONSpace(body[index]) {
index++
}
if index >= len(body) || body[index] != '{' {
return originalJSONMap(body, start)
}
valueEnd := matchingBrace(body, index)
if valueEnd < 0 {
return body[start:], len(body)
}
if !first {
result = append(result, ',')
}
first = false
result = append(result, '"')
result = append(result, escapeJSONString(key)...)
result = append(result, '"', ':')
result = append(result, body[index:valueEnd+1]...)
index = valueEnd + 1
}
return append(result, '}'), len(body)
}
func originalJSONMap(body []byte, start int) ([]byte, int) {
end := matchingBrace(body, start)
if end < 0 {
return body[start:], len(body)
}
return body[start : end+1], end + 1
}
func matchingBrace(body []byte, start int) int {
depth := 0
inString := false
escaped := false
for index := start; index < len(body); index++ {
character := body[index]
if inString {
if escaped {
escaped = false
continue
}
if character == '\\' {
escaped = true
continue
}
if character == '"' {
inString = false
}
continue
}
switch character {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return index
}
}
}
return -1
}
func escapeJSONString(value []byte) []byte {
result := make([]byte, 0, len(value)+8)
for _, character := range value {
switch character {
case '\\', '"':
result = append(result, '\\', character)
case '\n':
result = append(result, '\\', 'n')
case '\r':
result = append(result, '\\', 'r')
case '\t':
result = append(result, '\\', 't')
default:
result = append(result, character)
}
}
return result
}
func isJSONSpace(character byte) bool {
return character == ' ' || character == '\n' || character == '\r' || character == '\t'
}
func (manager *proxyManager) rewriteJSON(value any, parentKey string) (bool, error) {
changed := false
switch typed := value.(type) {
case map[string]any:
if parentKey == "brokerAddrs" {
for key, raw := range typed {
address, ok := raw.(string)
if !ok || address == "" {
continue
}
local, err := manager.ProxyFor(address, proxyTargetBroker)
if err != nil {
return changed, err
}
typed[key] = local
changed = true
}
return changed, nil
}
if parentKey == "filterServerTable" {
rewritten := make(map[string]any, len(typed))
for address, raw := range typed {
local, err := manager.ProxyFor(address, proxyTargetBroker)
if err != nil {
return changed, err
}
rewritten[local] = raw
changed = true
}
for key := range typed {
delete(typed, key)
}
for key, raw := range rewritten {
typed[key] = raw
}
}
for key, child := range typed {
if key == "brokerAddr" {
if address, ok := child.(string); ok && address != "" {
local, err := manager.ProxyFor(address, proxyTargetBroker)
if err != nil {
return changed, err
}
typed[key] = local
changed = true
continue
}
}
childChanged, err := manager.rewriteJSON(child, key)
if err != nil {
return changed, err
}
changed = changed || childChanged
}
case []any:
for _, child := range typed {
childChanged, err := manager.rewriteJSON(child, parentKey)
if err != nil {
return changed, err
}
changed = changed || childChanged
}
}
return changed, nil
}
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"reflect"
"sort"
"strings"
"testing"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
)
func TestProxyRoutesPrivateBrokersAndFailsClosedOnCollision(t *testing.T) {
manager := newProxyManager(connectionConfig{NameServers: []string{"127.0.0.1:9876"}})
defer manager.Close()
firstLocal, err := manager.ProxyFor("172.18.0.2:10911", proxyTargetBroker)
if err != nil {
t.Fatal(err)
}
secondLocal, err := manager.ProxyFor("172.18.0.3:10911", proxyTargetBroker)
if err != nil {
t.Fatal(err)
}
first := manager.byOriginal["172.18.0.2:10911"]
second := manager.byOriginal["172.18.0.3:10911"]
if !reflect.DeepEqual(first.targets, []string{"127.0.0.1:10911", "172.18.0.2:10911"}) {
t.Fatalf("unexpected first targets: %#v", first.targets)
}
if !reflect.DeepEqual(second.targets, []string{"172.18.0.3:10911"}) || !second.fallback {
t.Fatalf("collision must keep only original target: %#v", second)
}
if manager.OriginalForLocal(firstLocal) != "172.18.0.2:10911" || manager.OriginalForLocal(secondLocal) != "172.18.0.3:10911" {
t.Fatal("local endpoint did not round-trip to original address")
}
if !manager.IsCollisionFallback(secondLocal) {
t.Fatal("second private broker should be marked as collision fallback")
}
}
func TestProxyRoutingHonorsSocksAndExplicitBroker(t *testing.T) {
socksManager := newProxyManager(connectionConfig{
NameServers: []string{"127.0.0.1:9876"},
SocksProxy: &socksProxyConfig{Host: "127.0.0.1", Port: 1080},
})
defer socksManager.Close()
local, err := socksManager.ProxyFor("172.18.0.2:10911", proxyTargetBroker)
if err != nil {
t.Fatal(err)
}
if targets := socksManager.byOriginal[socksManager.OriginalForLocal(local)].targets; !reflect.DeepEqual(targets, []string{"172.18.0.2:10911"}) {
t.Fatalf("SOCKS route changed logical broker: %#v", targets)
}
explicitManager := newProxyManager(connectionConfig{
NameServers: []string{"127.0.0.1:9876"}, BrokerAddr: "broker.example.com:10911",
})
defer explicitManager.Close()
local, err = explicitManager.ProxyFor("172.18.0.2:10911", proxyTargetBroker)
if err != nil {
t.Fatal(err)
}
if targets := explicitManager.byOriginal[explicitManager.OriginalForLocal(local)].targets; !reflect.DeepEqual(targets, []string{"broker.example.com:10911"}) {
t.Fatalf("explicit broker was not honored: %#v", targets)
}
}
func TestSignCommandMatchesRocketMQACLAlgorithm(t *testing.T) {
command := remoting.NewRequest(17, map[string]string{"topic": "Orders", "perm": "6"})
command.Body = []byte("payload")
signCommand(command, "access", "secret")
keys := make([]string, 0, len(command.ExtFields))
for key := range command.ExtFields {
if key != "Signature" {
keys = append(keys, key)
}
}
sort.Strings(keys)
var content strings.Builder
for _, key := range keys {
content.WriteString(command.ExtFields[key])
}
mac := hmac.New(sha1.New, []byte("secret"))
_, _ = mac.Write(append([]byte(content.String()), command.Body...))
want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if command.ExtFields["AccessKey"] != "access" || command.ExtFields["Signature"] != want {
t.Fatalf("unexpected signed fields: %#v", command.ExtFields)
}
}
func TestRepairRocketMQJSONQuotesNonStandardKeys(t *testing.T) {
body := repairRocketMQJSON([]byte(`{"offsetTable":{{"brokerName":"broker-a","queueId":0,"topic":"Order-Events"}:{"brokerOffset":10}},brokerAddrs:{0:"127.0.0.1:10911"}}`))
if !json.Valid(body) {
t.Fatalf("repaired body is invalid JSON: %s", body)
}
if !bytes.Contains(body, []byte(`"0":`)) || !bytes.Contains(body, []byte(`"{\"brokerName\":\"broker-a\"`)) {
t.Fatalf("missing repaired keys: %s", body)
}
}
func TestRewriteResponseProxiesBrokerAddresses(t *testing.T) {
manager := newProxyManager(connectionConfig{NameServers: []string{"127.0.0.1:9876"}})
defer manager.Close()
command := &remoting.RemotingCommand{Body: []byte(`{
"brokerDatas":[{"brokerAddrs":{"0":"172.18.0.2:10911"}}],
"brokerAddr":"172.18.0.3:10912",
"filterServerTable":{"172.18.0.4:12000":[]}
}`)}
if err := manager.rewriteResponse(command); err != nil {
t.Fatal(err)
}
var value map[string]any
if err := json.Unmarshal(command.Body, &value); err != nil {
t.Fatal(err)
}
brokerDatas := value["brokerDatas"].([]any)
brokerLocal := brokerDatas[0].(map[string]any)["brokerAddrs"].(map[string]any)["0"].(string)
if manager.OriginalForLocal(brokerLocal) != "172.18.0.2:10911" {
t.Fatalf("broker address not proxied: %s", brokerLocal)
}
directLocal := value["brokerAddr"].(string)
if manager.OriginalForLocal(directLocal) != "172.18.0.3:10912" {
t.Fatalf("direct broker address not proxied: %s", directLocal)
}
filterTable := value["filterServerTable"].(map[string]any)
for local := range filterTable {
if manager.OriginalForLocal(local) != "172.18.0.4:12000" {
t.Fatalf("filter server address not proxied: %s", local)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
# The DBX RocketMQ agent under test is the Go binary in this directory. Maven
# only supplies disposable official RocketMQ NameServer/Broker server JARs.
version="${1:?RocketMQ version is required}"
module_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/dbx-rocketmq-${version}.XXXXXX")
namesrv_port="${ROCKETMQ_NAMESRV_PORT:-19876}"
broker_port="${ROCKETMQ_BROKER_PORT:-20911}"
store_root="${ROCKETMQ_STORE_ROOT:-${work_dir}/store}"
namesrv_pid=""
broker_pid=""
cleanup() {
if [[ -n "$broker_pid" ]] && kill -0 "$broker_pid" 2>/dev/null; then
kill "$broker_pid" 2>/dev/null || true
fi
if [[ -n "$namesrv_pid" ]] && kill -0 "$namesrv_pid" 2>/dev/null; then
kill "$namesrv_pid" 2>/dev/null || true
fi
wait "$broker_pid" 2>/dev/null || true
wait "$namesrv_pid" 2>/dev/null || true
rm -rf "$work_dir"
}
trap cleanup EXIT
cat >"$work_dir/pom.xml" <<EOF
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.dbx.test</groupId>
<artifactId>rocketmq-integration</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-namesrv</artifactId>
<version>${version}</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-broker</artifactId>
<version>${version}</version>
</dependency>
</dependencies>
</project>
EOF
mvn -q -f "$work_dir/pom.xml" dependency:copy-dependencies -DoutputDirectory="$work_dir/lib"
mkdir -p "$work_dir/conf"
for component in namesrv broker; do
cat >"$work_dir/conf/logback_${component}.xml" <<'EOF'
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
EOF
done
cat >"$work_dir/namesrv.conf" <<EOF
listenPort=${namesrv_port}
EOF
cat >"$work_dir/broker.conf" <<EOF
brokerClusterName=DefaultCluster
brokerName=broker-a
brokerId=0
brokerIP1=127.0.0.1
namesrvAddr=127.0.0.1:${namesrv_port}
listenPort=${broker_port}
haListenPort=$((broker_port + 1))
autoCreateTopicEnable=true
autoCreateSubscriptionGroup=true
deleteWhen=04
fileReservedTime=1
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
storePathRootDir=${store_root}
storePathCommitLog=${store_root}/commitlog
maxMessageSize=4194304
diskMaxUsedSpaceRatio=95
EOF
java -Xms128m -Xmx128m -Xmn64m \
-Drocketmq.home.dir="$work_dir" -Duser.home="$work_dir" \
-cp "$work_dir/lib/*" org.apache.rocketmq.namesrv.NamesrvStartup -c "$work_dir/namesrv.conf" \
>"$work_dir/namesrv.stdout.log" 2>&1 &
namesrv_pid=$!
java -Xms256m -Xmx256m -Xmn128m \
--add-exports=java.base/jdk.internal.ref=ALL-UNNAMED \
--add-opens=java.base/java.nio=ALL-UNNAMED \
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED \
-Drocketmq.home.dir="$work_dir" -Duser.home="$work_dir" \
-cp "$work_dir/lib/*" org.apache.rocketmq.broker.BrokerStartup -c "$work_dir/broker.conf" \
>"$work_dir/broker.stdout.log" 2>&1 &
broker_pid=$!
for _ in $(seq 1 120); do
if ! kill -0 "$namesrv_pid" 2>/dev/null; then
cat "$work_dir/namesrv.stdout.log"
exit 1
fi
if ! kill -0 "$broker_pid" 2>/dev/null; then
cat "$work_dir/broker.stdout.log"
exit 1
fi
if grep -Rqs "boot success" "$work_dir/logs" "$work_dir/broker.stdout.log" 2>/dev/null; then
break
fi
sleep 1
done
if ! grep -Rqs "boot success" "$work_dir/logs" "$work_dir/broker.stdout.log" 2>/dev/null; then
cat "$work_dir/namesrv.stdout.log"
cat "$work_dir/broker.stdout.log"
find "$work_dir/logs" -type f -maxdepth 3 -print -exec tail -80 {} \; 2>/dev/null || true
exit 1
fi
ROCKETMQ_INTEGRATION=1 \
ROCKETMQ_VERSION="$version" \
ROCKETMQ_NAMESRV_ADDR="127.0.0.1:${namesrv_port}" \
go test -C "$module_dir" -run '^TestRocketMQIntegration$' -count=1 -v .
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
admin "github.com/amigoer/rocketmq-admin-go"
)
const (
protocolVersion = 1
agentProtocolVersion = 1
)
var capabilities = []string{
"mq_connect", "mq_test_connection", "mq_topics", "mq_consumer_groups",
"mq_messages", "mq_acl", "mq_config", "mq_monitoring",
}
type rocketMQAgent struct {
mu sync.RWMutex
client *admin.Client
connection connectionConfig
proxies *proxyManager
clusterName string
brokerAddr string
shutdownRequested bool
}
func newRocketMQAgent() *rocketMQAgent {
return &rocketMQAgent{}
}
func (a *rocketMQAgent) dispatch(method string, raw json.RawMessage) (any, error) {
params, err := decodeParams(raw)
if err != nil {
return nil, err
}
switch method {
case "handshake":
return map[string]any{
"protocolVersion": protocolVersion,
"agentProtocolVersion": agentProtocolVersion,
"capabilities": capabilities,
}, nil
case "connect":
return a.connect(params)
case "test_connection":
return a.testConnection(params)
case "disconnect":
a.close()
return okResult(), nil
case "shutdown":
a.close()
a.shutdownRequested = true
return okResult(), nil
case "mq_list_topics":
return a.listTopics(params)
case "mq_create_topic":
return a.createTopic(params)
case "mq_delete_topic":
return a.deleteTopic(params)
case "mq_update_partitions":
return a.updatePartitions(params)
case "mq_get_topic_stats":
return a.getTopicStats(params)
case "mq_get_topic_route":
return a.getTopicRoute(params)
case "mq_get_topic_config":
return a.getTopicConfig(params)
case "mq_alter_topic_config":
return a.alterTopicConfig(params)
case "mq_skip_topic_accumulation":
return a.skipTopicAccumulation(params)
case "mq_list_consumer_groups":
return a.listConsumerGroups(params)
case "mq_describe_consumer_group":
return a.describeConsumerGroup(params)
case "mq_delete_consumer_group":
return a.deleteConsumerGroup(params)
case "mq_get_subscription_group_config":
return a.getSubscriptionGroupConfig(params)
case "mq_alter_subscription_group_config":
return a.alterSubscriptionGroupConfig(params)
case "mq_reset_consumer_group_offsets":
return a.resetConsumerGroupOffsets(params)
case "mq_get_consumer_lag":
return a.getConsumerLag(params)
case "mq_list_producers":
return a.listProducers(params)
case "mq_peek_messages":
return a.peekMessages(params)
case "mq_view_message":
return a.viewMessage(params)
case "mq_query_message_by_key":
return a.queryMessageByKey(params)
case "mq_query_message_by_topic":
return a.queryMessageByTopic(params)
case "mq_query_message_trace":
return a.queryMessageTrace(params)
case "mq_send_message":
return a.sendMessage(params)
case "mq_list_acls":
return a.listACLs(params)
case "mq_create_acls":
return a.createACLs(params)
case "mq_delete_acls":
return a.deleteACLs(params)
case "mq_describe_cluster":
return a.describeCluster(params)
default:
return nil, fmt.Errorf("unknown method: %s", method)
}
}
func (a *rocketMQAgent) requireClient() (*admin.Client, connectionConfig, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.client == nil {
return nil, connectionConfig{}, errors.New("RocketMQ agent is not connected")
}
return a.client, a.connection, nil
}
func (a *rocketMQAgent) close() {
a.mu.Lock()
defer a.mu.Unlock()
if a.client != nil {
_ = a.client.Close()
}
if a.proxies != nil {
a.proxies.Close()
}
a.client = nil
a.proxies = nil
a.connection = connectionConfig{}
a.clusterName = ""
a.brokerAddr = ""
}
func decodeParams(raw json.RawMessage) (map[string]any, error) {
if len(raw) == 0 || string(raw) == "null" {
return map[string]any{}, nil
}
var params map[string]any
if err := json.Unmarshal(raw, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
return params, nil
}
func normalizeError(err error) string {
if err == nil {
return ""
}
message := strings.TrimSpace(err.Error())
if message == "" {
return "RocketMQ operation failed"
}
return message
}
func okResult() map[string]any {
return map[string]any{"ok": true}
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"encoding/json"
"testing"
)
func TestHandshakeAndShutdown(t *testing.T) {
agent := newRocketMQAgent()
result, err := agent.dispatch("handshake", json.RawMessage(`{}`))
if err != nil {
t.Fatal(err)
}
handshake := result.(map[string]any)
if handshake["protocolVersion"] != protocolVersion || handshake["agentProtocolVersion"] != agentProtocolVersion {
t.Fatalf("unexpected handshake: %#v", handshake)
}
if _, err := agent.dispatch("shutdown", nil); err != nil {
t.Fatal(err)
}
if !agent.shutdownRequested {
t.Fatal("shutdown flag was not set")
}
}
func TestDispatchRejectsInvalidParamsAndUnknownMethods(t *testing.T) {
agent := newRocketMQAgent()
if _, err := agent.dispatch("handshake", json.RawMessage(`[]`)); err == nil {
t.Fatal("expected array params to fail")
}
if _, err := agent.dispatch("does_not_exist", json.RawMessage(`{}`)); err == nil {
t.Fatal("expected unknown method to fail")
}
}
File diff suppressed because it is too large Load Diff
@@ -1,179 +0,0 @@
package com.dbx.agent.rocketmq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.MessageSelector;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/**
* Live RocketMQ tests on 127.0.0.1:9876. SQL92 filtering requires broker
* {@code enablePropertyFilter=true} (skipped when unsupported).
*/
class RocketMqLiveMessagePropertyTest {
private static final String NAMESRV = "127.0.0.1:9876";
private static final String TOPIC = "CS-PT";
private static DefaultMQAdminExt admin;
private static JsonObject conn;
private static boolean available;
@BeforeAll
static void connect() {
conn = JsonParser.parseString("""
{"namesrv_addr":"127.0.0.1:9876"}
""").getAsJsonObject();
admin = new DefaultMQAdminExt();
admin.setNamesrvAddr(NAMESRV);
try {
admin.start();
admin.examineBrokerClusterInfo();
available = true;
} catch (Exception e) {
available = false;
}
}
@AfterAll
static void shutdown() {
if (admin != null) {
admin.shutdown();
}
}
@Test
void sendMessageUserPropertyRoundTripViaPeek() throws Exception {
assumeTrue(available, "RocketMQ namesrv not available on " + NAMESRV);
DefaultMQProducer producer = RocketMqAgent.buildProducer(conn);
try {
String marker = "dbx-prop-" + UUID.randomUUID();
JsonObject sendParams = JsonParser.parseString("""
{
"topic":"%s",
"tag":"prop-test",
"payloadBase64":"%s",
"headers":{"TAGS":"prop-test","Region":"Hangzhou","marker":"%s"}
}
""".formatted(
TOPIC,
Base64.getEncoder().encodeToString(marker.getBytes(StandardCharsets.UTF_8)),
marker
)).getAsJsonObject();
@SuppressWarnings("unchecked")
Map<String, Object> sendResult = (Map<String, Object>) RocketMqAgent.sendMessageForTest(producer, sendParams);
Number partition = (Number) sendResult.get("partition");
Number offset = (Number) sendResult.get("offset");
assertNotNull(partition);
assertNotNull(offset);
JsonObject peekParams = JsonParser.parseString("""
{
"connection":{"namesrv_addr":"127.0.0.1:9876"},
"topic":"%s",
"count":1,
"partition":%d,
"offset":%d
}
""".formatted(TOPIC, partition.intValue(), offset.longValue())).getAsJsonObject();
@SuppressWarnings("unchecked")
Map<String, Object> peekResult = (Map<String, Object>) RocketMqAgent.peekMessagesForTest(admin, peekParams);
@SuppressWarnings("unchecked")
List<Map<String, Object>> messages = (List<Map<String, Object>>) peekResult.get("messages");
assumeTrue(messages != null && !messages.isEmpty(), "peek returned no messages at sent offset");
@SuppressWarnings("unchecked")
Map<String, String> headers = (Map<String, String>) messages.get(0).get("headers");
assertEquals("Hangzhou", headers.get("Region"));
assertEquals(marker, headers.get("marker"));
} finally {
producer.shutdown();
}
}
@Test
void sql92ConsumerFiltersOnUserProperty() throws Exception {
assumeTrue(available, "RocketMQ namesrv not available on " + NAMESRV);
String group = "dbx-sql92-" + UUID.randomUUID();
String markerMatch = "match-" + UUID.randomUUID();
String markerSkip = "skip-" + UUID.randomUUID();
CountDownLatch matched = new CountDownLatch(1);
AtomicInteger received = new AtomicInteger();
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(group);
consumer.setNamesrvAddr(NAMESRV);
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
try {
consumer.subscribe(
TOPIC,
MessageSelector.bySql("Region IS NOT NULL AND Region='Hangzhou' AND marker IS NOT NULL")
);
} catch (MQClientException e) {
assumeTrue(false, "Broker does not support SQL92 property filter: " + e.getMessage());
}
consumer.registerMessageListener((MessageListenerConcurrently) (messages, context) -> {
for (var message : messages) {
String marker = message.getProperty("marker");
if (markerMatch.equals(marker)) {
received.incrementAndGet();
matched.countDown();
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
try {
consumer.start();
} catch (MQClientException e) {
assumeTrue(false, "Consumer start failed for SQL92: " + e.getMessage());
}
DefaultMQProducer producer = RocketMqAgent.buildProducer(conn);
try {
sendWithMarker(producer, markerSkip, "Shanghai");
sendWithMarker(producer, markerMatch, "Hangzhou");
assumeTrue(matched.await(15, TimeUnit.SECONDS), "SQL92 consumer did not receive matching message");
assertEquals(1, received.get(), "only the Hangzhou message should match the filter");
} finally {
producer.shutdown();
consumer.shutdown();
}
}
private static void sendWithMarker(DefaultMQProducer producer, String marker, String region) throws Exception {
JsonObject sendParams = JsonParser.parseString("""
{
"topic":"%s",
"tag":"sql92-test",
"payloadBase64":"%s",
"headers":{"TAGS":"sql92-test","Region":"%s","marker":"%s"}
}
""".formatted(
TOPIC,
Base64.getEncoder().encodeToString(marker.getBytes(StandardCharsets.UTF_8)),
region,
marker
)).getAsJsonObject();
RocketMqAgent.sendMessageForTest(producer, sendParams);
}
}
@@ -1,125 +0,0 @@
package com.dbx.agent.rocketmq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.List;
import java.util.Map;
import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.remoting.protocol.body.TopicConfigSerializeWrapper;
import org.apache.rocketmq.tools.admin.DefaultMQAdminExt;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/**
* Live verification against local RocketMQ (127.0.0.1:9876). Skipped when namesrv is unavailable.
*/
class RocketMqLiveTopicTypeTest {
private static DefaultMQAdminExt admin;
private static boolean available;
@BeforeAll
static void connect() {
admin = new DefaultMQAdminExt();
admin.setNamesrvAddr("127.0.0.1:9876");
try {
admin.start();
admin.examineBrokerClusterInfo();
available = true;
} catch (Exception e) {
available = false;
}
}
@AfterAll
static void shutdown() {
if (admin != null) {
admin.shutdown();
}
}
@Test
void cs123BrokerConfigHasNormalMessageType() throws Exception {
assumeTrue(available, "RocketMQ namesrv not available on 127.0.0.1:9876");
JsonObject conn = JsonParser.parseString("""
{"namesrv_addr":"127.0.0.1:9876"}
""").getAsJsonObject();
String brokerAddr = RocketMqAgent.remapBrokerAddrForClient(
admin.examineBrokerClusterInfo().getBrokerAddrTable().values().iterator().next().selectBrokerAddr(),
conn);
TopicConfig config = admin.examineTopicConfig(brokerAddr, "CS123");
assumeTrue(config != null, "CS123 topic config missing");
String fromAttributes = config.getAttributes() == null ? null : config.getAttributes().get("message.type");
assertEquals("NORMAL", fromAttributes, "attributes.message.type");
}
@Test
void getAllTopicConfigIncludesCs123MessageType() throws Exception {
assumeTrue(available, "RocketMQ namesrv not available on 127.0.0.1:9876");
JsonObject conn = JsonParser.parseString("""
{"namesrv_addr":"127.0.0.1:9876"}
""").getAsJsonObject();
String brokerAddr = RocketMqAgent.remapBrokerAddrForClient(
admin.examineBrokerClusterInfo().getBrokerAddrTable().values().iterator().next().selectBrokerAddr(),
conn);
TopicConfigSerializeWrapper wrapper = admin.getAllTopicConfig(brokerAddr, 10_000L);
TopicConfig config = wrapper.getTopicConfigTable().get("CS123");
assumeTrue(config != null, "CS123 missing from getAllTopicConfig");
String fromAttributes = config.getAttributes() == null ? null : config.getAttributes().get("message.type");
assertEquals("NORMAL", fromAttributes, "bulk attributes.message.type");
}
@Test
void agentListTopicsClassifiesAllTopicsAgainstBroker() throws Exception {
assumeTrue(available, "RocketMQ namesrv not available on 127.0.0.1:9876");
JsonObject params = JsonParser.parseString("""
{"connection":{"namesrv_addr":"127.0.0.1:9876"},"limit":500,"offset":0}
""").getAsJsonObject();
Object result = RocketMqAgent.listTopicsWithAdminForTest(admin, params);
assumeTrue(result instanceof Map, "unexpected listTopics result");
@SuppressWarnings("unchecked")
List<Map<String, Object>> topics = (List<Map<String, Object>>) ((Map<?, ?>) result).get("topics");
assumeTrue(topics != null && !topics.isEmpty(), "no topics returned");
Map<String, Object> cs123 = topics.stream()
.filter(row -> "CS123".equals(String.valueOf(row.get("name"))))
.findFirst()
.orElse(null);
assumeTrue(cs123 != null, "CS123 not in agent topic list");
assertEquals("NORMAL", String.valueOf(cs123.get("messageType")), "agent messageType for CS123");
Map<String, String> expected = Map.ofEntries(
Map.entry("CS123", "NORMAL"),
Map.entry("BenchmarkTest", "SYSTEM"),
Map.entry("SCHEDULE_TOPIC_XXXX", "SYSTEM"),
Map.entry("RMQ_SYS_TRANS_HALF_TOPIC", "SYSTEM"),
Map.entry("RMQ_SYS_TRANS_OP_HALF_TOPIC", "SYSTEM"),
Map.entry("RMQ_SYS_ROCKSDB_TRANS_HALF_TOPIC", "SYSTEM"),
Map.entry("RMQ_SYS_ROCKSDB_TRANS_OP_HALF_TOPIC", "SYSTEM"),
Map.entry("rmq_sys_wheel_timer", "SYSTEM"),
Map.entry("rmq_sys_REVIVE_LOG_DefaultCluster", "SYSTEM"),
Map.entry("rmq_sys_SYNC_BROKER_MEMBER_37e06a7b803c", "SYSTEM"),
Map.entry("rmq_sys_SYNC_BROKER_MEMBER_broker-a", "SYSTEM"),
Map.entry("DefaultHeartBeatSyncerTopic", "SYSTEM"),
Map.entry("DefaultCluster_REPLY_TOPIC", "SYSTEM"),
Map.entry("SELF_TEST_TOPIC", "SYSTEM"),
Map.entry("OFFSET_MOVED_EVENT", "SYSTEM"),
Map.entry("TBW102", "SYSTEM"),
Map.entry("DefaultCluster", "SYSTEM"),
Map.entry("broker-a", "SYSTEM"),
Map.entry("37e06a7b803c", "UNSPECIFIED"),
Map.entry("%RETRY%CID_DefaultHeartBeatSyncerTopic", "RETRY")
);
for (Map<String, Object> row : topics) {
String name = String.valueOf(row.get("name"));
String messageType = String.valueOf(row.get("messageType"));
if (expected.containsKey(name)) {
assertEquals(expected.get(name), messageType, "messageType for " + name);
}
}
}
}
+620
View File
@@ -0,0 +1,620 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
admin "github.com/amigoer/rocketmq-admin-go"
"github.com/amigoer/rocketmq-admin-go/protocol/remoting"
)
var reservedTopicNames = map[string]struct{}{
"TBW102": {}, "BenchmarkTest": {}, "SELF_TEST_TOPIC": {},
"OFFSET_MOVED_EVENT": {}, "DefaultHeartBeatSyncerTopic": {},
}
type topicConfigWire struct {
TopicName string `json:"topicName"`
ReadQueueNums int `json:"readQueueNums"`
WriteQueueNums int `json:"writeQueueNums"`
Perm int `json:"perm"`
TopicFilterType string `json:"topicFilterType"`
TopicSysFlag int `json:"topicSysFlag"`
Order bool `json:"order"`
Attributes map[string]string `json:"attributes"`
}
func (a *rocketMQAgent) listTopics(params map[string]any) (any, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
a.mu.RLock()
clusterName, brokerAddr := a.clusterName, a.brokerAddr
a.mu.RUnlock()
topicList, err := client.FetchTopicsByCluster(ctx, clusterName)
if err != nil {
topicList, err = client.FetchAllTopicList(ctx)
}
if err != nil {
return nil, err
}
_ = brokerAddr
configs, complete := a.collectTopicConfigs(ctx)
keyword := strings.ToLower(stringValue(params, "keyword"))
rows := make([]map[string]any, 0, len(topicList.TopicList))
names := make(map[string]struct{}, len(topicList.TopicList)+len(configs))
for _, name := range topicList.TopicList {
names[name] = struct{}{}
}
for name := range configs {
names[name] = struct{}{}
}
if complete && len(configs) > 0 {
names = make(map[string]struct{}, len(configs))
for name := range configs {
names[name] = struct{}{}
}
}
for _, name := range sortedKeys(names) {
if keyword != "" && !strings.Contains(strings.ToLower(name), keyword) {
continue
}
configEntry := configs[name]
partitions := 0
perm := 6
messageType := classifyTopicMessageType(name, clusterName, nil)
if configEntry != nil {
partitions = max(configEntry.ReadQueueNums, 1)
perm = configEntry.Perm
messageType = classifyTopicMessageType(name, clusterName, configEntry.Attributes)
}
if partitions <= 0 {
if route, routeErr := client.ExamineTopicRouteInfo(ctx, name); routeErr == nil {
if len(route.QueueDatas) > 0 {
partitions = max(route.QueueDatas[0].ReadQueueNums, 1)
}
}
}
if partitions <= 0 {
partitions = 1
}
rows = append(rows, map[string]any{
"name": name, "partitions": partitions, "replicationFactor": 1,
"internal": messageType == "SYSTEM" || messageType == "RETRY" || messageType == "DLQ",
"messageType": messageType, "readQueueNums": partitions,
"writeQueueNums": writeQueueNums(configEntry, partitions), "perm": perm,
})
}
sort.Slice(rows, func(i, j int) bool { return rows[i]["name"].(string) < rows[j]["name"].(string) })
offset := max(0, intValue(params, 0, "offset"))
limit := intValue(params, 200, "limit")
return map[string]any{
"topics": paginate(rows, offset, limit), "total": len(rows), "offset": offset, "limit": limit,
}, nil
}
func (a *rocketMQAgent) createTopic(params map[string]any) (any, error) {
name, err := requireString(params, "name")
if err != nil {
return nil, err
}
readQueues := max(1, intValue(params, 8, "readQueueNums", "partitions"))
writeQueues := max(1, intValue(params, readQueues, "writeQueueNums"))
perm := normalizeTopicPerm(intValue(params, 6, "perm"))
messageType := normalizeMessageType(stringValue(params, "messageType"))
addresses, err := a.masterBrokerAddresses(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
_, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
for _, address := range addresses {
if err := writeTopicConfig(ctx, address, &topicConfigWire{
TopicName: name, ReadQueueNums: readQueues, WriteQueueNums: writeQueues,
Perm: perm, TopicFilterType: "SINGLE_TAG", Attributes: map[string]string{"+message.type": messageType},
}); err != nil {
return nil, err
}
}
return okResult(), nil
}
func writeTopicConfig(ctx context.Context, address string, config *topicConfigWire) error {
fields := map[string]string{
"topic": config.TopicName, "defaultTopic": "TBW102", "readQueueNums": strconv.Itoa(config.ReadQueueNums),
"writeQueueNums": strconv.Itoa(config.WriteQueueNums), "perm": strconv.Itoa(config.Perm),
"topicFilterType": valueOrDefault(config.TopicFilterType, "SINGLE_TAG"),
"topicSysFlag": strconv.Itoa(config.TopicSysFlag), "order": strconv.FormatBool(config.Order),
"attributes": topicAttributesString(config.Attributes),
}
command := remoting.NewRequest(remoting.UpdateAndCreateTopic, fields)
_, err := invokeRemotingWithClient(ctx, address, command)
return err
}
func (a *rocketMQAgent) deleteTopic(params map[string]any) (any, error) {
name, err := requireString(params, "name")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
addresses, err := a.masterBrokerAddresses(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
for _, address := range addresses {
if err := client.DeleteTopicInBroker(ctx, address, name); err != nil {
return nil, err
}
}
if err := client.DeleteTopicInNameServer(ctx, name); err != nil {
return nil, err
}
return okResult(), nil
}
func (a *rocketMQAgent) updatePartitions(params map[string]any) (any, error) {
name, err := requireString(params, "name")
if err != nil {
return nil, err
}
_, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
addresses, err := a.masterBrokerAddresses(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
readQueues := max(1, intValue(params, intValue(params, 1, "totalPartitions"), "readQueueNums"))
writeQueues := max(1, intValue(params, readQueues, "writeQueueNums"))
for _, address := range addresses {
current, configErr := readTopicConfig(ctx, address, name)
if configErr != nil {
current = &topicConfigWire{TopicName: name, Perm: 6, TopicFilterType: "SINGLE_TAG"}
}
current.ReadQueueNums = readQueues
current.WriteQueueNums = writeQueues
if err := writeTopicConfig(ctx, address, current); err != nil {
return nil, err
}
}
return okResult(), nil
}
func (a *rocketMQAgent) getTopicRoute(params map[string]any) (any, error) {
name, err := requireString(params, "name", "topic")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
route, err := client.ExamineTopicRouteInfo(ctx, name)
if err != nil {
return nil, err
}
a.mu.RLock()
proxies := a.proxies
a.mu.RUnlock()
brokerDatas := make([]map[string]any, 0, len(route.BrokerDatas))
for _, broker := range route.BrokerDatas {
addresses := make(map[string]string, len(broker.BrokerAddrs))
for brokerID, address := range broker.BrokerAddrs {
if original := proxies.OriginalForLocal(address); original != "" {
address = original
}
addresses[brokerID] = address
}
brokerDatas = append(brokerDatas, map[string]any{
"brokerName": broker.BrokerName, "cluster": broker.Cluster, "brokerAddrs": addresses,
})
}
queueDatas := make([]map[string]any, 0, len(route.QueueDatas))
for _, queue := range route.QueueDatas {
queueDatas = append(queueDatas, map[string]any{
"brokerName": queue.BrokerName, "readQueueNums": queue.ReadQueueNums,
"writeQueueNums": queue.WriteQueueNums, "perm": queue.Perm,
})
}
return map[string]any{"topic": name, "queueDatas": queueDatas, "brokerDatas": brokerDatas}, nil
}
func (a *rocketMQAgent) getTopicStats(params map[string]any) (any, error) {
name, err := requireString(params, "name", "topic")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
stats, err := a.examineTopicStats(ctx, client, name)
if err != nil {
return nil, err
}
partitionStats := make([]map[string]any, 0, len(stats))
var total int64
for key, offset := range stats {
queue := parseMessageQueueKey(key)
count := max(int64(0), offset.MaxOffset-offset.MinOffset)
total += count
partitionStats = append(partitionStats, map[string]any{
"partition": queue.QueueID, "brokerName": queue.BrokerName,
"beginOffset": offset.MinOffset, "endOffset": offset.MaxOffset,
"messageCount": count, "lastTimestamp": offset.LastUpdateTimestamp,
})
}
sort.Slice(partitionStats, func(i, j int) bool {
return partitionStats[i]["partition"].(int) < partitionStats[j]["partition"].(int)
})
return map[string]any{
"name": name, "partitions": len(partitionStats), "replicationFactor": 1,
"totalMessages": total, "partitionStats": partitionStats,
}, nil
}
func (a *rocketMQAgent) examineTopicStats(
ctx context.Context,
client *admin.Client,
topic string,
) (map[string]*admin.TopicOffset, error) {
route, err := client.ExamineTopicRouteInfo(ctx, topic)
if err != nil {
return nil, err
}
addresses := masterAddressesFromRoute(route)
if len(addresses) == 0 {
return nil, fmt.Errorf("no RocketMQ master broker found for topic %s", topic)
}
merged := make(map[string]*admin.TopicOffset)
var lastErr error
successCount := 0
for _, address := range addresses {
response, requestErr := invokeRemotingWithClient(ctx, address,
remoting.NewRequest(remoting.GetTopicStatsInfo, map[string]string{"topic": topic}))
if requestErr != nil {
lastErr = requestErr
continue
}
partial, decodeErr := decodeTopicStats(response.Body)
if decodeErr != nil {
lastErr = decodeErr
continue
}
successCount++
for key, offset := range partial {
merged[key] = offset
}
}
if successCount != len(addresses) {
return nil, fmt.Errorf("query topic stats for %s on all masters: %w", topic, lastErr)
}
if len(merged) == 0 {
return nil, fmt.Errorf("topic stats not found: %s", topic)
}
return merged, nil
}
func decodeTopicStats(body []byte) (map[string]*admin.TopicOffset, error) {
var stats admin.TopicStatsTable
if err := json.Unmarshal(repairRocketMQJSON(body), &stats); err != nil {
return nil, fmt.Errorf("decode topic stats: %w", err)
}
if stats.OffsetTable == nil {
stats.OffsetTable = make(map[string]*admin.TopicOffset)
}
return stats.OffsetTable, nil
}
func masterAddressesFromRoute(route *admin.TopicRouteData) []string {
addresses := make(map[string]struct{})
for _, broker := range route.BrokerDatas {
if address := broker.BrokerAddrs["0"]; address != "" {
addresses[address] = struct{}{}
}
}
return sortedKeys(addresses)
}
func (a *rocketMQAgent) getTopicConfig(params map[string]any) (any, error) {
name, err := requireString(params, "name", "topic")
if err != nil {
return nil, err
}
_, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
address, err := a.brokerAddressForName(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
topicConfig, err := readTopicConfig(ctx, address, name)
if err != nil {
return nil, err
}
configs := map[string]any{
"readQueueNums": configEntry(strconv.Itoa(topicConfig.ReadQueueNums)),
"writeQueueNums": configEntry(strconv.Itoa(topicConfig.WriteQueueNums)),
"perm": configEntry(strconv.Itoa(topicConfig.Perm)),
}
return map[string]any{"configs": configs}, nil
}
func (a *rocketMQAgent) alterTopicConfig(params map[string]any) (any, error) {
name, err := requireString(params, "name", "topic")
if err != nil {
return nil, err
}
_, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
address, err := a.brokerAddressForName(stringValue(params, "brokerName"))
if err != nil {
return nil, err
}
topicConfig, err := readTopicConfig(ctx, address, name)
if err != nil {
return nil, err
}
if entries, ok := params["configs"].([]any); ok {
for _, raw := range entries {
entry, _ := raw.(map[string]any)
value := stringValue(entry, "value")
switch stringValue(entry, "key") {
case "readQueueNums":
topicConfig.ReadQueueNums = intValue(entry, topicConfig.ReadQueueNums, "value")
case "writeQueueNums":
topicConfig.WriteQueueNums = intValue(entry, topicConfig.WriteQueueNums, "value")
case "perm":
topicConfig.Perm = normalizeTopicPerm(intValue(entry, topicConfig.Perm, "value"))
case "retention.ms", "retention.bytes":
if value != "" {
return nil, fmt.Errorf("RocketMQ topic retention is broker-level and cannot be changed per topic")
}
}
}
}
if err := writeTopicConfig(ctx, address, topicConfig); err != nil {
return nil, err
}
return okResult(), nil
}
func (a *rocketMQAgent) skipTopicAccumulation(params map[string]any) (any, error) {
topic, err := requireString(params, "topic", "name")
if err != nil {
return nil, err
}
client, config, _ := a.requireClient()
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
groups, err := client.QueryTopicConsumeByWho(ctx, topic)
if err != nil {
return nil, err
}
reset := make([]string, 0, len(groups))
for _, group := range groups {
if _, resetErr := client.ResetOffsetByTimestamp(ctx, topic, group, time.Now().UnixMilli(), true); resetErr == nil {
reset = append(reset, group)
}
}
return map[string]any{"ok": true, "resetGroups": len(reset)}, nil
}
func (a *rocketMQAgent) masterBrokerAddresses(brokerName string) ([]string, error) {
client, config, err := a.requireClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
defer cancel()
info, err := client.ExamineBrokerClusterInfo(ctx)
if err != nil {
return nil, err
}
addresses := make([]string, 0)
for _, name := range sortedKeys(info.BrokerAddrTable) {
if brokerName != "" && brokerName != name {
continue
}
if address := info.BrokerAddrTable[name].BrokerAddrs["0"]; address != "" {
addresses = append(addresses, address)
}
}
if len(addresses) == 0 {
a.mu.RLock()
fallback := a.brokerAddr
a.mu.RUnlock()
if fallback != "" {
addresses = append(addresses, fallback)
}
}
if len(addresses) == 0 {
return nil, fmt.Errorf("no RocketMQ master broker found")
}
return addresses, nil
}
func (a *rocketMQAgent) brokerAddressForName(brokerName string) (string, error) {
addresses, err := a.masterBrokerAddresses(brokerName)
if err != nil {
return "", err
}
return addresses[0], nil
}
type parsedMessageQueue struct {
Topic string
BrokerName string
QueueID int
}
func parseMessageQueueKey(value string) parsedMessageQueue {
value = strings.TrimSpace(value)
var queue struct {
Topic string `json:"topic"`
BrokerName string `json:"brokerName"`
QueueID int `json:"queueId"`
}
if strings.HasPrefix(value, "{") && json.Unmarshal([]byte(value), &queue) == nil {
return parsedMessageQueue(queue)
}
if strings.HasPrefix(value, "MessageQueue [") && strings.HasSuffix(value, "]") {
fields := strings.Split(strings.TrimSuffix(strings.TrimPrefix(value, "MessageQueue ["), "]"), ",")
for _, field := range fields {
keyValue := strings.SplitN(strings.TrimSpace(field), "=", 2)
if len(keyValue) != 2 {
continue
}
switch keyValue[0] {
case "topic":
queue.Topic = keyValue[1]
case "brokerName":
queue.BrokerName = keyValue[1]
case "queueId":
queue.QueueID, _ = strconv.Atoi(keyValue[1])
}
}
return parsedMessageQueue(queue)
}
parts := strings.Split(value, "-")
if len(parts) >= 3 {
queueID, _ := strconv.Atoi(parts[len(parts)-1])
return parsedMessageQueue{Topic: strings.Join(parts[:len(parts)-2], "-"), BrokerName: parts[len(parts)-2], QueueID: queueID}
}
return parsedMessageQueue{}
}
func configEntry(value string) map[string]any {
return map[string]any{
"value": value, "isDefault": false,
"isReadOnly": false, "isSensitive": false, "source": "USER",
}
}
func isSystemTopic(topic, cluster string) bool {
if topic == "" {
return true
}
if _, ok := reservedTopicNames[topic]; ok {
return true
}
return strings.HasPrefix(topic, "%") || strings.HasPrefix(topic, "RMQ_SYS") ||
strings.HasPrefix(topic, "rmq_sys") || strings.HasPrefix(topic, "SCHEDULE_TOPIC_") ||
strings.HasPrefix(topic, "rocketmq-broker-") || strings.HasSuffix(topic, "_REPLY_TOPIC") || topic == cluster
}
func classifyTopicMessageType(topic, cluster string, attributes map[string]string) string {
if strings.HasPrefix(topic, "%RETRY%") || strings.HasPrefix(topic, "%R") {
return "RETRY"
}
if strings.HasPrefix(topic, "%DLQ%") || strings.HasPrefix(topic, "%D") {
return "DLQ"
}
if isSystemTopic(topic, cluster) {
return "SYSTEM"
}
for _, key := range []string{"message.type", "+message.type"} {
if value := normalizeMessageType(attributes[key]); value != "NORMAL" || attributes[key] != "" {
return value
}
}
return "UNSPECIFIED"
}
func (a *rocketMQAgent) collectTopicConfigs(ctx context.Context) (map[string]*topicConfigWire, bool) {
addresses, err := a.masterBrokerAddresses("")
if err != nil {
return map[string]*topicConfigWire{}, false
}
merged := make(map[string]*topicConfigWire)
complete := true
for _, address := range addresses {
if a.proxies.IsCollisionFallback(address) && len(merged) > 0 {
complete = false
continue
}
configs, fetchErr := fetchAllTopicConfigs(ctx, address)
if fetchErr != nil {
complete = false
continue
}
for name, config := range configs {
if merged[name] == nil {
merged[name] = config
}
}
}
return merged, complete
}
func fetchAllTopicConfigs(ctx context.Context, address string) (map[string]*topicConfigWire, error) {
response, err := invokeRemotingWithClient(ctx, address, remoting.NewRequest(remoting.GetAllTopicConfig, nil))
if err != nil {
return nil, err
}
var wrapper struct {
TopicConfigTable map[string]*topicConfigWire `json:"topicConfigTable"`
}
if err := json.Unmarshal(repairRocketMQJSON(response.Body), &wrapper); err != nil {
return nil, err
}
return wrapper.TopicConfigTable, nil
}
func readTopicConfig(ctx context.Context, address, topic string) (*topicConfigWire, error) {
response, err := invokeRemotingWithClient(ctx, address, remoting.NewRequest(remoting.GetTopicConfig, map[string]string{"topic": topic}))
if err == nil {
var config topicConfigWire
if decodeErr := json.Unmarshal(repairRocketMQJSON(response.Body), &config); decodeErr != nil {
return nil, decodeErr
}
return &config, nil
}
configs, fallbackErr := fetchAllTopicConfigs(ctx, address)
if fallbackErr != nil {
return nil, fmt.Errorf("read topic config %s: %v; fallback snapshot: %w", topic, err, fallbackErr)
}
config := configs[topic]
if config == nil {
return nil, fmt.Errorf("topic config not found: %s", topic)
}
if config.TopicName == "" {
config.TopicName = topic
}
return config, nil
}
func topicAttributesString(attributes map[string]string) string {
keys := sortedKeys(attributes)
parts := make([]string, 0, len(keys))
for _, key := range keys {
if !strings.HasPrefix(key, "+") && !strings.HasPrefix(key, "-") {
continue
}
if attributes[key] == "" {
parts = append(parts, key)
} else {
parts = append(parts, key+"="+attributes[key])
}
}
return strings.Join(parts, ",")
}
func writeQueueNums(config *topicConfigWire, fallback int) int {
if config == nil || config.WriteQueueNums <= 0 {
return fallback
}
return config.WriteQueueNums
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"reflect"
"testing"
)
func TestTopicAttributesStringEmitsOnlyRocketMQ5Modifications(t *testing.T) {
attributes := map[string]string{
"+message.type": "FIFO",
"cleanup.policy": "DELETE",
"-reserve.time": "",
}
if got := topicAttributesString(attributes); got != "+message.type=FIFO,-reserve.time" {
t.Fatalf("topicAttributesString() = %q", got)
}
}
func TestClassifyTopicMessageType(t *testing.T) {
tests := map[string]string{
"%RETRY%GID_Orders": "RETRY",
"%DLQ%GID_Orders": "DLQ",
"RMQ_SYS_TRANS_HALF_TOPIC": "SYSTEM",
"Orders": "UNSPECIFIED",
}
for topic, want := range tests {
if got := classifyTopicMessageType(topic, "DefaultCluster", nil); got != want {
t.Fatalf("classifyTopicMessageType(%q) = %q, want %q", topic, got, want)
}
}
attributes := map[string]string{"message.type": "ORDER"}
if got := classifyTopicMessageType("Ordered", "DefaultCluster", attributes); got != "FIFO" {
t.Fatalf("ORDER attribute classified as %q", got)
}
}
func TestPaginationAndMessageQueueParsing(t *testing.T) {
items := []int{0, 1, 2, 3}
if got := paginate(items, 2, 0); !reflect.DeepEqual(got, []int{2, 3}) {
t.Fatalf("paginate all from offset = %#v", got)
}
queue := parseMessageQueueKey(`{"topic":"Orders","brokerName":"broker-a","queueId":3}`)
if queue.Topic != "Orders" || queue.BrokerName != "broker-a" || queue.QueueID != 3 {
t.Fatalf("unexpected JSON queue: %#v", queue)
}
queue = parseMessageQueueKey("MessageQueue [topic=Order-Events, brokerName=broker-a, queueId=7]")
if queue.Topic != "Order-Events" || queue.BrokerName != "broker-a" || queue.QueueID != 7 {
t.Fatalf("unexpected text queue: %#v", queue)
}
}
func TestDecodeTopicStatsRepairsObjectKeys(t *testing.T) {
body := []byte(`{"offsetTable":{{"brokerName":"broker-a","queueId":1,"topic":"Orders"}:{"minOffset":2,"maxOffset":9,"lastUpdateTimestamp":10}}}`)
stats, err := decodeTopicStats(body)
if err != nil {
t.Fatal(err)
}
if len(stats) != 1 {
t.Fatalf("unexpected stats: %#v", stats)
}
for key, offset := range stats {
queue := parseMessageQueueKey(key)
if queue.Topic != "Orders" || queue.BrokerName != "broker-a" || queue.QueueID != 1 {
t.Fatalf("unexpected queue key %q: %#v", key, queue)
}
if offset.MinOffset != 2 || offset.MaxOffset != 9 {
t.Fatalf("unexpected offset: %#v", offset)
}
}
}
func TestConfigEntryMatchesAgentContract(t *testing.T) {
entry := configEntry("8")
if entry["value"] != "8" || entry["source"] != "USER" {
t.Fatalf("unexpected config entry: %#v", entry)
}
if _, exists := entry["key"]; exists {
t.Fatalf("config entry must be keyed by its parent object: %#v", entry)
}
}
+24 -2
View File
@@ -24,6 +24,8 @@ class DriverReleasePackagesTest(unittest.TestCase):
duckdb_source.write_bytes(b"\xcf\xfa\xed\xfetest-duckdb-agent")
rabbitmq_source = release_dir / "dbx-agent-rabbitmq-linux-x64"
rabbitmq_source.write_bytes(b"\x7fELFtest-rabbitmq-agent")
rocketmq_source = release_dir / "dbx-agent-rocketmq-windows-x64.exe"
rocketmq_source.write_bytes(b"MZtest-rocketmq-agent")
cassandra_source = release_dir / "dbx-agent-cassandra-linux-x64"
cassandra_source.write_bytes(b"\x7fELFtest-cassandra-agent")
tdengine_source = release_dir / "dbx-agent-tdengine-windows-aarch64.exe"
@@ -40,6 +42,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
"vastbase": "0.1.37",
"duckdb": "0.1.0",
"rabbitmq": "0.1.0",
"rocketmq": "0.1.0",
"cassandra": "0.1.37",
"hive": "0.1.43",
"tdengine": "0.1.0",
@@ -51,6 +54,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
versioned_vastbase = release_dir / "dbx-agent-vastbase-0.1.37-linux-x64"
versioned_duckdb = release_dir / "dbx-agent-duckdb-0.1.0-macos-aarch64"
versioned_rabbitmq = release_dir / "dbx-agent-rabbitmq-0.1.0-linux-x64"
versioned_rocketmq = release_dir / "dbx-agent-rocketmq-0.1.0-windows-x64.exe"
versioned_cassandra = release_dir / "dbx-agent-cassandra-0.1.37-linux-x64"
versioned_tdengine = release_dir / "dbx-agent-tdengine-0.1.0-windows-aarch64.exe"
self.assertEqual(
@@ -62,6 +66,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
versioned_vastbase,
versioned_duckdb,
versioned_rabbitmq,
versioned_rocketmq,
versioned_tdengine,
],
)
@@ -141,6 +146,19 @@ class DriverReleasePackagesTest(unittest.TestCase):
}
},
},
"rocketmq": {
"version": "0.1.0",
"label": "Apache RocketMQ",
"min_app_version": "0.6.0",
"jre": "21",
"jar": {"url": "https://example.com/legacy-placeholder.jar", "size": 0},
"native": {
"windows-x64": {
"url": f"https://example.com/{versioned_rocketmq.name}",
"size": versioned_rocketmq.stat().st_size,
}
},
},
"tdengine": {
"version": "0.1.0",
"label": "TDengine",
@@ -169,6 +187,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
release_dir / "dbx-agent-vastbase-0.1.37-linux-x64.tar.zst",
release_dir / "dbx-agent-duckdb-0.1.0-macos-aarch64.tar.zst",
release_dir / "dbx-agent-rabbitmq-0.1.0-linux-x64.tar.zst",
release_dir / "dbx-agent-rocketmq-0.1.0-windows-x64.tar.zst",
release_dir / "dbx-agent-tdengine-0.1.0-windows-aarch64.tar.zst",
],
)
@@ -179,7 +198,8 @@ class DriverReleasePackagesTest(unittest.TestCase):
(outputs[3], "vastbase", versioned_vastbase, "native", "linux-x64"),
(outputs[4], "duckdb", versioned_duckdb, "native", "macos-aarch64"),
(outputs[5], "rabbitmq", versioned_rabbitmq, "native", "linux-x64"),
(outputs[6], "tdengine", versioned_tdengine, "native", "windows-aarch64"),
(outputs[6], "rocketmq", versioned_rocketmq, "native", "windows-x64"),
(outputs[7], "tdengine", versioned_tdengine, "native", "windows-aarch64"),
]
for output, driver_name, source, artifact_type, platform in package_cases:
tar_bytes = subprocess.run(
@@ -209,7 +229,8 @@ class DriverReleasePackagesTest(unittest.TestCase):
(final_registry["drivers"]["vastbase"]["native"]["linux-x64"], outputs[3]),
(final_registry["drivers"]["duckdb"]["native"]["macos-aarch64"], outputs[4]),
(final_registry["drivers"]["rabbitmq"]["native"]["linux-x64"], outputs[5]),
(final_registry["drivers"]["tdengine"]["native"]["windows-aarch64"], outputs[6]),
(final_registry["drivers"]["rocketmq"]["native"]["windows-x64"], outputs[6]),
(final_registry["drivers"]["tdengine"]["native"]["windows-aarch64"], outputs[7]),
]
for artifact, output in release_artifacts:
self.assertEqual(artifact["url"], f"https://example.com/{output.name}")
@@ -226,6 +247,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
versioned_java,
versioned_native,
versioned_rabbitmq,
versioned_rocketmq,
versioned_tdengine,
versioned_vastbase,
],
+1
View File
@@ -24,6 +24,7 @@ NATIVE_ONLY_AGENT_MODULES = {
"tdengine": "drivers/tdengine",
"xugu": "drivers/xugu",
"rabbitmq": "drivers/rabbitmq",
"rocketmq": "drivers/rocketmq",
}
AUTO_VERSIONED_NATIVE_MODULES = {"duckdb"}
JDBC_ARCHITECTURE_ALLOWLIST = {
+2 -1
View File
@@ -147,11 +147,12 @@ class ValidateAgentsTest(unittest.TestCase):
"xugu",
"duckdb",
"rabbitmq",
"rocketmq",
"tdengine",
):
(root / "drivers" / driver).mkdir(parents=True)
(root / "versions.json").write_text(
json.dumps({"h2": "0.1.0", "cassandra": "0.1.0", "hive": "0.1.0", "oracle": "0.1.0", "kingbase": "0.1.0", "iotdb": "0.1.0", "neo4j": "0.1.0", "vastbase": "0.1.0", "xugu": "0.1.0", "rabbitmq": "0.1.0", "tdengine": "0.1.0"}),
json.dumps({"h2": "0.1.0", "cassandra": "0.1.0", "hive": "0.1.0", "oracle": "0.1.0", "kingbase": "0.1.0", "iotdb": "0.1.0", "neo4j": "0.1.0", "vastbase": "0.1.0", "xugu": "0.1.0", "rabbitmq": "0.1.0", "rocketmq": "0.1.0", "tdengine": "0.1.0"}),
encoding="utf-8",
)
+1 -1
View File
@@ -4,7 +4,7 @@ import json
from pathlib import Path
NATIVE_DRIVERS = ("cassandra", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "duckdb", "rabbitmq", "tdengine")
NATIVE_DRIVERS = ("cassandra", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "duckdb", "rabbitmq", "rocketmq", "tdengine")
PLATFORMS = (
"macos-aarch64",
"macos-x64",
+1 -1
View File
@@ -6,7 +6,7 @@ def driverModules = [
'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s',
'bigquery', 'kylin', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'spark',
'db2', 'informix', 'mongodb', 'highgo', 'uxdb', 'yashandb', 'oscar',
'iris', 'etcd', 'zookeeper', 'kafka', 'rocketmq', 'sqlserver-legacy'
'iris', 'etcd', 'zookeeper', 'kafka', 'sqlserver-legacy'
]
include(*(infrastructureModules + driverModules))
+2
View File
@@ -14,7 +14,9 @@ mq/
├── service.rs - 服务层函数
└── adapters/
├── pulsar.rs - Pulsar 实现
├── kafka.rs - Kafka 实现 (Java agent)
├── rabbitmq.rs - RabbitMQ 实现 (Go native agent)
├── rocketmq.rs - RocketMQ 实现 (Go native agent)
└── pulsar_version.rs - 版本探测
```
+8 -9
View File
@@ -1,10 +1,9 @@
//! Apache RocketMQ admin adapter. Communicates with a Java agent process
//! (`RocketMqAgent.java`) via JSON-RPC over stdin/stdout. The Java agent uses
//! `DefaultMQAdminExt` for admin operations and `DefaultMQProducer` for
//! message production.
//! Apache RocketMQ admin adapter. Communicates with the Go native agent via
//! JSON-RPC over stdin/stdout. The agent uses `rocketmq-admin-go` plus direct
//! remoting calls for compatibility gaps in the upstream admin API.
//!
//! This adapter follows the same pattern as the ZooKeeper/Etcd agents:
//! 1. Spawn a Java agent process via `AgentDriverClient`
//! This adapter follows the same process-agent pattern as the other drivers:
//! 1. Spawn the native agent process via `AgentDriverClient`
//! 2. Perform JSON-RPC handshake + connect
//! 3. Delegate all `MessageQueueAdmin` trait methods to JSON-RPC calls
@@ -97,10 +96,10 @@ fn cluster_info_from_agent_result(result: &serde_json::Value) -> MqClusterInfo {
}
impl RocketMqAdmin {
/// Spawn the RocketMQ Java agent, perform handshake, and connect.
/// Spawn the RocketMQ native agent, perform handshake, and connect.
///
/// Callers must probe NameServer reachability before invoking this so the
/// connect-timeout wall covers only JVM spawn + handshake + connect.
/// connect-timeout wall covers only process spawn + handshake + connect.
pub async fn new(cfg: MqAdminConfig, launch: AgentLaunchSpec) -> Result<Self, String> {
let mut client = AgentDriverClient::spawn(launch).await?;
@@ -814,7 +813,7 @@ fn rocketmq_topic_admin_params(topic: &TopicRef, partitions: Option<u32>) -> ser
params
}
/// Build the connection params JSON from MqAdminConfig for the Java agent.
/// Build the connection params JSON from MqAdminConfig for the native agent.
fn build_connection_params(cfg: &MqAdminConfig) -> serde_json::Value {
let extra = &cfg.extra;
let access_key = extra_str(extra, "accessKey")
+4 -4
View File
@@ -378,7 +378,7 @@ cargo check --package dbx-web --features mq-admin
### 添加新的 MQ 系统
Kafka 与 RocketMQ 已实现 Java Agent + Rust 适配器模式。新增 MQ 系统时:
Kafka 使用 Java Agent,RocketMQ 使用 Go 原生 Agent;两者均通过 Rust 适配器接入。新增 MQ 系统时:
1. 在 `agents/drivers/<name>/` 实现 JSON-RPC Agent(参考 `kafka` / `rocketmq`)
2. 在 `crates/dbx-core/src/mq/adapters/` 实现 `MessageQueueAdmin`
@@ -407,9 +407,9 @@ Kafka 与 RocketMQ 已实现 Java Agent + Rust 适配器模式。新增 MQ 系
Agent 构建与安装:
```bash
cd agents
./gradlew :rocketmq:shadowJar
# 将 shadow JAR 安装到 DBX 数据目录 agents/drivers/rocketmq/agent.jar
cd agents/drivers/rocketmq
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o agent .
# 将可执行文件安装到 DBX 数据目录 agents/drivers/rocketmq/agent
```
Docker 快速启动(NameServer 9876 + Broker 10911,仅用于本地验证):