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

This commit is contained in:
t8y2
2026-08-14 13:34:20 +08:00
parent 2d47c94992
commit 56fde5b20b
26 changed files with 2306 additions and 1928 deletions
+6 -1
View File
@@ -59,15 +59,20 @@ const nativeDriverDirectories = {
vastbase: "vastbase-go",
rabbitmq: "rabbitmq",
rocketmq: "rocketmq",
zookeeper: "zookeeper",
tdengine: "tdengine",
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "tdengine"]);
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine"]);
const nativeDriverSharedPaths = {
hive: [
"agents/go-common/go-gssapi",
"agents/go-common/gohive",
"agents/go-common/gosasl",
],
zookeeper: [
"agents/go-common/go-gssapi",
"agents/go-common/gosasl",
],
};
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
@@ -71,6 +71,25 @@ test("bumps the native RocketMQ agent from its Go directory", () => {
assert.deepEqual(result.nativeModules, ["rocketmq"]);
});
test("bumps ZooKeeper from native and shared SASL source directories", () => {
for (const changedFile of [
"agents/drivers/zookeeper/main.go",
"agents/go-common/gosasl/sasl.go",
"agents/go-common/go-gssapi/krb5/krb5.go",
]) {
const result = evaluateAgentVersionBump({
versions: { zookeeper: "0.1.0" },
changedFiles: [changedFile],
moduleExists: (path) => path === "agents/drivers/zookeeper",
readModuleFile: () => "",
});
assert.equal(result.versions.zookeeper, "0.1.1");
assert.deepEqual(result.javaModules, []);
assert.deepEqual(result.nativeModules, ["zookeeper"]);
}
});
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", "rocketmq", "tdengine"]);
const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine"]);
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
+49 -2
View File
@@ -329,6 +329,46 @@ jobs:
name: rocketmq-native
path: "release-native/dbx-agent-rocketmq-*"
build-zookeeper-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'zookeeper') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Test ZooKeeper native agent
working-directory: agents/drivers/zookeeper
run: go test -race ./...
- name: Cross-compile ZooKeeper native agent
shell: bash
run: |
mkdir -p release-native
cd agents/drivers/zookeeper
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-zookeeper-${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: zookeeper-native
path: "release-native/dbx-agent-zookeeper-*"
build-cassandra-native:
needs: [bump-versions]
if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'cassandra') }}
@@ -908,7 +948,7 @@ jobs:
retention-days: 1
release:
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]
needs: [bump-versions, commit-versions, build-agents, build-oracle-native, build-xugu-native, build-rabbitmq-native, build-rocketmq-native, build-zookeeper-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:
@@ -1031,6 +1071,7 @@ jobs:
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
rocketmq) echo "Apache RocketMQ" ;;
zookeeper) echo "Apache ZooKeeper" ;;
cassandra) echo "Apache Cassandra" ;;
hive) echo "Apache Hive" ;;
neo4j) echo "Neo4j" ;;
@@ -1099,7 +1140,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 rocketmq cassandra hive tdengine; do
for name in oracle xugu kingbase vastbase neo4j iotdb duckdb rabbitmq rocketmq zookeeper cassandra hive tdengine; do
version=$(get_module_version "$name")
[ -f "release/dbx-agent-${name}-${version}.jar" ] && continue
native_json=$(generate_native_platforms "$name" "$version")
@@ -1170,6 +1211,7 @@ jobs:
xugu) echo "虚谷 XuguDB" ;;
rabbitmq) echo "RabbitMQ" ;;
rocketmq) echo "Apache RocketMQ" ;;
zookeeper) echo "Apache ZooKeeper" ;;
cassandra) echo "Apache Cassandra" ;;
hive) echo "Apache Hive" ;;
neo4j) echo "Neo4j" ;;
@@ -1219,6 +1261,11 @@ jobs:
"agents/go-common/gohive/"
"agents/go-common/gosasl/"
)
elif [ "$name" = "zookeeper" ]; then
LOG_PATHS+=(
"agents/go-common/go-gssapi/"
"agents/go-common/gosasl/"
)
fi
while IFS= read -r line; do
[ -n "$line" ] && NOTES="${NOTES}- ${line}"$'\n'
+88
View File
@@ -744,6 +744,10 @@ jobs:
run: go test -race ./...
working-directory: agents/drivers/rocketmq
- name: ZooKeeper native agent tests
run: go test -race ./...
working-directory: agents/drivers/zookeeper
- name: Cassandra native agent tests
run: go test ./...
working-directory: agents/drivers/cassandra-go
@@ -780,6 +784,90 @@ jobs:
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: ZooKeeper native agent cross-builds
shell: bash
working-directory: agents/drivers/zookeeper
run: |
set -euo pipefail
for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64 windows/arm64 windows/amd64; do
IFS=/ read -r goos goarch <<< "$target"
output="/tmp/dbx-agent-zookeeper-${goos}-${goarch}"
[ "$goos" = windows ] && output="${output}.exe"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags="-s -w" -o "$output" .
done
- name: ZooKeeper native agent integration tests
shell: bash
working-directory: agents/drivers/zookeeper
run: |
set -euo pipefail
for version in 3.4.14 3.5.5 3.7.0 3.9.5; do
name="dbx-zookeeper-${version//./-}"
docker rm -fv "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" -p 2181:2181 "zookeeper:$version"
cleanup() {
docker rm -fv "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
ready=false
for _ in $(seq 1 90); do
if timeout 1 bash -c '</dev/tcp/127.0.0.1/2181' >/dev/null 2>&1; then
ready=true
break
fi
sleep 2
done
if [ "$ready" != true ]; then
docker logs "$name"
exit 1
fi
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
go test -run '^TestZooKeeperIntegration$' -count=1 ./...
cleanup
trap - EXIT
done
- name: ZooKeeper SASL DIGEST-MD5 integration test
shell: bash
working-directory: agents/drivers/zookeeper
run: |
set -euo pipefail
name="dbx-zookeeper-sasl-3-7-0"
jaas_file=$(mktemp)
cat > "$jaas_file" <<'EOF'
Server {
org.apache.zookeeper.server.auth.DigestLoginModule required
user_dbx="dbx-secret";
};
EOF
docker rm -fv "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" -p 2181:2181 \
-v "$jaas_file:/conf/server-jaas.conf:ro" \
-e ZOO_CFG_EXTRA="authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider" \
-e JVMFLAGS="-Djava.security.auth.login.config=/conf/server-jaas.conf -Dzookeeper.sasl.serverconfig=Server -Dzookeeper.sessionRequireClientSASLAuth=true" \
zookeeper:3.7.0
cleanup() {
docker rm -fv "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
ready=false
for _ in $(seq 1 90); do
if timeout 1 bash -c '</dev/tcp/127.0.0.1/2181' >/dev/null 2>&1; then
ready=true
break
fi
sleep 2
done
if [ "$ready" != true ]; then
docker logs "$name"
exit 1
fi
DBX_ZOOKEEPER_TEST_CONNECT_STRING=127.0.0.1:2181 \
DBX_ZOOKEEPER_TEST_AUTH_SCHEME=sasl_digest \
DBX_ZOOKEEPER_TEST_USERNAME=dbx \
DBX_ZOOKEEPER_TEST_PASSWORD=dbx-secret \
go test -run '^TestZooKeeperIntegration$' -count=1 ./...
- name: RocketMQ native agent integration tests
shell: bash
working-directory: agents/drivers/rocketmq
+6 -5
View File
@@ -43,14 +43,14 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
| xugu | 虚谷 XuguDB | XuguDB Go native agent |
| iotdb | Apache IoTDB | Apache IoTDB Go Client native agent |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
| 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`, `rabbitmq`, and `rocketmq`, 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`, `rocketmq`, and `zookeeper`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
## JDBC Connection Pooling
@@ -76,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`, `drivers/rabbitmq` (amqp091-go), and `drivers/rocketmq` (rocketmq-admin-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), `drivers/rocketmq` (rocketmq-admin-go), and `drivers/zookeeper` (go-zookeeper) 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`.
@@ -97,9 +97,10 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed).
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
(cd drivers/rocketmq && go build -o agent .)
(cd drivers/zookeeper && 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`, `drivers/rabbitmq`, and `drivers/rocketmq`.
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`, `drivers/rocketmq`, and `drivers/zookeeper`.
### Local DBX Runtime Test
@@ -113,7 +114,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`, `rabbitmq`, and `rocketmq` 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`, `rocketmq`, and `zookeeper` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`.
## Versioning
+6 -5
View File
@@ -43,14 +43,14 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
| xugu | 虚谷 XuguDB | XuguDB Go 原生 agent |
| iotdb | Apache IoTDB | Apache IoTDB Go Client 原生 Agent |
| etcd | etcd | jetcd |
| zookeeper | Apache ZooKeeper | Apache Curator |
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
| 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` 和 `rocketmq`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
## JDBC 连接池
@@ -76,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)和 `drivers/rocketmq`(rocketmq-admin-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)和 `drivers/zookeeper`(go-zookeeper)。无需 JRE 下载与管理。
- **Java/JDBC** —— 当某数据库只有 JDBC 驱动,或原生驱动不成熟、缺乏维护时的默认兜底方案。多数 agent 仍属此类。
原生 agent 实现与 Java agent 相同的 JSON-RPC 契约和 `versions.json` 登记;它发布的是 `agent` 可执行文件而非 `agent.jar`。若同一数据库同时保留原生和 Java 源码实现,默认只发布原生产物;只有 Java 变体以独立兼容配置登记时才同时发布,例如 `oracle-legacy` / `oracle-10g`。
@@ -97,9 +97,10 @@ HikariCP 会直接打进启用连接池的 Agent JAR。已经使用 DBX 托管 J
(cd drivers/xugu && go build -o agent .)
(cd drivers/rabbitmq && go build -o agent .)
(cd drivers/rocketmq && go build -o agent .)
(cd drivers/zookeeper && 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` 和 `drivers/rocketmq` 构建。
产物 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` 和 `drivers/zookeeper` 构建。
### 本地 DBX 运行时测试
@@ -113,7 +114,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` 和 `rocketmq` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
`cassandra`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper` 等原生 agent 使用可执行文件而非 `agent.jar`。TDengine 从 `drivers/tdengine/Cargo.toml` 构建 `target/release/dbx-tdengine-driver`。
## 版本管理
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"testing"
"github.com/go-zookeeper/zk"
)
func TestHandshakeAndRPCFailures(t *testing.T) {
service := newServer()
response, shutdown := service.handleRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"handshake","params":{}}`))
if shutdown || response.Error != nil {
t.Fatalf("handshake response = %#v, shutdown=%v", response, shutdown)
}
handshake, ok := response.Result.(handshakeResult)
if !ok || handshake.ProtocolVersion != 1 || handshake.AgentProtocolVersion != 1 || strings.Join(handshake.Capabilities, ",") != "connect,test_connection,kv" {
t.Fatalf("unexpected handshake: %#v", response.Result)
}
response, _ = service.handleRequest([]byte(`{"jsonrpc":"2.0","id":"get","method":"kv_get","params":{"key":"/a"}}`))
if response.Error == nil || response.Error.Message != "Not connected" {
t.Fatalf("unexpected disconnected response: %#v", response)
}
response, _ = service.handleRequest([]byte(`{"jsonrpc":"2.0","id":2,"method":"missing","params":{}}`))
if response.Error == nil || response.Error.Message != "Unknown method: missing" {
t.Fatalf("unexpected unknown-method response: %#v", response)
}
response, _ = service.handleRequest([]byte(`not-json`))
if response.Error == nil || response.Error.Code != -1 || response.ID != nil {
t.Fatalf("unexpected parse response: %#v", response)
}
}
func TestConnectionConfiguration(t *testing.T) {
if got := connectionString(connectionConfig{ZooKeeperConnectString: " zk-main:2181 "}); got != "zk-main:2181" {
t.Fatalf("zookeeper connect string = %q", got)
}
if got := connectionString(connectionConfig{ConnectString: "zk-alt:2181"}); got != "zk-alt:2181" {
t.Fatalf("connect string = %q", got)
}
if got := connectionString(connectionConfig{Host: "::1", Port: 2281}); got != "[::1]:2281" {
t.Fatalf("host fallback = %q", got)
}
target, err := parseConnectTarget("zookeeper://zk-a:2181, zk-b:2182/app/root/")
if err != nil || strings.Join(target.Servers, ",") != "zk-a:2181,zk-b:2182" || target.Chroot != "/app/root" {
t.Fatalf("target=%#v err=%v", target, err)
}
if got := joinPrefix("/app/", "/tenant/"); got != "/app/tenant" {
t.Fatalf("prefix = %q", got)
}
for input, expected := range map[string]string{
"host": "host:2181", "host:2281": "host:2281", "[::1]": "[::1]:2181", "[::1]:2281": "[::1]:2281", "::1": "[::1]:2181",
} {
got, err := endpointAddress(input)
if err != nil || got != expected {
t.Errorf("endpointAddress(%q)=%q,%v want %q", input, got, err, expected)
}
}
if resolveAuthScheme(connectionConfig{URLParams: "?foo=1;auth_scheme=SASL_DIGEST"}) != saslDigestAuthScheme {
t.Fatal("URL auth_scheme was not resolved")
}
if !hasTLSOptions(connectionConfig{CACertPath: "/tmp/ca.pem"}) {
t.Fatal("TLS path was not detected")
}
for input, expected := range map[string]int{"": 16, "bad": 16, "0": 1, "32": 32, "100": 64} {
if got := configuredStatLookupConcurrency(input); got != expected {
t.Errorf("configuredStatLookupConcurrency(%q)=%d want %d", input, got, expected)
}
}
}
func TestConnectionValidationHappensBeforeNetwork(t *testing.T) {
tests := []struct {
name string
config connectionConfig
message string
}{
{"tls", connectionConfig{SSL: true}, "ZooKeeper TLS is not supported"},
{"auth", connectionConfig{AuthScheme: "sasl"}, `Unsupported auth_scheme "sasl"`},
{"sasl username", connectionConfig{AuthScheme: saslDigestAuthScheme, Password: "secret"}, `username is required when auth_scheme = "sasl_digest"`},
{"sasl password", connectionConfig{AuthScheme: saslDigestAuthScheme, Username: "user"}, `password is required when auth_scheme = "sasl_digest"`},
{"negative base sleep", connectionConfig{BaseSleepTimeMS: intPointer(-1)}, "base_sleep_time_ms must be non-negative"},
{"negative retries", connectionConfig{MaxRetries: intPointer(-1)}, "max_retries must be non-negative"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := openClient(test.config)
if err == nil || !strings.Contains(err.Error(), test.message) {
t.Fatalf("error=%v want substring %q", err, test.message)
}
})
}
}
func intPointer(value int) *int { return &value }
func TestPathValueCursorAndCreateModes(t *testing.T) {
for input, expected := range map[string]string{"": "/", "/": "/", "app": "/app", "/app/": "/app", "//app//": "//app"} {
if got := normalizePath(input); got != expected {
t.Errorf("normalizePath(%q)=%q want %q", input, got, expected)
}
}
if got := encodeValue([]byte("hello")); got.Encoding != "utf8" || got.Data != "hello" {
t.Fatalf("utf8 value = %#v", got)
}
binaryValue := []byte{0xff, 0x00, 0x81}
encoded := encodeValue(binaryValue)
if encoded.Encoding != "base64" || encoded.Data != base64.StdEncoding.EncodeToString(binaryValue) {
t.Fatalf("binary value = %#v", encoded)
}
decoded, err := decodeValue(encoded)
if err != nil || !bytes.Equal(decoded, binaryValue) {
t.Fatalf("decoded=%v err=%v", decoded, err)
}
if _, err := decodeValue(valueObject{Encoding: "hex"}); err == nil {
t.Fatal("unsupported encoding was accepted")
}
cursor := listCursor{Root: "/app", Recursive: true, Offset: 12}
continuation, err := encodeCursor(cursor)
if err != nil {
t.Fatal(err)
}
decodedCursor, err := decodeCursor(continuation)
if err != nil || decodedCursor != cursor {
t.Fatalf("cursor=%#v err=%v", decodedCursor, err)
}
for mode, expected := range map[string]int32{"": 0, "persistent": 0, "ephemeral": zk.FlagEphemeral, "persistent_sequential": zk.FlagSequence, "ephemeral_sequential": zk.FlagEphemeral | zk.FlagSequence} {
flags, err := createFlags(mode)
if err != nil || flags != expected {
t.Errorf("createFlags(%q)=%d,%v want %d", mode, flags, err, expected)
}
}
}
func TestZooKeeperRetryClassification(t *testing.T) {
for _, err := range []error{zk.ErrConnectionClosed, zk.ErrClosing, zk.ErrSessionMoved} {
if !isRetryableZooKeeperError(err) {
t.Fatalf("expected retryable error: %v", err)
}
}
for _, err := range []error{zk.ErrNoNode, zk.ErrAuthFailed, zk.ErrSessionExpired} {
if isRetryableZooKeeperError(err) {
t.Fatalf("unexpected retryable error: %v", err)
}
}
}
func TestKVOperationsAndPagination(t *testing.T) {
client := newMemoryClient()
service := &server{activeClient: client, statLookupConcurrency: 4}
put := func(payload string) map[string]any {
result, err := service.put(json.RawMessage(payload))
if err != nil {
t.Fatal(err)
}
return result
}
put(`{"key":"/app/name","value":{"encoding":"utf8","data":"dbx"}}`)
put(`{"key":"/app/a","value":{"encoding":"base64","data":"/wA="},"writeMode":"create"}`)
sequential := put(`{"key":"/app/seq-","value":{"data":"v"},"writeMode":"create","createMode":"persistent_sequential"}`)
if !strings.HasPrefix(sequential["createdKey"].(string), "/app/seq-") {
t.Fatalf("sequential result = %#v", sequential)
}
got, err := service.get(json.RawMessage(`{"key":"app/name/"}`))
if err != nil || got["found"] != true || got["value"].(valueObject).Data != "dbx" {
t.Fatalf("get=%#v err=%v", got, err)
}
put(`{"key":"/app/name","value":{"data":"dbx2"},"writeMode":"update"}`)
recursive := true
first, err := service.listPrefix(mustJSON(listRequest{Prefix: "/", Recursive: &recursive, Limit: 2}))
if err != nil || len(first.Keys) != 2 || first.Continuation == nil {
t.Fatalf("first page=%#v err=%v", first, err)
}
second, err := service.listPrefix(mustJSON(listRequest{Prefix: "/", Recursive: &recursive, Limit: 100, Continuation: *first.Continuation}))
if err != nil || len(second.Keys) < 2 || second.Continuation != nil {
t.Fatalf("second page=%#v err=%v", second, err)
}
wrongRecursive := false
if _, err := service.listPrefix(mustJSON(listRequest{Prefix: "/", Recursive: &wrongRecursive, Continuation: *first.Continuation})); err == nil || err.Error() != "Continuation does not match request" {
t.Fatalf("continuation mismatch error=%v", err)
}
deleted, err := service.delete(json.RawMessage(`{"key":"/app","recursive":true}`))
if err != nil || deleted["deleted"].(int) < 4 {
t.Fatalf("delete=%#v err=%v", deleted, err)
}
missing, err := service.delete(json.RawMessage(`{"key":"/app","recursive":true}`))
if err != nil || missing["deleted"] != 0 {
t.Fatalf("missing delete=%#v err=%v", missing, err)
}
if _, err := service.put(json.RawMessage(`{"key":"/","value":{"data":"x"}}`)); err == nil || err.Error() != "Root znode cannot be modified" {
t.Fatalf("root put error=%v", err)
}
if _, err := service.delete(json.RawMessage(`{"key":"/","recursive":true}`)); err == nil || err.Error() != "Root znode cannot be deleted" {
t.Fatalf("root delete error=%v", err)
}
}
func mustJSON(value any) json.RawMessage {
payload, err := json.Marshal(value)
if err != nil {
panic(err)
}
return payload
}
type memoryNode struct {
data []byte
stat zk.Stat
}
type memoryClient struct {
mutex sync.Mutex
nodes map[string]*memoryNode
sequence int
}
func newMemoryClient() *memoryClient {
return &memoryClient{nodes: map[string]*memoryNode{"/": {stat: zk.Stat{Ctime: 1, Mtime: 1}}}}
}
func (client *memoryClient) Close() {}
func (client *memoryClient) Exists(path string) (bool, *zk.Stat, error) {
client.mutex.Lock()
defer client.mutex.Unlock()
node, ok := client.nodes[path]
if !ok {
return false, nil, nil
}
stat := node.stat
stat.NumChildren = int32(len(client.childrenLocked(path)))
stat.DataLength = int32(len(node.data))
return true, &stat, nil
}
func (client *memoryClient) Get(path string) ([]byte, *zk.Stat, error) {
exists, stat, err := client.Exists(path)
if err != nil || !exists {
return nil, nil, zk.ErrNoNode
}
client.mutex.Lock()
data := append([]byte(nil), client.nodes[path].data...)
client.mutex.Unlock()
return data, stat, nil
}
func (client *memoryClient) Children(path string) ([]string, *zk.Stat, error) {
exists, stat, err := client.Exists(path)
if err != nil || !exists {
return nil, nil, zk.ErrNoNode
}
client.mutex.Lock()
children := client.childrenLocked(path)
client.mutex.Unlock()
return children, stat, nil
}
func (client *memoryClient) Create(path string, data []byte, flags int32) (string, error) {
client.mutex.Lock()
defer client.mutex.Unlock()
created := path
if flags&zk.FlagSequence != 0 {
created = fmt.Sprintf("%s%010d", path, client.sequence)
client.sequence++
}
if _, exists := client.nodes[created]; exists {
return "", zk.ErrNodeExists
}
if _, exists := client.nodes[parentPath(created)]; !exists {
return "", zk.ErrNoNode
}
owner := int64(0)
if flags&zk.FlagEphemeral != 0 {
owner = 1
}
client.nodes[created] = &memoryNode{data: append([]byte(nil), data...), stat: zk.Stat{Ctime: 1, Mtime: 1, EphemeralOwner: owner}}
return created, nil
}
func (client *memoryClient) Set(path string, data []byte) (*zk.Stat, error) {
client.mutex.Lock()
defer client.mutex.Unlock()
node, exists := client.nodes[path]
if !exists {
return nil, zk.ErrNoNode
}
node.data = append([]byte(nil), data...)
node.stat.Version++
node.stat.Mtime++
stat := node.stat
stat.DataLength = int32(len(node.data))
return &stat, nil
}
func (client *memoryClient) Delete(path string) error {
client.mutex.Lock()
defer client.mutex.Unlock()
if _, exists := client.nodes[path]; !exists {
return zk.ErrNoNode
}
if len(client.childrenLocked(path)) != 0 {
return zk.ErrNotEmpty
}
delete(client.nodes, path)
return nil
}
func (client *memoryClient) childrenLocked(path string) []string {
prefix := path
if prefix != "/" {
prefix += "/"
}
children := make([]string, 0)
for candidate := range client.nodes {
if candidate == path || !strings.HasPrefix(candidate, prefix) {
continue
}
remainder := strings.TrimPrefix(candidate, prefix)
if remainder != "" && !strings.Contains(remainder, "/") {
children = append(children, remainder)
}
}
sort.Strings(children)
return children
}
var _ znodeClient = (*memoryClient)(nil)
-13
View File
@@ -1,13 +0,0 @@
dependencies {
implementation 'com.google.code.gson:gson:2.12.1'
implementation 'org.apache.curator:curator-framework:5.9.0'
runtimeOnly 'org.slf4j:slf4j-simple:1.7.36'
testImplementation 'org.apache.curator:curator-test:5.9.0'
}
tasks.named('shadowJar') {
mergeServiceFiles()
manifest {
attributes('Agent-Label': 'Apache ZooKeeper', 'Main-Class': 'com.dbx.agent.zookeeper.ZooKeeperAgent')
}
}
+563
View File
@@ -0,0 +1,563 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/go-zookeeper/zk"
)
const (
defaultSessionTimeout = 30 * time.Second
defaultConnectionTimeout = 15 * time.Second
defaultProbeTimeout = 2 * time.Second
defaultBaseSleepTime = 250 * time.Millisecond
defaultMaxRetries = 2
defaultPort = 2181
defaultAuthScheme = "digest"
saslDigestAuthScheme = "sasl_digest"
statLookupConcurrencyEnv = "DBX_ZOOKEEPER_STAT_LOOKUP_CONCURRENCY"
defaultStatLookupWorkers = 16
minimumStatLookupWorkers = 1
maximumStatLookupWorkers = 64
maximumReachabilityWorkers = 8
)
type connectionConfig struct {
ZooKeeperConnectString string `json:"zookeeper_connect_string"`
ConnectString string `json:"connect_string"`
ConnectionString string `json:"connection_string"`
Host string `json:"host"`
Port int `json:"port"`
Namespace string `json:"namespace"`
Username string `json:"username"`
Password string `json:"password"`
AuthScheme string `json:"auth_scheme"`
URLParams string `json:"url_params"`
SessionTimeoutMS int `json:"session_timeout_ms"`
ConnectionTimeoutMS int `json:"connection_timeout_ms"`
BaseSleepTimeMS *int `json:"base_sleep_time_ms"`
MaxRetries *int `json:"max_retries"`
SSL bool `json:"ssl"`
CACertPath string `json:"ca_cert_path"`
ClientCertPath string `json:"client_cert_path"`
ClientKeyPath string `json:"client_key_path"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
}
type connectionParams struct {
Connection json.RawMessage `json:"connection"`
}
type connectTarget struct {
Servers []string
Chroot string
}
type clientSession struct {
connection *zk.Conn
prefix string
retryBase time.Duration
maxRetries int
}
type znodeClient interface {
Close()
Exists(path string) (bool, *zk.Stat, error)
Get(path string) ([]byte, *zk.Stat, error)
Children(path string) ([]string, *zk.Stat, error)
Create(path string, data []byte, flags int32) (string, error)
Set(path string, data []byte) (*zk.Stat, error)
Delete(path string) error
}
func decodeConnectionConfig(params json.RawMessage) (connectionConfig, error) {
var wrapper connectionParams
if err := json.Unmarshal(params, &wrapper); err != nil {
return connectionConfig{}, err
}
payload := params
if len(wrapper.Connection) > 0 && string(wrapper.Connection) != "null" {
payload = wrapper.Connection
}
var config connectionConfig
if err := json.Unmarshal(payload, &config); err != nil {
return connectionConfig{}, err
}
return config, nil
}
func (service *server) connect(params json.RawMessage) (map[string]bool, error) {
config, err := decodeConnectionConfig(params)
if err != nil {
return nil, err
}
nextClient, err := openClient(config)
if err != nil {
return nil, err
}
previousClient := service.activeClient
service.activeClient = nextClient
if previousClient != nil {
previousClient.Close()
}
return map[string]bool{"ok": true}, nil
}
func (service *server) testConnection(params json.RawMessage) (map[string]bool, error) {
config, err := decodeConnectionConfig(params)
if err != nil {
return nil, err
}
probe, err := openClient(config)
if err != nil {
return nil, err
}
probe.Close()
return map[string]bool{"ok": true}, nil
}
func openClient(config connectionConfig) (*clientSession, error) {
if hasTLSOptions(config) {
return nil, errors.New("ZooKeeper TLS is not supported")
}
authScheme := resolveAuthScheme(config)
if authScheme != defaultAuthScheme && authScheme != saslDigestAuthScheme {
return nil, fmt.Errorf("Unsupported auth_scheme %q; expected %q or %q", authScheme, defaultAuthScheme, saslDigestAuthScheme)
}
if authScheme == saslDigestAuthScheme {
if strings.TrimSpace(config.Username) == "" {
return nil, errors.New(`username is required when auth_scheme = "sasl_digest"`)
}
if config.Password == "" {
return nil, errors.New(`password is required when auth_scheme = "sasl_digest"`)
}
}
if config.BaseSleepTimeMS != nil && *config.BaseSleepTimeMS < 0 {
return nil, errors.New("base_sleep_time_ms must be non-negative")
}
if config.MaxRetries != nil && *config.MaxRetries < 0 {
return nil, errors.New("max_retries must be non-negative")
}
target, err := parseConnectTarget(connectionString(config))
if err != nil {
return nil, err
}
connectionTimeout := millisecondsOrDefault(config.ConnectionTimeoutMS, defaultConnectionTimeout)
probeTimeout := minDuration(defaultProbeTimeout, connectionTimeout)
if err := requireReachableServer(target.Servers, probeTimeout); err != nil {
return nil, err
}
dialer := newZooKeeperDialer(connectionTimeout, nil)
if authScheme == saslDigestAuthScheme {
dialer = newZooKeeperDialer(connectionTimeout, &saslDigestCredentials{
Username: strings.TrimSpace(config.Username),
Password: config.Password,
})
}
sessionTimeout := millisecondsOrDefault(config.SessionTimeoutMS, defaultSessionTimeout)
connection, events, err := zk.Connect(
target.Servers,
sessionTimeout,
zk.WithDialer(dialer),
zk.WithLogInfo(false),
)
if err != nil {
return nil, err
}
connected := false
timer := time.NewTimer(connectionTimeout)
defer timer.Stop()
for !connected {
select {
case event, open := <-events:
if !open {
connection.Close()
return nil, errors.New("Connection timed out")
}
if event.State == zk.StateHasSession {
connected = true
}
if event.State == zk.StateAuthFailed {
connection.Close()
return nil, errors.New("ZooKeeper authentication failed")
}
case <-timer.C:
connection.Close()
return nil, errors.New("Connection timed out")
}
}
if authScheme == defaultAuthScheme && strings.TrimSpace(config.Username) != "" {
credentials := []byte(strings.TrimSpace(config.Username) + ":" + config.Password)
if err := connection.AddAuth(defaultAuthScheme, credentials); err != nil {
connection.Close()
return nil, err
}
}
prefix := joinPrefix(target.Chroot, config.Namespace)
retryBase := defaultBaseSleepTime
if config.BaseSleepTimeMS != nil {
retryBase = time.Duration(*config.BaseSleepTimeMS) * time.Millisecond
}
maxRetries := defaultMaxRetries
if config.MaxRetries != nil {
maxRetries = *config.MaxRetries
}
session := &clientSession{connection: connection, prefix: prefix, retryBase: retryBase, maxRetries: maxRetries}
exists, _, err := session.Exists("/")
if err != nil || !exists {
connection.Close()
if err != nil {
return nil, err
}
return nil, errors.New("Root znode is not readable")
}
return session, nil
}
func newZooKeeperDialer(connectionTimeout time.Duration, credentials *saslDigestCredentials) zk.Dialer {
return func(network, address string, libraryTimeout time.Duration) (net.Conn, error) {
timeout := libraryTimeout
if timeout <= 0 || connectionTimeout < timeout {
timeout = connectionTimeout
}
connection, err := net.DialTimeout(network, address, timeout)
if err != nil {
return nil, err
}
if credentials == nil {
return connection, nil
}
return newSASLHandshakeConn(connection, timeout, *credentials), nil
}
}
func connectionString(config connectionConfig) string {
for _, candidate := range []string{config.ZooKeeperConnectString, config.ConnectString, config.ConnectionString} {
if strings.TrimSpace(candidate) != "" {
return strings.TrimSpace(candidate)
}
}
host := strings.TrimSpace(config.Host)
if host == "" {
host = "127.0.0.1"
}
port := config.Port
if port <= 0 {
port = defaultPort
}
return net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(port))
}
func parseConnectTarget(value string) (connectTarget, error) {
connectString := strings.TrimSpace(strings.TrimPrefix(value, "zookeeper://"))
slash := strings.Index(connectString, "/")
hostsPart := connectString
chroot := ""
if slash >= 0 {
hostsPart = connectString[:slash]
chroot = normalizePrefix(connectString[slash:])
}
servers := make([]string, 0)
for _, item := range strings.Split(hostsPart, ",") {
server := strings.TrimSpace(item)
if server != "" {
servers = append(servers, server)
}
}
if len(servers) == 0 {
return connectTarget{}, errors.New("ZooKeeper connect string contains no servers")
}
return connectTarget{Servers: servers, Chroot: chroot}, nil
}
func requireReachableServer(servers []string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout+500*time.Millisecond)
defer cancel()
workers := minInt(len(servers), maximumReachabilityWorkers)
jobs := make(chan string)
reachable := make(chan struct{}, 1)
var waitGroup sync.WaitGroup
for worker := 0; worker < workers; worker++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for server := range jobs {
address, err := endpointAddress(server)
if err != nil {
continue
}
connection, err := net.DialTimeout("tcp", address, timeout)
if err == nil {
connection.Close()
select {
case reachable <- struct{}{}:
default:
}
}
}
}()
}
go func() {
defer close(jobs)
for _, server := range servers {
select {
case jobs <- server:
case <-ctx.Done():
return
}
}
}()
done := make(chan struct{})
go func() {
waitGroup.Wait()
close(done)
}()
select {
case <-reachable:
return nil
case <-done:
case <-ctx.Done():
}
return fmt.Errorf("No reachable ZooKeeper server within %dms: %s", timeout.Milliseconds(), strings.Join(servers, ","))
}
func endpointAddress(endpoint string) (string, error) {
value := strings.TrimSpace(endpoint)
if strings.HasPrefix(value, "[") {
if _, _, err := net.SplitHostPort(value); err == nil {
return value, nil
}
return value + ":" + strconv.Itoa(defaultPort), nil
}
if strings.Count(value, ":") == 0 {
return net.JoinHostPort(value, strconv.Itoa(defaultPort)), nil
}
if strings.Count(value, ":") == 1 {
if _, _, err := net.SplitHostPort(value); err != nil {
return "", err
}
return value, nil
}
return net.JoinHostPort(value, strconv.Itoa(defaultPort)), nil
}
func resolveAuthScheme(config connectionConfig) string {
if strings.TrimSpace(config.AuthScheme) != "" {
return strings.ToLower(strings.TrimSpace(config.AuthScheme))
}
params := strings.TrimPrefix(strings.TrimSpace(config.URLParams), "?")
params = strings.ReplaceAll(params, ";", "&")
if parsed, err := url.ParseQuery(params); err == nil {
if configured := strings.TrimSpace(parsed.Get("auth_scheme")); configured != "" {
return strings.ToLower(configured)
}
}
return defaultAuthScheme
}
func hasTLSOptions(config connectionConfig) bool {
return config.SSL || firstNonBlank(
config.CACertPath,
config.ClientCertPath,
config.ClientKeyPath,
config.CertPath,
config.KeyPath,
) != ""
}
func joinPrefix(chroot, namespace string) string {
parts := make([]string, 0, 2)
if normalized := normalizePrefix(chroot); normalized != "" {
parts = append(parts, strings.Trim(normalized, "/"))
}
if normalized := normalizePrefix(namespace); normalized != "" {
parts = append(parts, strings.Trim(normalized, "/"))
}
if len(parts) == 0 {
return ""
}
return "/" + strings.Join(parts, "/")
}
func normalizePrefix(value string) string {
trimmed := strings.Trim(strings.TrimSpace(value), "/")
if trimmed == "" {
return ""
}
return "/" + trimmed
}
func millisecondsOrDefault(value int, fallback time.Duration) time.Duration {
if value <= 0 {
return fallback
}
return time.Duration(value) * time.Millisecond
}
func configuredStatLookupConcurrency(value string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return defaultStatLookupWorkers
}
return maxInt(minimumStatLookupWorkers, minInt(maximumStatLookupWorkers, parsed))
}
func (service *server) requireClient() (znodeClient, error) {
if service.activeClient == nil {
return nil, errors.New("Not connected")
}
return service.activeClient, nil
}
func (service *server) closeClient() {
if service.activeClient != nil {
service.activeClient.Close()
service.activeClient = nil
}
}
func (session *clientSession) Close() {
if session != nil && session.connection != nil {
session.connection.Close()
}
}
func (session *clientSession) physicalPath(logicalPath string) string {
logical := normalizePath(logicalPath)
if session.prefix == "" {
return logical
}
if logical == "/" {
return session.prefix
}
return session.prefix + logical
}
func (session *clientSession) logicalPath(physicalPath string) string {
if session.prefix == "" {
return normalizePath(physicalPath)
}
trimmed := strings.TrimPrefix(physicalPath, session.prefix)
return normalizePath(trimmed)
}
func (session *clientSession) Exists(path string) (bool, *zk.Stat, error) {
type result struct {
exists bool
stat *zk.Stat
}
value, err := retryZooKeeper(session, func() (result, error) {
exists, stat, err := session.connection.Exists(session.physicalPath(path))
return result{exists: exists, stat: stat}, err
})
return value.exists, value.stat, err
}
func (session *clientSession) Get(path string) ([]byte, *zk.Stat, error) {
type result struct {
data []byte
stat *zk.Stat
}
value, err := retryZooKeeper(session, func() (result, error) {
data, stat, err := session.connection.Get(session.physicalPath(path))
return result{data: data, stat: stat}, err
})
return value.data, value.stat, err
}
func (session *clientSession) Children(path string) ([]string, *zk.Stat, error) {
type result struct {
children []string
stat *zk.Stat
}
value, err := retryZooKeeper(session, func() (result, error) {
children, stat, err := session.connection.Children(session.physicalPath(path))
return result{children: children, stat: stat}, err
})
return value.children, value.stat, err
}
func (session *clientSession) Create(path string, data []byte, flags int32) (string, error) {
createdPath, err := retryZooKeeper(session, func() (string, error) {
return session.connection.Create(session.physicalPath(path), data, flags, zk.WorldACL(zk.PermAll))
})
if err != nil {
return "", err
}
return session.logicalPath(createdPath), nil
}
func (session *clientSession) Set(path string, data []byte) (*zk.Stat, error) {
return retryZooKeeper(session, func() (*zk.Stat, error) {
return session.connection.Set(session.physicalPath(path), data, -1)
})
}
func (session *clientSession) Delete(path string) error {
_, err := retryZooKeeper(session, func() (struct{}, error) {
return struct{}{}, session.connection.Delete(session.physicalPath(path), -1)
})
return err
}
func retryZooKeeper[T any](session *clientSession, operation func() (T, error)) (T, error) {
for attempt := 0; ; attempt++ {
value, err := operation()
if err == nil || attempt >= session.maxRetries || !isRetryableZooKeeperError(err) {
return value, err
}
delay := session.retryBase * time.Duration(1<<minInt(attempt, 8))
if delay > 0 {
time.Sleep(delay)
}
}
}
func isRetryableZooKeeperError(err error) bool {
return errors.Is(err, zk.ErrConnectionClosed) || errors.Is(err, zk.ErrClosing) || errors.Is(err, zk.ErrSessionMoved)
}
func minDuration(first, second time.Duration) time.Duration {
if first < second {
return first
}
return second
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func minInt(first, second int) int {
if first < second {
return first
}
return second
}
func maxInt(first, second int) int {
if first > second {
return first
}
return second
}
+26
View File
@@ -0,0 +1,26 @@
module github.com/t8y2/dbx/agents/drivers/zookeeper
go 1.23.0
require (
github.com/beltran/gosasl v1.0.0
github.com/go-zookeeper/zk v1.0.4
)
require (
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/golang-auth/go-gssapi/v2 v2.0.0 // indirect
github.com/hashicorp/go-uuid v1.0.2 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.0.0 // indirect
github.com/jcmturner/gokrb5 v8.4.2+incompatible // indirect
github.com/jcmturner/gokrb5/v8 v8.4.2 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect
golang.org/x/net v0.0.0-20210326220855-61e056675ecf // indirect
)
replace github.com/beltran/gosasl => ../../go-common/gosasl
replace github.com/golang-auth/go-gssapi/v2 => ../../go-common/go-gssapi
+50
View File
@@ -0,0 +1,50 @@
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I=
github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0 h1:J7uCkflzTEhUZ64xqKnkDxq3kzc96ajM1Gli5ktUem8=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5 v8.4.2+incompatible h1:MQW70Fbazv31g6URAXCjO2bGenIL0wVt3wqcpc0EjHI=
github.com/jcmturner/gokrb5 v8.4.2+incompatible/go.mod h1:0Q5eFyVvYsEsZ8xl1A/jUqhXvxUp/X9ELrJm+zieq5E=
github.com/jcmturner/gokrb5/v8 v8.4.2 h1:6ZIM6b/JJN0X8UM43ZOM6Z4SJzla+a/u7scXFJzodkA=
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 h1:umElSU9WZirRdgu2yFHY0ayQkEnKiOC1TtM3fWXFnoU=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210326220855-61e056675ecf h1:WUcCxqQqDT0aXO4VnQbfMvp4zh7m1Gb2clVuHUAGGRE=
golang.org/x/net v0.0.0-20210326220855-61e056675ecf/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"fmt"
"os"
"testing"
"time"
)
const integrationConnectStringEnv = "DBX_ZOOKEEPER_TEST_CONNECT_STRING"
func TestZooKeeperIntegration(t *testing.T) {
connectString := os.Getenv(integrationConnectStringEnv)
if connectString == "" {
t.Skip(integrationConnectStringEnv + " is not set")
}
service := &server{statLookupConcurrency: 4}
connection := map[string]any{"zookeeper_connect_string": connectString}
if authScheme := os.Getenv("DBX_ZOOKEEPER_TEST_AUTH_SCHEME"); authScheme != "" {
connection["auth_scheme"] = authScheme
connection["username"] = os.Getenv("DBX_ZOOKEEPER_TEST_USERNAME")
connection["password"] = os.Getenv("DBX_ZOOKEEPER_TEST_PASSWORD")
}
params := mustJSON(map[string]any{"connection": connection})
if _, err := service.connect(params); err != nil {
t.Fatal(err)
}
t.Cleanup(service.closeClient)
root := fmt.Sprintf("/dbx-go-integration-%d", time.Now().UnixNano())
if _, err := service.put(mustJSON(map[string]any{"key": root + "/parent/value", "value": map[string]string{"encoding": "utf8", "data": "first"}})); err != nil {
t.Fatal(err)
}
result, err := service.get(mustJSON(map[string]any{"key": root + "/parent/value"}))
if err != nil || result["found"] != true {
t.Fatalf("get=%#v err=%v", result, err)
}
badConnection := mustJSON(map[string]any{"connection": map[string]any{"zookeeper_connect_string": "127.0.0.1:1", "connection_timeout_ms": 50}})
if _, err := service.testConnection(badConnection); err == nil {
t.Fatal("unreachable test connection unexpectedly succeeded")
}
if _, err := service.connect(badConnection); err == nil {
t.Fatal("unreachable replacement connection unexpectedly succeeded")
}
result, err = service.get(mustJSON(map[string]any{"key": root + "/parent/value"}))
if err != nil || result["found"] != true {
t.Fatalf("active connection was replaced after failed probe: get=%#v err=%v", result, err)
}
if _, err := service.put(mustJSON(map[string]any{"key": root + "/ephemeral-", "value": map[string]string{"data": "e"}, "writeMode": "create", "createMode": "ephemeral_sequential"})); err != nil {
t.Fatal(err)
}
listed, err := service.listPrefix(mustJSON(map[string]any{"prefix": root, "recursive": true, "limit": 100}))
if err != nil || len(listed.Keys) < 3 {
t.Fatalf("list=%#v err=%v", listed, err)
}
deleted, err := service.delete(mustJSON(map[string]any{"key": root, "recursive": true}))
if err != nil || deleted["deleted"].(int) < 4 {
t.Fatalf("delete=%#v err=%v", deleted, err)
}
}
func BenchmarkZooKeeperOperations(b *testing.B) {
connectString := os.Getenv(integrationConnectStringEnv)
if connectString == "" {
b.Skip(integrationConnectStringEnv + " is not set")
}
service := &server{statLookupConcurrency: 16}
if _, err := service.connect(mustJSON(map[string]any{"connection": map[string]any{"zookeeper_connect_string": connectString}})); err != nil {
b.Fatal(err)
}
b.Cleanup(service.closeClient)
root := fmt.Sprintf("/dbx-go-benchmark-%d", time.Now().UnixNano())
if _, err := service.put(mustJSON(map[string]any{"key": root + "/value", "value": map[string]string{"data": "warmup"}})); err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _, _ = service.delete(mustJSON(map[string]any{"key": root, "recursive": true})) })
b.Run("get", func(b *testing.B) {
params := json.RawMessage(fmt.Sprintf(`{"key":%q}`, root+"/value"))
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := service.get(params); err != nil {
b.Fatal(err)
}
}
})
b.Run("put", func(b *testing.B) {
params := json.RawMessage(fmt.Sprintf(`{"key":%q,"value":{"data":"updated"}}`, root+"/value"))
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := service.put(params); err != nil {
b.Fatal(err)
}
}
})
b.Run("list", func(b *testing.B) {
params := json.RawMessage(fmt.Sprintf(`{"prefix":%q,"recursive":true,"limit":100}`, root))
b.ResetTimer()
for index := 0; index < b.N; index++ {
if _, err := service.listPrefix(params); err != nil {
b.Fatal(err)
}
}
})
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
const (
protocolVersion = 1
agentProtocolVersion = 1
maxRPCMessageBytes = 32 * 1024 * 1024
)
var capabilities = []string{"connect", "test_connection", "kv"}
type rpcRequest struct {
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"`
}
type handshakeResult struct {
ProtocolVersion int `json:"protocolVersion"`
AgentProtocolVersion int `json:"agentProtocolVersion"`
Capabilities []string `json:"capabilities"`
}
type server struct {
activeClient znodeClient
statLookupConcurrency int
}
func main() {
service := newServer()
defer service.closeClient()
encoder := json.NewEncoder(os.Stdout)
encoder.SetEscapeHTML(false)
fmt.Fprintln(os.Stdout, `{"ready":true}`)
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 64*1024), maxRPCMessageBytes)
for scanner.Scan() {
response, shutdown := service.handleRequest(scanner.Bytes())
if err := encoder.Encode(response); err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
if shutdown {
return
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
func newServer() *server {
return &server{statLookupConcurrency: configuredStatLookupConcurrency(os.Getenv(statLookupConcurrencyEnv))}
}
func (service *server) handleRequest(payload []byte) (rpcResponse, bool) {
var request rpcRequest
if err := json.Unmarshal(payload, &request); err != nil {
return rpcResponse{JSONRPC: "2.0", Error: &rpcError{Code: -1, Message: err.Error()}}, false
}
result, shutdown, err := service.dispatch(request.Method, request.Params)
if err != nil {
return rpcResponse{
JSONRPC: "2.0",
ID: request.ID,
Error: &rpcError{Code: -1, Message: err.Error()},
}, false
}
return rpcResponse{JSONRPC: "2.0", ID: request.ID, Result: result}, shutdown
}
func (service *server) dispatch(method string, params json.RawMessage) (any, bool, error) {
switch method {
case "handshake":
return handshakeResult{
ProtocolVersion: protocolVersion,
AgentProtocolVersion: agentProtocolVersion,
Capabilities: capabilities,
}, false, nil
case "connect":
result, err := service.connect(params)
return result, false, err
case "test_connection":
result, err := service.testConnection(params)
return result, false, err
case "kv_list_prefix":
result, err := service.listPrefix(params)
return result, false, err
case "kv_get":
result, err := service.get(params)
return result, false, err
case "kv_put":
result, err := service.put(params)
return result, false, err
case "kv_delete":
result, err := service.delete(params)
return result, false, err
case "disconnect":
service.closeClient()
return map[string]bool{"ok": true}, false, nil
case "shutdown":
service.closeClient()
return map[string]bool{"ok": true}, true, nil
default:
return nil, false, fmt.Errorf("Unknown method: %s", method)
}
}
+515
View File
@@ -0,0 +1,515 @@
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"unicode/utf8"
"github.com/go-zookeeper/zk"
)
const defaultListLimit = 100
type valueObject struct {
Encoding string `json:"encoding"`
Data string `json:"data"`
}
type getRequest struct {
Key string `json:"key"`
}
type putRequest struct {
Key string `json:"key"`
Value valueObject `json:"value"`
WriteMode string `json:"writeMode"`
CreateMode string `json:"createMode"`
}
type deleteRequest struct {
Key string `json:"key"`
Recursive bool `json:"recursive"`
}
type listRequest struct {
Prefix string `json:"prefix"`
Recursive *bool `json:"recursive"`
Limit int `json:"limit"`
Continuation string `json:"continuation"`
}
type listCursor struct {
Root string `json:"root"`
Recursive bool `json:"recursive"`
Offset int `json:"offset"`
}
type listResponse struct {
Keys []map[string]any `json:"keys"`
Continuation *string `json:"continuation"`
}
func (service *server) get(params json.RawMessage) (map[string]any, error) {
client, err := service.requireClient()
if err != nil {
return nil, err
}
var request getRequest
if err := json.Unmarshal(params, &request); err != nil {
return nil, err
}
path := normalizePath(request.Key)
exists, _, err := client.Exists(path)
if err != nil {
return nil, err
}
if !exists {
return map[string]any{"found": false, "key": path, "value": nil, "metadata": nil}, nil
}
data, stat, err := client.Get(path)
if err == zk.ErrNoNode {
return map[string]any{"found": false, "key": path, "value": nil, "metadata": nil}, nil
}
if err != nil {
return nil, err
}
return map[string]any{
"found": true,
"key": path,
"value": encodeValue(data),
"metadata": statMetadata(stat),
}, nil
}
func (service *server) put(params json.RawMessage) (map[string]any, error) {
client, err := service.requireClient()
if err != nil {
return nil, err
}
var request putRequest
if err := json.Unmarshal(params, &request); err != nil {
return nil, err
}
path := normalizePath(request.Key)
if path == "/" {
return nil, errors.New("Root znode cannot be modified")
}
data, err := decodeValue(request.Value)
if err != nil {
return nil, err
}
writeMode := request.WriteMode
if writeMode == "" {
writeMode = "upsert"
}
switch writeMode {
case "create":
return createNode(client, path, data, request.CreateMode)
case "update":
stat, err := client.Set(path, data)
if err != nil {
return nil, err
}
return putResult(stat), nil
case "upsert":
exists, _, err := client.Exists(path)
if err != nil {
return nil, err
}
if exists {
stat, err := client.Set(path, data)
if err != nil {
return nil, err
}
return putResult(stat), nil
}
if err := createParents(client, parentPath(path)); err != nil {
return nil, err
}
createdPath, err := client.Create(path, data, 0)
if err != nil {
return nil, err
}
_, stat, err := client.Get(createdPath)
if err != nil {
return nil, err
}
return putResult(stat), nil
default:
return nil, fmt.Errorf("Unsupported writeMode: %s", writeMode)
}
}
func createNode(client znodeClient, path string, data []byte, mode string) (map[string]any, error) {
flags, err := createFlags(mode)
if err != nil {
return nil, err
}
if err := createParents(client, parentPath(path)); err != nil {
return nil, err
}
createdPath, err := client.Create(path, data, flags)
if err != nil {
return nil, err
}
_, stat, err := client.Get(createdPath)
if err != nil {
return nil, err
}
result := putResult(stat)
result["key"] = createdPath
result["createdKey"] = createdPath
return result, nil
}
func createFlags(mode string) (int32, error) {
switch mode {
case "", "persistent":
return 0, nil
case "ephemeral":
return zk.FlagEphemeral, nil
case "persistent_sequential":
return zk.FlagSequence, nil
case "ephemeral_sequential":
return zk.FlagEphemeral | zk.FlagSequence, nil
default:
return 0, fmt.Errorf("Unsupported createMode: %s", mode)
}
}
func createParents(client znodeClient, parent string) error {
if parent == "/" {
return nil
}
current := ""
for _, segment := range strings.Split(strings.Trim(parent, "/"), "/") {
current = childPath(current, segment)
exists, _, err := client.Exists(current)
if err != nil {
return err
}
if exists {
continue
}
if _, err := client.Create(current, nil, 0); err != nil && err != zk.ErrNodeExists {
return err
}
}
return nil
}
func (service *server) delete(params json.RawMessage) (map[string]any, error) {
client, err := service.requireClient()
if err != nil {
return nil, err
}
var request deleteRequest
if err := json.Unmarshal(params, &request); err != nil {
return nil, err
}
path := normalizePath(request.Key)
if path == "/" {
return nil, errors.New("Root znode cannot be deleted")
}
exists, _, err := client.Exists(path)
if err != nil {
return nil, err
}
if !exists {
return map[string]any{"deleted": 0}, nil
}
if !request.Recursive {
if err := client.Delete(path); err != nil {
if err == zk.ErrNoNode {
return map[string]any{"deleted": 0}, nil
}
return nil, err
}
return map[string]any{"deleted": 1}, nil
}
deleted, err := deleteSubtree(client, path)
if err != nil {
return nil, err
}
return map[string]any{"deleted": deleted}, nil
}
func deleteSubtree(client znodeClient, path string) (int, error) {
children, _, err := client.Children(path)
if err == zk.ErrNoNode {
return 0, nil
}
if err != nil {
return 0, err
}
deleted := 0
for _, child := range children {
count, err := deleteSubtree(client, childPath(path, child))
if err != nil {
return deleted, err
}
deleted += count
}
if err := client.Delete(path); err != nil {
if err == zk.ErrNoNode {
return deleted, nil
}
return deleted, err
}
return deleted + 1, nil
}
func (service *server) listPrefix(params json.RawMessage) (listResponse, error) {
client, err := service.requireClient()
if err != nil {
return listResponse{}, err
}
var request listRequest
if err := json.Unmarshal(params, &request); err != nil {
return listResponse{}, err
}
root := normalizePath(request.Prefix)
recursive := true
if request.Recursive != nil {
recursive = *request.Recursive
}
limit := request.Limit
if limit < 1 {
limit = defaultListLimit
}
cursor := listCursor{Root: root, Recursive: recursive}
if strings.TrimSpace(request.Continuation) != "" {
decoded, err := decodeCursor(request.Continuation)
if err != nil {
return listResponse{}, err
}
if decoded.Root != root || decoded.Recursive != recursive {
return listResponse{}, errors.New("Continuation does not match request")
}
cursor = decoded
}
exists, _, err := client.Exists(root)
if err != nil {
return listResponse{}, err
}
if !exists {
return listResponse{Keys: []map[string]any{}, Continuation: nil}, nil
}
var paths []string
if recursive {
paths, err = listRecursive(client, root)
} else {
paths, err = listDirectChildren(client, root)
}
if err != nil {
return listResponse{}, err
}
sort.Strings(paths)
offset := maxInt(0, cursor.Offset)
if offset > len(paths) {
offset = len(paths)
}
end := minInt(len(paths), offset+limit)
rows, err := service.rowsWithMetadata(client, paths[offset:end])
if err != nil {
return listResponse{}, err
}
var continuation *string
if end < len(paths) {
encoded, err := encodeCursor(listCursor{Root: root, Recursive: recursive, Offset: end})
if err != nil {
return listResponse{}, err
}
continuation = &encoded
}
return listResponse{Keys: rows, Continuation: continuation}, nil
}
func listDirectChildren(client znodeClient, root string) ([]string, error) {
children, _, err := client.Children(root)
if err == zk.ErrNoNode {
return []string{}, nil
}
if err != nil {
return nil, err
}
sort.Strings(children)
paths := make([]string, 0, len(children))
for _, child := range children {
paths = append(paths, childPath(root, child))
}
return paths, nil
}
func listRecursive(client znodeClient, root string) ([]string, error) {
result := make([]string, 0)
if err := collectRecursive(client, root, &result); err != nil {
return nil, err
}
return result, nil
}
func collectRecursive(client znodeClient, root string, result *[]string) error {
children, _, err := client.Children(root)
if err == zk.ErrNoNode {
return nil
}
if err != nil {
return err
}
sort.Strings(children)
for _, child := range children {
path := childPath(root, child)
*result = append(*result, path)
if err := collectRecursive(client, path, result); err != nil {
return err
}
}
return nil
}
func (service *server) rowsWithMetadata(client znodeClient, paths []string) ([]map[string]any, error) {
if len(paths) == 0 {
return []map[string]any{}, nil
}
rows := make([]map[string]any, len(paths))
jobs := make(chan int)
workers := minInt(service.statLookupConcurrency, len(paths))
var waitGroup sync.WaitGroup
var firstError error
var errorMutex sync.Mutex
for worker := 0; worker < workers; worker++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for index := range jobs {
exists, stat, err := client.Exists(paths[index])
if err == zk.ErrNoNode || !exists {
continue
}
if err != nil {
errorMutex.Lock()
if firstError == nil {
firstError = err
}
errorMutex.Unlock()
continue
}
row := statMetadata(stat)
row["key"] = paths[index]
rows[index] = row
}
}()
}
for index := range paths {
jobs <- index
}
close(jobs)
waitGroup.Wait()
if firstError != nil {
return nil, firstError
}
filtered := make([]map[string]any, 0, len(rows))
for _, row := range rows {
if row != nil {
filtered = append(filtered, row)
}
}
return filtered, nil
}
func statMetadata(stat *zk.Stat) map[string]any {
return map[string]any{
"czxid": stat.Czxid,
"mzxid": stat.Mzxid,
"pzxid": stat.Pzxid,
"ctime": stat.Ctime,
"mtime": stat.Mtime,
"version": stat.Version,
"cversion": stat.Cversion,
"aversion": stat.Aversion,
"ephemeralOwner": stat.EphemeralOwner,
"dataLength": stat.DataLength,
"numChildren": stat.NumChildren,
"createRevision": stat.Czxid,
"modRevision": stat.Mzxid,
"valueSize": stat.DataLength,
}
}
func putResult(stat *zk.Stat) map[string]any {
return map[string]any{"version": stat.Version, "mtime": stat.Mtime}
}
func encodeValue(data []byte) valueObject {
if utf8.Valid(data) {
return valueObject{Encoding: "utf8", Data: string(data)}
}
return valueObject{Encoding: "base64", Data: base64.StdEncoding.EncodeToString(data)}
}
func decodeValue(value valueObject) ([]byte, error) {
switch value.Encoding {
case "", "utf8":
return []byte(value.Data), nil
case "base64":
return base64.StdEncoding.DecodeString(value.Data)
default:
return nil, fmt.Errorf("Unsupported value encoding: %s", value.Encoding)
}
}
func encodeCursor(cursor listCursor) (string, error) {
payload, err := json.Marshal(cursor)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(payload), nil
}
func decodeCursor(value string) (listCursor, error) {
payload, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return listCursor{}, err
}
var cursor listCursor
if err := json.Unmarshal(payload, &cursor); err != nil {
return listCursor{}, err
}
return cursor, nil
}
func normalizePath(value string) string {
if value == "" || value == "/" {
return "/"
}
normalized := value
if !strings.HasPrefix(normalized, "/") {
normalized = "/" + normalized
}
for len(normalized) > 1 && strings.HasSuffix(normalized, "/") {
normalized = strings.TrimSuffix(normalized, "/")
}
return normalized
}
func childPath(parent, child string) string {
if parent == "" || parent == "/" {
return "/" + strings.Trim(child, "/")
}
return strings.TrimRight(parent, "/") + "/" + strings.Trim(child, "/")
}
func parentPath(value string) string {
path := normalizePath(value)
separator := strings.LastIndex(path, "/")
if separator <= 0 {
return "/"
}
return path[:separator]
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/beltran/gosasl"
)
const (
zooKeeperSASLOpcode = int32(102)
zooKeeperSASLXIDBase = int32(0x3fff0000)
zooKeeperSASLMaxRounds = 8
zooKeeperMaximumFrameLen = 16 * 1024 * 1024
)
type saslDigestCredentials struct {
Username string
Password string
}
type saslHandshakeConn struct {
net.Conn
timeout time.Duration
credentials saslDigestCredentials
mutex sync.Mutex
initialized bool
buffer []byte
error error
}
var authenticateZooKeeperSASL = authenticateSASLDigest
func newSASLHandshakeConn(connection net.Conn, timeout time.Duration, credentials saslDigestCredentials) net.Conn {
return &saslHandshakeConn{
Conn: connection,
timeout: timeout,
credentials: credentials,
}
}
func (connection *saslHandshakeConn) Read(payload []byte) (int, error) {
connection.mutex.Lock()
defer connection.mutex.Unlock()
if !connection.initialized {
connection.initialized = true
connection.buffer, connection.error = readZooKeeperFrame(connection.Conn)
if connection.error == nil {
connection.error = authenticateZooKeeperSASL(connection.Conn, connection.timeout, connection.credentials)
}
}
if connection.error != nil {
return 0, connection.error
}
if len(connection.buffer) > 0 {
read := copy(payload, connection.buffer)
connection.buffer = connection.buffer[read:]
return read, nil
}
return connection.Conn.Read(payload)
}
func authenticateSASLDigest(connection net.Conn, timeout time.Duration, credentials saslDigestCredentials) error {
mechanism := gosasl.NewDigestMD5Mechanism("zookeeper", credentials.Username, credentials.Password)
saslClient := gosasl.NewSaslClient("zk-sasl-md5", mechanism)
defer saslClient.Dispose()
return negotiateSASLDigest(connection, timeout, saslClient)
}
type saslClient interface {
Start() ([]byte, error)
Step(challenge []byte) ([]byte, error)
Complete() bool
}
func negotiateSASLDigest(connection net.Conn, timeout time.Duration, saslClient saslClient) error {
if timeout <= 0 {
timeout = defaultConnectionTimeout
}
if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
return err
}
defer connection.SetDeadline(time.Time{})
token, err := saslClient.Start()
if err != nil {
return fmt.Errorf("start ZooKeeper DIGEST-MD5 negotiation: %w", err)
}
for round := 0; round < zooKeeperSASLMaxRounds; round++ {
challenge, err := zooKeeperSASLRound(connection, zooKeeperSASLXIDBase+int32(round), token)
if err != nil {
return fmt.Errorf("ZooKeeper SASL round %d: %w", round+1, err)
}
if saslClient.Complete() {
if len(challenge) != 0 {
return errors.New("ZooKeeper sent an unexpected token after DIGEST-MD5 completion")
}
return nil
}
token, err = saslClient.Step(challenge)
if err != nil {
return fmt.Errorf("continue ZooKeeper DIGEST-MD5 negotiation at round %d: %w", round+1, err)
}
if saslClient.Complete() {
if len(token) != 0 {
return errors.New("ZooKeeper DIGEST-MD5 completed with an unexpected client token")
}
return nil
}
}
return fmt.Errorf("ZooKeeper DIGEST-MD5 negotiation exceeded %d rounds", zooKeeperSASLMaxRounds)
}
func zooKeeperSASLRound(connection net.Conn, xid int32, token []byte) ([]byte, error) {
payload := make([]byte, 12+len(token))
binary.BigEndian.PutUint32(payload[0:4], uint32(xid))
binary.BigEndian.PutUint32(payload[4:8], uint32(zooKeeperSASLOpcode))
binary.BigEndian.PutUint32(payload[8:12], uint32(len(token)))
copy(payload[12:], token)
if err := writeZooKeeperFrame(connection, payload); err != nil {
return nil, err
}
response, err := readZooKeeperFrame(connection)
if err != nil {
return nil, err
}
if len(response) < 20 {
return nil, errors.New("ZooKeeper SASL response is truncated")
}
responseXID := int32(binary.BigEndian.Uint32(response[4:8]))
if responseXID != xid {
return nil, fmt.Errorf("ZooKeeper SASL response xid %d does not match request xid %d", responseXID, xid)
}
errorCode := int32(binary.BigEndian.Uint32(response[16:20]))
if errorCode != 0 {
return nil, fmt.Errorf("ZooKeeper SASL server returned error %d", errorCode)
}
if len(response) < 24 {
return nil, errors.New("ZooKeeper SASL token is truncated")
}
tokenLength := int(int32(binary.BigEndian.Uint32(response[20:24])))
if tokenLength < 0 || tokenLength > zooKeeperMaximumFrameLen || 24+tokenLength > len(response) {
return nil, fmt.Errorf("ZooKeeper SASL token length %d is invalid", tokenLength)
}
return append([]byte(nil), response[24:24+tokenLength]...), nil
}
func readZooKeeperFrame(reader io.Reader) ([]byte, error) {
header := make([]byte, 4)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, err
}
length := int(int32(binary.BigEndian.Uint32(header)))
if length < 0 || length > zooKeeperMaximumFrameLen {
return nil, fmt.Errorf("ZooKeeper frame length %d is invalid", length)
}
payload := make([]byte, length+4)
copy(payload, header)
if _, err := io.ReadFull(reader, payload[4:]); err != nil {
return nil, err
}
return payload, nil
}
func writeZooKeeperFrame(writer io.Writer, payload []byte) error {
if len(payload) > zooKeeperMaximumFrameLen {
return fmt.Errorf("ZooKeeper frame length %d exceeds maximum %d", len(payload), zooKeeperMaximumFrameLen)
}
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, uint32(len(payload)))
if err := writeAll(writer, header); err != nil {
return err
}
return writeAll(writer, payload)
}
func writeAll(writer io.Writer, payload []byte) error {
for len(payload) > 0 {
written, err := writer.Write(payload)
if err != nil {
return err
}
if written <= 0 {
return io.ErrShortWrite
}
payload = payload[written:]
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"bytes"
"encoding/binary"
"errors"
"io"
"net"
"testing"
"time"
)
func TestSASLHandshakeConnReplaysConnectResponse(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
previous := authenticateZooKeeperSASL
t.Cleanup(func() { authenticateZooKeeperSASL = previous })
called := make(chan saslDigestCredentials, 1)
authenticateZooKeeperSASL = func(_ net.Conn, _ time.Duration, credentials saslDigestCredentials) error {
called <- credentials
return nil
}
go func() { _ = writeZooKeeperFrame(server, []byte("connect-response")) }()
wrapper := newSASLHandshakeConn(client, time.Second, saslDigestCredentials{Username: "user", Password: "secret"})
frame, err := readZooKeeperFrame(wrapper)
if err != nil || string(frame[4:]) != "connect-response" {
t.Fatalf("frame=%q err=%v", frame, err)
}
if credentials := <-called; credentials.Username != "user" || credentials.Password != "secret" {
t.Fatalf("credentials=%#v", credentials)
}
}
func TestZooKeeperSASLRound(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
serverError := make(chan error, 1)
go func() {
request, err := readZooKeeperFrame(server)
if err != nil {
serverError <- err
return
}
if len(request) != 4+12+len("client-token") || int32(binary.BigEndian.Uint32(request[4:8])) != 41 || int32(binary.BigEndian.Uint32(request[8:12])) != zooKeeperSASLOpcode || string(request[16:]) != "client-token" {
serverError <- errors.New("unexpected SASL request")
return
}
response := make([]byte, 20+4+len("server-token"))
binary.BigEndian.PutUint32(response[0:4], 41)
binary.BigEndian.PutUint32(response[16:20], uint32(len("server-token")))
copy(response[20:], "server-token")
serverError <- writeZooKeeperFrame(server, response)
}()
token, err := zooKeeperSASLRound(client, 41, []byte("client-token"))
if err != nil || string(token) != "server-token" {
t.Fatalf("token=%q err=%v", token, err)
}
if err := <-serverError; err != nil {
t.Fatal(err)
}
}
func TestZooKeeperSASLRoundRejectsWrongXID(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
go func() {
_, _ = readZooKeeperFrame(server)
response := make([]byte, 24)
binary.BigEndian.PutUint32(response[0:4], 99)
_ = writeZooKeeperFrame(server, response)
}()
if _, err := zooKeeperSASLRound(client, 41, nil); err == nil || !bytes.Contains([]byte(err.Error()), []byte("does not match")) {
t.Fatalf("error=%v", err)
}
}
func TestZooKeeperFrameValidation(t *testing.T) {
oversized := make([]byte, 4)
binary.BigEndian.PutUint32(oversized, zooKeeperMaximumFrameLen+1)
if _, err := readZooKeeperFrame(bytes.NewReader(oversized)); err == nil {
t.Fatal("oversized frame was accepted")
}
truncated := append([]byte{0, 0, 0, 4}, []byte{1, 2}...)
if _, err := readZooKeeperFrame(bytes.NewReader(truncated)); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("truncated error=%v", err)
}
}
func TestSASLNegotiationStopsAfterRspauth(t *testing.T) {
clientConnection, serverConnection := net.Pipe()
defer clientConnection.Close()
defer serverConnection.Close()
client := &scriptedDigestClient{}
serverError := make(chan error, 1)
go func() {
for round, challenge := range [][]byte{[]byte("challenge"), []byte("rspauth")} {
request, err := readZooKeeperFrame(serverConnection)
if err != nil {
serverError <- err
return
}
expectedToken := ""
if round == 1 {
expectedToken = "response"
}
if string(request[16:]) != expectedToken {
serverError <- errors.New("unexpected client token")
return
}
response := make([]byte, 24+len(challenge))
xid := int32(binary.BigEndian.Uint32(request[4:8]))
binary.BigEndian.PutUint32(response[0:4], uint32(xid))
binary.BigEndian.PutUint32(response[16:20], uint32(len(challenge)))
copy(response[20:], challenge)
if err := writeZooKeeperFrame(serverConnection, response); err != nil {
serverError <- err
return
}
}
_ = serverConnection.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
buffer := make([]byte, 1)
if _, err := serverConnection.Read(buffer); err == nil {
serverError <- errors.New("client sent an unexpected third SASL round")
return
}
serverError <- nil
}()
if err := negotiateSASLDigest(clientConnection, time.Second, client); err != nil {
t.Fatal(err)
}
if err := <-serverError; err != nil {
t.Fatal(err)
}
}
type scriptedDigestClient struct {
step int
}
func (client *scriptedDigestClient) Start() ([]byte, error) { return nil, nil }
func (client *scriptedDigestClient) Step(challenge []byte) ([]byte, error) {
client.step++
if client.step == 1 && string(challenge) == "challenge" {
return []byte("response"), nil
}
if client.step == 2 && string(challenge) == "rspauth" {
return nil, nil
}
return nil, errors.New("unexpected challenge")
}
func (client *scriptedDigestClient) Complete() bool { return client.step == 2 }
@@ -1,971 +0,0 @@
package com.dbx.agent.zookeeper;
import com.dbx.agent.AgentProtocol;
import com.google.gson.*;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.client.ZKClientConfig;
import org.apache.zookeeper.data.Stat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URLDecoder;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
public final class ZooKeeperAgent {
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
private static final int DEFAULT_LIMIT = 100;
private static final int DEFAULT_SESSION_TIMEOUT_MS = 30000;
private static final int DEFAULT_CONNECTION_TIMEOUT_MS = 15000;
private static final int DEFAULT_BASE_SLEEP_TIME_MS = 250;
private static final int DEFAULT_MAX_RETRIES = 2;
private static final int DEFAULT_PORT = 2181;
private static final int DEFAULT_PROBE_TIMEOUT_MS = 2000;
private static final String STAT_LOOKUP_CONCURRENCY_PROPERTY = "dbx.zookeeper.statLookupConcurrency";
private static final String STAT_LOOKUP_CONCURRENCY_ENV = "DBX_ZOOKEEPER_STAT_LOOKUP_CONCURRENCY";
private static final int DEFAULT_STAT_LOOKUP_CONCURRENCY = 16;
private static final int MIN_STAT_LOOKUP_CONCURRENCY = 1;
private static final int MAX_STAT_LOOKUP_CONCURRENCY = 64;
private static final String DEFAULT_AUTH_SCHEME = "digest";
private static final String SASL_DIGEST_AUTH_SCHEME = "sasl_digest";
private static final String SASL_LOGIN_CONTEXT_PREFIX = "DbxZooKeeperClient";
private static final Object JAAS_LOCK = new Object();
private static final AtomicInteger SASL_CONTEXT_SEQUENCE = new AtomicInteger(1);
private static final int STAT_LOOKUP_CONCURRENCY = configuredStatLookupConcurrency();
private static final ExecutorService STAT_LOOKUP_EXECUTOR = Executors.newFixedThreadPool(
STAT_LOOKUP_CONCURRENCY,
daemonThreadFactory("dbx-zookeeper-stat-lookup-")
);
private static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
AgentProtocol.CAPABILITY_CONNECT,
AgentProtocol.CAPABILITY_TEST_CONNECTION,
AgentProtocol.CAPABILITY_KV
));
private static CuratorFramework client;
private static SaslLease activeSaslLease;
private static JaasRegistryConfiguration jaasRegistry;
private ZooKeeperAgent() {
}
private static Object handshakeResult() {
return new HandshakeResult(AgentProtocol.PROTOCOL_VERSION, AgentProtocol.PROTOCOL_VERSION, CAPABILITIES);
}
private static Object connect(JsonObject params) throws Exception {
JsonObject connection = connectionObject(params);
SaslLease nextLease = SASL_DIGEST_AUTH_SCHEME.equals(authScheme(connection)) ? acquireSaslLease(connection) : null;
CuratorFramework nextClient = null;
try {
nextClient = buildClient(connection, nextLease == null ? null : nextLease.contextName);
startAndVerify(nextClient, connection);
closeClient();
client = nextClient;
activeSaslLease = nextLease;
nextLease = null;
return Collections.singletonMap("ok", true);
} catch (Exception e) {
if (nextClient != null) {
nextClient.close();
}
throw e;
} finally {
if (nextLease != null) {
nextLease.close();
}
}
}
private static Object testConnection(JsonObject params) throws Exception {
JsonObject connection = connectionObject(params);
SaslLease probeLease = SASL_DIGEST_AUTH_SCHEME.equals(authScheme(connection)) ? acquireSaslLease(connection) : null;
CuratorFramework probe = null;
try {
probe = buildClient(connection, probeLease == null ? null : probeLease.contextName);
startAndVerify(probe, connection);
return Collections.singletonMap("ok", true);
} finally {
try {
if (probe != null) {
probe.close();
}
} finally {
if (probeLease != null) {
probeLease.close();
}
}
}
}
static CuratorFramework buildClient(JsonObject connection) {
return buildClient(connection, null);
}
private static CuratorFramework buildClient(JsonObject connection, String saslContextName) {
if (hasTlsOptions(connection)) {
throw new IllegalArgumentException("ZooKeeper TLS is not supported");
}
String scheme = authScheme(connection);
if (!DEFAULT_AUTH_SCHEME.equals(scheme) && !SASL_DIGEST_AUTH_SCHEME.equals(scheme)) {
throw new IllegalArgumentException(
"Unsupported auth_scheme \"" + scheme + "\"; expected \"digest\" or \"sasl_digest\""
);
}
if (SASL_DIGEST_AUTH_SCHEME.equals(scheme)) {
saslCredentials(connection);
}
int baseSleepTimeMs = intOrDefault(connection, "base_sleep_time_ms", DEFAULT_BASE_SLEEP_TIME_MS);
int maxRetries = intOrDefault(connection, "max_retries", DEFAULT_MAX_RETRIES);
int connectionTimeoutMs = intOrDefault(connection, "connection_timeout_ms", DEFAULT_CONNECTION_TIMEOUT_MS);
String connectString = reachableConnectString(
connectString(connection),
Math.min(DEFAULT_PROBE_TIMEOUT_MS, connectionTimeoutMs)
);
CuratorFrameworkFactory.Builder builder = CuratorFrameworkFactory.builder()
.connectString(connectString)
.sessionTimeoutMs(intOrDefault(connection, "session_timeout_ms", DEFAULT_SESSION_TIMEOUT_MS))
.connectionTimeoutMs(connectionTimeoutMs)
.retryPolicy(new ExponentialBackoffRetry(baseSleepTimeMs, maxRetries));
if (SASL_DIGEST_AUTH_SCHEME.equals(scheme)) {
if (saslContextName == null) {
throw new IllegalStateException("SASL JAAS context was not initialized");
}
ZKClientConfig clientConfig = new ZKClientConfig();
clientConfig.setProperty(ZKClientConfig.ENABLE_CLIENT_SASL_KEY, "true");
clientConfig.setProperty(ZKClientConfig.LOGIN_CONTEXT_NAME_KEY, saslContextName);
builder.zkClientConfig(clientConfig);
}
String namespace = trimSlashes(stringOrEmpty(connection, "namespace"));
if (!namespace.isBlank()) {
builder.namespace(namespace);
}
if (DEFAULT_AUTH_SCHEME.equals(scheme)) {
String username = stringOrEmpty(connection, "username");
String password = stringOrEmpty(connection, "password");
if (!username.isBlank()) {
builder.authorization(DEFAULT_AUTH_SCHEME, (username + ":" + password).getBytes(StandardCharsets.UTF_8));
}
}
return builder.build();
}
static String connectString(JsonObject connection) {
String configured = firstNonBlank(
stringOrNull(connection, "zookeeper_connect_string"),
stringOrNull(connection, "connect_string"),
stringOrNull(connection, "connection_string")
);
if (configured != null) {
return configured;
}
return stringOrDefault(connection, "host", "127.0.0.1") + ":"
+ intOrDefault(connection, "port", DEFAULT_PORT);
}
static String reachableConnectString(String connectString, int probeTimeoutMs) {
int slash = connectString.indexOf('/');
String hostsPart = slash >= 0 ? connectString.substring(0, slash) : connectString;
String chroot = slash >= 0 ? connectString.substring(slash) : "";
List<String> hosts = new ArrayList<>();
for (String item : hostsPart.split(",")) {
String trimmed = item.trim();
if (!trimmed.isEmpty()) {
hosts.add(trimmed);
}
}
List<String> reachable = probeReachableHosts(hosts, probeTimeoutMs);
if (reachable.isEmpty()) {
throw new IllegalStateException(
"No reachable ZooKeeper server within " + probeTimeoutMs + "ms: " + hostsPart
);
}
// Curator needs the complete ensemble to reconnect when nodes recover or fail over later.
return String.join(",", hosts) + chroot;
}
private static List<String> probeReachableHosts(List<String> hosts, int probeTimeoutMs) {
if (hosts.isEmpty()) {
return Collections.emptyList();
}
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(hosts.size(), 8),
daemonThreadFactory("dbx-zookeeper-probe-")
);
try {
List<Future<Boolean>> probes = new ArrayList<>();
for (String host : hosts) {
probes.add(executor.submit(() -> isEndpointReachable(host, probeTimeoutMs)));
}
// Probes run in parallel; the deadline covers socket timeout plus DNS slack.
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(probeTimeoutMs + 500L);
List<String> reachable = new ArrayList<>();
for (int i = 0; i < hosts.size(); i++) {
long remainingMs = Math.max(1, TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()));
try {
if (probes.get(i).get(remainingMs, TimeUnit.MILLISECONDS)) {
reachable.add(hosts.get(i));
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("ZooKeeper reachability probe interrupted", e);
} catch (ExecutionException | TimeoutException e) {
// Treat probe failures as unreachable.
}
}
return reachable;
} finally {
executor.shutdownNow();
}
}
private static boolean isEndpointReachable(String endpoint, int probeTimeoutMs) {
try (Socket socket = new Socket()) {
socket.connect(parseEndpoint(endpoint), probeTimeoutMs);
return true;
} catch (Exception e) {
return false;
}
}
static InetSocketAddress parseEndpoint(String endpoint) {
String host = endpoint;
int port = DEFAULT_PORT;
int bracketEnd = endpoint.indexOf(']');
if (endpoint.startsWith("[") && bracketEnd > 0) {
host = endpoint.substring(1, bracketEnd);
if (bracketEnd + 1 < endpoint.length() && endpoint.charAt(bracketEnd + 1) == ':') {
port = Integer.parseInt(endpoint.substring(bracketEnd + 2));
}
} else {
int colon = endpoint.lastIndexOf(':');
if (colon >= 0 && endpoint.indexOf(':') == colon) {
host = endpoint.substring(0, colon);
port = Integer.parseInt(endpoint.substring(colon + 1));
}
}
return new InetSocketAddress(host, port);
}
private static void startAndVerify(CuratorFramework active, JsonObject connection) throws Exception {
active.start();
int timeoutMs = intOrDefault(connection, "connection_timeout_ms", DEFAULT_CONNECTION_TIMEOUT_MS);
if (!active.blockUntilConnected(timeoutMs, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("Connection timed out");
}
if (active.checkExists().forPath("/") == null) {
throw new IllegalStateException("Root znode is not readable");
}
}
private static Object listPrefix(JsonObject params) throws Exception {
CuratorFramework active = requireClient();
String root = normalizePath(stringOrDefault(params, "prefix", "/"));
boolean recursive = boolOrDefault(params, "recursive", true);
int limit = Math.max(1, intOrDefault(params, "limit", DEFAULT_LIMIT));
Cursor cursor = null;
String continuation = stringOrNull(params, "continuation");
if (continuation != null && !continuation.isBlank()) {
cursor = decodeCursor(continuation);
if (!root.equals(cursor.root) || recursive != cursor.recursive) {
throw new IllegalArgumentException("Continuation does not match request");
}
}
Map<String, Object> result = new LinkedHashMap<>();
List<Map<String, Object>> keys = new ArrayList<>();
Stat rootStat = active.checkExists().forPath(root);
if (rootStat == null) {
result.put("keys", keys);
result.put("continuation", null);
return result;
}
List<String> paths = recursive ? listRecursive(active, root) : listDirectChildren(active, root);
Collections.sort(paths);
int offset = cursor == null ? 0 : Math.max(0, cursor.offset);
int end = Math.min(paths.size(), offset + limit);
keys.addAll(listRowsWithMetadata(active, paths.subList(offset, end)));
result.put("keys", keys);
result.put("continuation", end < paths.size() ? encodeCursor(new Cursor(root, recursive, end)) : null);
return result;
}
private static Object get(JsonObject params) throws Exception {
CuratorFramework active = requireClient();
String path = normalizePath(stringOrDefault(params, "key", ""));
Map<String, Object> result = new LinkedHashMap<>();
Stat exists = active.checkExists().forPath(path);
if (exists == null) {
result.put("found", false);
result.put("key", path);
result.put("value", null);
result.put("metadata", null);
return result;
}
Stat stat = new Stat();
byte[] bytes = active.getData().storingStatIn(stat).forPath(path);
result.put("found", true);
result.put("key", path);
result.put("value", valueObject(bytes));
result.put("metadata", statMetadata(stat));
return result;
}
private static Object put(JsonObject params) throws Exception {
CuratorFramework active = requireClient();
String path = normalizePath(stringOrDefault(params, "key", ""));
requireMutablePath(path, "modified");
byte[] bytes = parseValue(params.getAsJsonObject("value"));
String writeMode = stringOrDefault(params, "writeMode", "upsert");
return switch (writeMode) {
case "create" -> create(active, path, bytes, createMode(params));
case "update" -> update(active, path, bytes);
case "upsert" -> upsert(active, path, bytes);
default -> throw new IllegalArgumentException("Unsupported writeMode: " + writeMode);
};
}
private static Object upsert(CuratorFramework active, String path, byte[] bytes) throws Exception {
Stat stat = active.checkExists().forPath(path);
if (stat == null) {
active.create().creatingParentsIfNeeded().forPath(path, bytes);
stat = active.checkExists().forPath(path);
} else {
stat = active.setData().forPath(path, bytes);
}
return putResult(stat);
}
private static Object create(CuratorFramework active, String path, byte[] bytes, CreateMode createMode) throws Exception {
String createdPath = active.create()
.creatingParentsIfNeeded()
.withMode(createMode)
.forPath(path, bytes);
Stat stat = active.checkExists().forPath(createdPath);
Map<String, Object> result = putResult(stat);
result.put("key", createdPath);
result.put("createdKey", createdPath);
return result;
}
private static Object update(CuratorFramework active, String path, byte[] bytes) throws Exception {
return putResult(active.setData().forPath(path, bytes));
}
private static Map<String, Object> putResult(Stat stat) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("version", stat.getVersion());
result.put("mtime", stat.getMtime());
return result;
}
private static CreateMode createMode(JsonObject params) {
String createMode = stringOrDefault(params, "createMode", "persistent");
return switch (createMode) {
case "persistent" -> CreateMode.PERSISTENT;
case "ephemeral" -> CreateMode.EPHEMERAL;
case "persistent_sequential" -> CreateMode.PERSISTENT_SEQUENTIAL;
case "ephemeral_sequential" -> CreateMode.EPHEMERAL_SEQUENTIAL;
default -> throw new IllegalArgumentException("Unsupported createMode: " + createMode);
};
}
private static Object delete(JsonObject params) throws Exception {
CuratorFramework active = requireClient();
String path = normalizePath(stringOrDefault(params, "key", ""));
requireMutablePath(path, "deleted");
boolean recursive = boolOrDefault(params, "recursive", false);
int deleted;
try {
if (active.checkExists().forPath(path) == null) {
deleted = 0;
} else if (recursive) {
deleted = countSubtree(active, path);
active.delete().deletingChildrenIfNeeded().forPath(path);
} else {
active.delete().forPath(path);
deleted = 1;
}
} catch (KeeperException.NoNodeException e) {
deleted = 0;
}
return Collections.singletonMap("deleted", deleted);
}
private static Object dispatch(String method, JsonObject params) throws Exception {
return switch (method) {
case AgentProtocol.METHOD_HANDSHAKE -> handshakeResult();
case AgentProtocol.METHOD_CONNECT -> connect(params);
case AgentProtocol.METHOD_TEST_CONNECTION -> testConnection(params);
case AgentProtocol.KV_METHOD_LIST_PREFIX -> listPrefix(params);
case AgentProtocol.KV_METHOD_GET -> get(params);
case AgentProtocol.KV_METHOD_PUT -> put(params);
case AgentProtocol.KV_METHOD_DELETE -> delete(params);
case AgentProtocol.METHOD_DISCONNECT -> {
closeClient();
yield Collections.singletonMap("ok", true);
}
case AgentProtocol.METHOD_SHUTDOWN -> {
closeClient();
STAT_LOOKUP_EXECUTOR.shutdownNow();
System.exit(0);
yield Collections.singletonMap("ok", true);
}
default -> throw new IllegalArgumentException("Unknown method: " + method);
};
}
static String handleRequest(String line) {
JsonObject req = JsonParser.parseString(line).getAsJsonObject();
JsonElement id = req.get("id");
String method = req.get("method").getAsString();
JsonObject params = req.has("params") && req.get("params").isJsonObject()
? req.getAsJsonObject("params")
: new JsonObject();
JsonObject response = new JsonObject();
response.addProperty("jsonrpc", "2.0");
response.add("id", id);
try {
Object result = dispatch(method, params);
response.add("result", GSON.toJsonTree(result));
} catch (Exception e) {
JsonObject error = new JsonObject();
error.addProperty("code", -1);
error.addProperty("message", e.getMessage() == null ? "Unknown error" : e.getMessage());
response.add("error", error);
}
return GSON.toJson(response);
}
private static JsonObject connectionObject(JsonObject params) {
JsonElement connection = params.get("connection");
return connection != null && connection.isJsonObject() ? connection.getAsJsonObject() : params;
}
private static CuratorFramework requireClient() {
if (client == null) {
throw new IllegalStateException("Not connected");
}
return client;
}
private static void closeClient() {
try {
if (client != null) {
client.close();
client = null;
}
} finally {
if (activeSaslLease != null) {
activeSaslLease.close();
activeSaslLease = null;
}
}
}
static String normalizePath(String path) {
String normalized = path == null ? "" : path;
if (normalized.isEmpty() || "/".equals(normalized)) {
return "/";
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
while (normalized.length() > 1 && normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private static String childPath(String parent, String child) {
return "/".equals(parent) ? "/" + child : parent + "/" + child;
}
private static void requireMutablePath(String path, String action) {
if ("/".equals(path)) {
throw new IllegalArgumentException("Root znode cannot be " + action);
}
}
private static Map<String, Object> statMetadata(Stat stat) {
Map<String, Object> metadata = new LinkedHashMap<>();
long czxid = stat.getCzxid();
long mzxid = stat.getMzxid();
long pzxid = stat.getPzxid();
int dataLength = stat.getDataLength();
metadata.put("czxid", czxid);
metadata.put("mzxid", mzxid);
metadata.put("pzxid", pzxid);
metadata.put("ctime", stat.getCtime());
metadata.put("mtime", stat.getMtime());
metadata.put("version", stat.getVersion());
metadata.put("cversion", stat.getCversion());
metadata.put("aversion", stat.getAversion());
metadata.put("ephemeralOwner", stat.getEphemeralOwner());
metadata.put("dataLength", dataLength);
metadata.put("numChildren", stat.getNumChildren());
metadata.put("createRevision", czxid);
metadata.put("modRevision", mzxid);
metadata.put("valueSize", dataLength);
return metadata;
}
private static Map<String, Object> valueObject(byte[] bytes) {
Map<String, Object> value = new LinkedHashMap<>();
// ZooKeeper represents znodes created without a data payload as null.
// Expose them as empty UTF-8 values instead of passing null to decoders.
if (bytes == null) {
bytes = new byte[0];
}
String utf8 = strictUtf8(bytes);
if (utf8 != null) {
value.put("encoding", "utf8");
value.put("data", utf8);
} else {
value.put("encoding", "base64");
value.put("data", Base64.getEncoder().encodeToString(bytes));
}
return value;
}
private static byte[] parseValue(JsonObject value) {
if (value == null) {
return new byte[0];
}
String encoding = stringOrDefault(value, "encoding", "utf8");
String data = stringOrDefault(value, "data", "");
if ("base64".equals(encoding)) {
return Base64.getDecoder().decode(data);
}
if (!"utf8".equals(encoding)) {
throw new IllegalArgumentException("Unsupported value encoding: " + encoding);
}
return data.getBytes(StandardCharsets.UTF_8);
}
private static String strictUtf8(byte[] bytes) {
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
return null;
}
}
private static String encodeCursor(Cursor cursor) {
JsonObject object = new JsonObject();
object.addProperty("root", cursor.root);
object.addProperty("recursive", cursor.recursive);
object.addProperty("offset", cursor.offset);
return Base64.getEncoder().encodeToString(GSON.toJson(object).getBytes(StandardCharsets.UTF_8));
}
private static Cursor decodeCursor(String continuation) {
String json = new String(Base64.getDecoder().decode(continuation), StandardCharsets.UTF_8);
JsonObject object = JsonParser.parseString(json).getAsJsonObject();
return new Cursor(
object.get("root").getAsString(),
object.get("recursive").getAsBoolean(),
object.get("offset").getAsInt()
);
}
private static List<String> listDirectChildren(CuratorFramework active, String root) throws Exception {
List<String> paths = new ArrayList<>();
List<String> children = new ArrayList<>(active.getChildren().forPath(root));
Collections.sort(children);
for (String child : children) {
paths.add(childPath(root, child));
}
return paths;
}
private static List<String> listRecursive(CuratorFramework active, String root) throws Exception {
List<String> result = new ArrayList<>();
collectRecursive(active, root, result);
return result;
}
private static List<Map<String, Object>> listRowsWithMetadata(CuratorFramework active, List<String> paths) throws Exception {
if (paths.isEmpty()) {
return Collections.emptyList();
}
try {
List<Future<Map<String, Object>>> futures = new ArrayList<>();
for (String path : paths) {
futures.add(STAT_LOOKUP_EXECUTOR.submit(() -> rowWithMetadata(active, path)));
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Future<Map<String, Object>> future : futures) {
Map<String, Object> row = future.get();
if (row != null) {
rows.add(row);
}
}
return rows;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof Exception exception) {
throw exception;
}
if (cause instanceof Error error) {
throw error;
}
throw new RuntimeException(cause);
}
}
private static Map<String, Object> rowWithMetadata(CuratorFramework active, String path) throws Exception {
try {
Stat stat = active.checkExists().forPath(path);
if (stat == null) {
return null;
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("key", path);
row.putAll(statMetadata(stat));
return row;
} catch (KeeperException.NoNodeException e) {
return null;
}
}
private static void collectRecursive(CuratorFramework active, String path, List<String> result) throws Exception {
List<String> children;
try {
children = new ArrayList<>(active.getChildren().forPath(path));
} catch (KeeperException.NoNodeException e) {
return;
}
Collections.sort(children);
for (String child : children) {
String childPath = childPath(path, child);
result.add(childPath);
collectRecursive(active, childPath, result);
}
}
private static int countSubtree(CuratorFramework active, String path) throws Exception {
try {
if (active.checkExists().forPath(path) == null) {
return 0;
}
int count = 1;
for (String child : active.getChildren().forPath(path)) {
count += countSubtree(active, childPath(path, child));
}
return count;
} catch (KeeperException.NoNodeException e) {
return 0;
}
}
private static boolean hasTlsOptions(JsonObject connection) {
return boolOrDefault(connection, "ssl", false)
|| firstNonBlank(
stringOrNull(connection, "ca_cert_path"),
stringOrNull(connection, "client_cert_path"),
stringOrNull(connection, "client_key_path"),
stringOrNull(connection, "cert_path"),
stringOrNull(connection, "key_path")
) != null;
}
static String authScheme(JsonObject connection) {
String configured = connectionOption(connection, "auth_scheme");
return configured == null ? DEFAULT_AUTH_SCHEME : configured.toLowerCase(Locale.ROOT);
}
private static String connectionOption(JsonObject connection, String key) {
String direct = stringOrNull(connection, key);
if (direct != null && !direct.isBlank()) {
return direct.trim();
}
String params = stringOrEmpty(connection, "url_params").trim();
if (params.startsWith("?")) {
params = params.substring(1);
}
for (String part : params.split("[&;]")) {
int separator = part.indexOf('=');
if (separator <= 0) {
continue;
}
String candidateKey = URLDecoder.decode(part.substring(0, separator), StandardCharsets.UTF_8);
if (key.equalsIgnoreCase(candidateKey.trim())) {
String value = URLDecoder.decode(part.substring(separator + 1), StandardCharsets.UTF_8).trim();
return value.isEmpty() ? null : value;
}
}
return null;
}
private static SaslCredentials saslCredentials(JsonObject connection) {
String username = stringOrEmpty(connection, "username").trim();
String password = stringOrEmpty(connection, "password");
if (username.isEmpty()) {
throw new IllegalArgumentException("username is required when auth_scheme = \"sasl_digest\"");
}
if (password.isEmpty()) {
throw new IllegalArgumentException("password is required when auth_scheme = \"sasl_digest\"");
}
return new SaslCredentials(username, password);
}
private static SaslLease acquireSaslLease(JsonObject connection) {
SaslCredentials credentials = saslCredentials(connection);
synchronized (JAAS_LOCK) {
if (jaasRegistry == null) {
jaasRegistry = new JaasRegistryConfiguration(Configuration.getConfiguration());
Configuration.setConfiguration(jaasRegistry);
}
String contextName = SASL_LOGIN_CONTEXT_PREFIX + SASL_CONTEXT_SEQUENCE.getAndIncrement();
jaasRegistry.add(contextName, credentials);
return new SaslLease(contextName);
}
}
private static void releaseSaslLease(String contextName) {
synchronized (JAAS_LOCK) {
if (jaasRegistry == null) {
return;
}
jaasRegistry.remove(contextName);
if (jaasRegistry.isEmpty()) {
Configuration base = jaasRegistry.base;
jaasRegistry = null;
Configuration.setConfiguration(base);
}
}
}
private static String trimSlashes(String value) {
String trimmed = value.trim();
while (trimmed.startsWith("/")) {
trimmed = trimmed.substring(1);
}
while (trimmed.endsWith("/")) {
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
return trimmed;
}
private static String stringOrNull(JsonObject object, String key) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
private static String stringOrEmpty(JsonObject object, String key) {
return stringOrDefault(object, key, "");
}
private static String stringOrDefault(JsonObject object, String key, String fallback) {
String value = stringOrNull(object, key);
return value == null ? fallback : value;
}
private static int intOrDefault(JsonObject object, String key, int fallback) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? fallback : element.getAsInt();
}
private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) {
JsonElement element = object.get(key);
return element == null || element.isJsonNull() ? fallback : element.getAsBoolean();
}
static int configuredStatLookupConcurrency(String propertyValue, String envValue) {
String configured = firstNonBlank(propertyValue, envValue);
if (configured == null) {
return DEFAULT_STAT_LOOKUP_CONCURRENCY;
}
try {
int parsed = Integer.parseInt(configured.trim());
return Math.max(MIN_STAT_LOOKUP_CONCURRENCY, Math.min(MAX_STAT_LOOKUP_CONCURRENCY, parsed));
} catch (NumberFormatException e) {
return DEFAULT_STAT_LOOKUP_CONCURRENCY;
}
}
private static int configuredStatLookupConcurrency() {
return configuredStatLookupConcurrency(
System.getProperty(STAT_LOOKUP_CONCURRENCY_PROPERTY),
System.getenv(STAT_LOOKUP_CONCURRENCY_ENV)
);
}
private static ThreadFactory daemonThreadFactory(String prefix) {
AtomicInteger sequence = new AtomicInteger(1);
return runnable -> {
Thread thread = new Thread(runnable, prefix + sequence.getAndIncrement());
thread.setDaemon(true);
return thread;
};
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
public static void main(String[] args) throws Exception {
// Entry point for the standalone DBX agent process.
System.setProperty("org.slf4j.simpleLogger.logFile", "System.err");
System.out.println("{\"ready\":true}");
System.out.flush();
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
String line = reader.readLine();
if (line == null) {
break;
}
System.out.println(handleRequest(line));
System.out.flush();
}
}
private static final class SaslCredentials {
private final String username;
private final String password;
private SaslCredentials(String username, String password) {
this.username = username;
this.password = password;
}
}
private static final class JaasRegistryConfiguration extends Configuration {
private final Configuration base;
private final Map<String, AppConfigurationEntry[]> entries = new HashMap<>();
private JaasRegistryConfiguration(Configuration base) {
this.base = base;
}
private void add(String contextName, SaslCredentials credentials) {
Map<String, Object> options = new HashMap<>();
options.put("username", credentials.username);
options.put("password", credentials.password);
entries.put(contextName, new AppConfigurationEntry[] {
new AppConfigurationEntry(
"org.apache.zookeeper.server.auth.DigestLoginModule",
AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
Collections.unmodifiableMap(options)
)
});
}
private void remove(String contextName) {
entries.remove(contextName);
}
private boolean isEmpty() {
return entries.isEmpty();
}
@Override
public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
synchronized (JAAS_LOCK) {
AppConfigurationEntry[] configured = entries.get(name);
if (configured != null) {
return configured.clone();
}
}
return base == null ? null : base.getAppConfigurationEntry(name);
}
@Override
public void refresh() {
if (base != null) {
base.refresh();
}
}
}
private static final class SaslLease implements AutoCloseable {
private final String contextName;
private boolean closed;
private SaslLease(String contextName) {
this.contextName = contextName;
}
@Override
public void close() {
if (!closed) {
releaseSaslLease(contextName);
closed = true;
}
}
}
private static final class Cursor {
private final String root;
private final boolean recursive;
private final int offset;
private Cursor(String root, boolean recursive, int offset) {
this.root = root;
this.recursive = recursive;
this.offset = offset;
}
}
private static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;
private final List<String> capabilities;
private HandshakeResult(int protocolVersion, int agentProtocolVersion, List<String> capabilities) {
this.protocolVersion = protocolVersion;
this.agentProtocolVersion = agentProtocolVersion;
this.capabilities = capabilities;
}
}
}
@@ -1,924 +0,0 @@
package com.dbx.agent.zookeeper;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.test.InstanceSpec;
import org.apache.curator.test.TestingServer;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.ZooDefs;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
final class ZooKeeperAgentTest {
@AfterEach
void disconnect() {
ZooKeeperAgent.handleRequest("{\"jsonrpc\":\"2.0\",\"id\":999,\"method\":\"disconnect\",\"params\":{}}");
}
@Test
void handshakeAdvertisesKvCapability() {
String response = ZooKeeperAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}"
);
JsonObject result = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result");
Assertions.assertEquals(1, result.get("protocolVersion").getAsInt());
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"connect\"")));
Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"test_connection\"")));
}
@Test
void kvMethodDispatchReturnsJsonRpcErrorWhenDisconnected() {
String response = ZooKeeperAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"kv_get\",\"params\":{\"key\":\"/app/name\"}}"
);
JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error");
Assertions.assertEquals(-1, error.get("code").getAsInt());
Assertions.assertEquals("Not connected", error.get("message").getAsString());
}
@Test
void unknownMethodReturnsJsonRpcError() {
String response = ZooKeeperAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"missing_method\",\"params\":{}}"
);
JsonObject error = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("error");
Assertions.assertEquals(-1, error.get("code").getAsInt());
Assertions.assertEquals("Unknown method: missing_method", error.get("message").getAsString());
}
@Test
void connectStringUsesConfiguredValuesAndHostPortFallback() {
Assertions.assertEquals(
"zk-1:2181,zk-2:2181",
ZooKeeperAgent.connectString(
JsonParser.parseString("{\"connect_string\":\"zk-1:2181,zk-2:2181\"}").getAsJsonObject()
)
);
Assertions.assertEquals(
"zk-main:2181",
ZooKeeperAgent.connectString(
JsonParser.parseString("{\"zookeeper_connect_string\":\"zk-main:2181\"}").getAsJsonObject()
)
);
Assertions.assertEquals(
"127.0.0.1:2181",
ZooKeeperAgent.connectString(JsonParser.parseString("{}").getAsJsonObject())
);
Assertions.assertEquals(
"zk.local:2281",
ZooKeeperAgent.connectString(JsonParser.parseString("{\"host\":\"zk.local\",\"port\":2281}").getAsJsonObject())
);
}
@Test
void buildClientRejectsTlsOptionsUntilTlsIsSupported() {
JsonObject connection = JsonParser.parseString(
"{\"ssl\":true,\"ca_cert_path\":\"ca.pem\",\"client_cert_path\":\"client.pem\",\"client_key_path\":\"client.key\"}"
).getAsJsonObject();
IllegalArgumentException error = Assertions.assertThrows(
IllegalArgumentException.class,
() -> ZooKeeperAgent.buildClient(connection)
);
Assertions.assertEquals("ZooKeeper TLS is not supported", error.getMessage());
}
@Test
void buildClientRejectsUnsupportedAuthSchemeBeforeNetworkProbe() {
JsonObject connection = JsonParser.parseString(
"{\"auth_scheme\":\"world\",\"connect_string\":\"127.0.0.1:1\"}"
).getAsJsonObject();
IllegalArgumentException error = Assertions.assertThrows(
IllegalArgumentException.class,
() -> ZooKeeperAgent.buildClient(connection)
);
Assertions.assertTrue(error.getMessage().contains("world"), error.getMessage());
Assertions.assertFalse(error.getMessage().contains("No reachable"), error.getMessage());
}
@Test
void saslDigestRequiresUsernameAndPasswordBeforeNetworkProbe() {
JsonObject missingUsername = JsonParser.parseString(
"{\"auth_scheme\":\"sasl_digest\",\"password\":\"secret\",\"connect_string\":\"127.0.0.1:1\"}"
).getAsJsonObject();
JsonObject missingPassword = JsonParser.parseString(
"{\"auth_scheme\":\"sasl_digest\",\"username\":\"dbx\",\"connect_string\":\"127.0.0.1:1\"}"
).getAsJsonObject();
IllegalArgumentException usernameError = Assertions.assertThrows(
IllegalArgumentException.class,
() -> ZooKeeperAgent.buildClient(missingUsername)
);
IllegalArgumentException passwordError = Assertions.assertThrows(
IllegalArgumentException.class,
() -> ZooKeeperAgent.buildClient(missingPassword)
);
Assertions.assertTrue(usernameError.getMessage().contains("username"), usernameError.getMessage());
Assertions.assertTrue(passwordError.getMessage().contains("password"), passwordError.getMessage());
}
@Test
void rejectsGenericSaslSchemeBeforeNetworkProbe() {
JsonObject connection = JsonParser.parseString(
"{\"auth_scheme\":\"sasl\",\"username\":\"dbx\",\"password\":\"secret\",\"connect_string\":\"127.0.0.1:1\"}"
).getAsJsonObject();
IllegalArgumentException error = Assertions.assertThrows(
IllegalArgumentException.class,
() -> ZooKeeperAgent.buildClient(connection)
);
Assertions.assertTrue(error.getMessage().contains("sasl_digest"), error.getMessage());
Assertions.assertFalse(error.getMessage().contains("No reachable"), error.getMessage());
}
@Test
void connectsToServerThatRequiresSaslDigestAuthenticationAndRestoresJaasConfiguration() throws Exception {
javax.security.auth.login.Configuration previousConfiguration =
javax.security.auth.login.Configuration.getConfiguration();
String previousRequiredSasl = System.getProperty("zookeeper.sessionRequireClientSASLAuth");
String previousServerConfig = System.getProperty("zookeeper.sasl.serverconfig");
String previousLoginConfig = System.getProperty("java.security.auth.login.config");
java.nio.file.Path jaas = java.nio.file.Files.createTempFile("dbx-zookeeper-server", ".conf");
java.nio.file.Files.writeString(jaas, """
Server {
org.apache.zookeeper.server.auth.DigestLoginModule required
user_dbx="secret";
};
""");
try {
System.setProperty("java.security.auth.login.config", jaas.toString());
System.setProperty("zookeeper.sasl.serverconfig", "Server");
System.setProperty("zookeeper.sessionRequireClientSASLAuth", "true");
javax.security.auth.login.Configuration.getConfiguration().refresh();
InstanceSpec spec = new InstanceSpec(
null,
-1,
-1,
-1,
true,
-1,
-1,
-1,
Map.of("authProvider.1", "org.apache.zookeeper.server.auth.SASLAuthenticationProvider")
);
try (TestingServer server = new TestingServer(spec, true)) {
String connection = "{\"connection\":{\"url_params\":\"auth_scheme=sasl_digest\","
+ "\"username\":\"dbx\",\"password\":\"secret\","
+ "\"connect_string\":\"" + server.getConnectString() + "\"}}";
JsonObject connect = result(request(1, "connect", connection));
Assertions.assertNotNull(connect, "SASL connect returned an RPC error");
Assertions.assertTrue(connect.get("ok").getAsBoolean());
JsonObject probe = result(request(2, "test_connection", connection));
Assertions.assertNotNull(probe, "concurrent SASL test_connection returned an RPC error");
Assertions.assertTrue(probe.get("ok").getAsBoolean());
JsonObject root = result(request(3, "kv_get", "{\"key\":\"/\"}"));
Assertions.assertTrue(root.get("found").getAsBoolean(), "active SASL client must remain usable");
JsonObject disconnect = result(request(4, "disconnect", "{}"));
Assertions.assertTrue(disconnect.get("ok").getAsBoolean());
String wrongConnection = "{\"connection\":{\"url_params\":\"auth_scheme=sasl_digest\","
+ "\"username\":\"dbx\",\"password\":\"wrong\","
+ "\"connect_string\":\"" + server.getConnectString() + "\"}}";
JsonObject authError = error(request(5, "test_connection", wrongConnection));
Assertions.assertTrue(
authError.get("message").getAsString().toLowerCase(java.util.Locale.ROOT).contains("auth"),
authError.toString()
);
}
Assertions.assertSame(
previousConfiguration,
javax.security.auth.login.Configuration.getConfiguration(),
"client SASL configuration must be restored after authentication"
);
} finally {
javax.security.auth.login.Configuration.setConfiguration(previousConfiguration);
restoreSystemProperty("zookeeper.sessionRequireClientSASLAuth", previousRequiredSasl);
restoreSystemProperty("zookeeper.sasl.serverconfig", previousServerConfig);
restoreSystemProperty("java.security.auth.login.config", previousLoginConfig);
javax.security.auth.login.Configuration.getConfiguration().refresh();
java.nio.file.Files.deleteIfExists(jaas);
}
}
@Test
void normalizePathStandardizesPublicPaths() {
Assertions.assertEquals("/", ZooKeeperAgent.normalizePath(""));
Assertions.assertEquals("/", ZooKeeperAgent.normalizePath("/"));
Assertions.assertEquals("/app", ZooKeeperAgent.normalizePath("app"));
Assertions.assertEquals("/app", ZooKeeperAgent.normalizePath("/app/"));
Assertions.assertEquals("/app/name", ZooKeeperAgent.normalizePath("/app/name"));
}
@Test
void statLookupConcurrencyUsesConservativeDefaultsAndOverrides() {
Assertions.assertEquals(16, ZooKeeperAgent.configuredStatLookupConcurrency(null, null));
Assertions.assertEquals(20, ZooKeeperAgent.configuredStatLookupConcurrency("20", "12"));
Assertions.assertEquals(12, ZooKeeperAgent.configuredStatLookupConcurrency(null, "12"));
Assertions.assertEquals(1, ZooKeeperAgent.configuredStatLookupConcurrency("0", null));
Assertions.assertEquals(64, ZooKeeperAgent.configuredStatLookupConcurrency("128", null));
Assertions.assertEquals(16, ZooKeeperAgent.configuredStatLookupConcurrency("not-a-number", null));
}
@Test
void connectAndTestConnectionWorkAgainstTestingServer() throws Exception {
try (TestingServer server = new TestingServer()) {
JsonObject connect = result(request(
1,
"connect",
"{\"connection\":{\"connect_string\":\"" + server.getConnectString() + "\"}}"
));
Assertions.assertTrue(connect.get("ok").getAsBoolean());
JsonObject test = result(request(
2,
"test_connection",
"{\"connection\":{\"connect_string\":\"" + server.getConnectString() + "\"}}"
));
Assertions.assertTrue(test.get("ok").getAsBoolean());
}
}
@Test
void defaultTimeoutAllowsSessionEstablishmentAfterFiveSeconds() throws Exception {
try (TestingServer server = new TestingServer();
DelayedTcpProxy proxy = new DelayedTcpProxy(server.getConnectString(), 5500)) {
long startedAt = System.nanoTime();
JsonObject connect = result(request(
1,
"connect",
"{\"connection\":{\"connect_string\":\"" + proxy.getConnectString() + "\"}}"
));
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
Assertions.assertTrue(connect.get("ok").getAsBoolean());
Assertions.assertTrue(elapsedMs >= 5000, "expected delayed handshake, took " + elapsedMs + "ms");
}
}
@Test
void connectFailsFastWhenAllServersAreUnreachable() {
long startedAt = System.nanoTime();
JsonObject error = error(request(
1,
"connect",
"{\"connection\":{\"connect_string\":\"127.0.0.1:1\",\"connection_timeout_ms\":15000}}"
));
long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000;
Assertions.assertTrue(
error.get("message").getAsString().contains("No reachable ZooKeeper server"),
"expected fast unreachable error, got: " + error.get("message").getAsString()
);
Assertions.assertTrue(elapsedMs < 4000, "expected fast failure, took " + elapsedMs + "ms");
}
@Test
void connectUsesReachableServerWithoutDroppingEnsembleMembers() throws Exception {
try (TestingServer server = new TestingServer()) {
JsonObject connect = result(request(
1,
"connect",
"{\"connection\":{\"connect_string\":\"127.0.0.1:1," + server.getConnectString() + "\"}}"
));
Assertions.assertTrue(connect.get("ok").getAsBoolean());
JsonObject get = result(request(2, "kv_get", "{\"key\":\"/\"}"));
Assertions.assertTrue(get.get("found").getAsBoolean());
}
}
@Test
void reachableConnectStringPreservesFullEnsembleAndChroot() throws Exception {
try (TestingServer server = new TestingServer()) {
String alive = server.getConnectString();
String ensemble = "127.0.0.1:1," + alive + "/app";
Assertions.assertEquals(
ensemble,
ZooKeeperAgent.reachableConnectString(ensemble, 1000)
);
Assertions.assertEquals(alive, ZooKeeperAgent.reachableConnectString(alive, 1000));
IllegalStateException error = Assertions.assertThrows(
IllegalStateException.class,
() -> ZooKeeperAgent.reachableConnectString("127.0.0.1:1/app", 1000)
);
Assertions.assertTrue(error.getMessage().contains("No reachable ZooKeeper server"));
}
}
@Test
void testConnectionDoesNotReplaceActiveClient() throws Exception {
try (TestingServer active = new TestingServer(); TestingServer probe = new TestingServer()) {
result(request(1, "connect", "{\"connection\":{\"connect_string\":\"" + active.getConnectString() + "\"}}"));
result(request(
2,
"kv_put",
"{\"key\":\"/active\",\"value\":{\"encoding\":\"utf8\",\"data\":\"still-here\"}}"
));
result(request(3, "test_connection", "{\"connection\":{\"connect_string\":\"" + probe.getConnectString() + "\"}}"));
JsonObject get = result(request(4, "kv_get", "{\"key\":\"/active\"}"));
Assertions.assertTrue(get.get("found").getAsBoolean());
Assertions.assertEquals("still-here", get.getAsJsonObject("value").get("data").getAsString());
}
}
@Test
void kvPutCreatesMissingParentsAndGetReadsUtf8Value() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject put = result(request(
2,
"kv_put",
"{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"
));
Assertions.assertTrue(put.has("version"));
JsonObject get = result(request(3, "kv_get", "{\"key\":\"/app/name\"}"));
Assertions.assertTrue(get.get("found").getAsBoolean());
Assertions.assertEquals("utf8", get.getAsJsonObject("value").get("encoding").getAsString());
Assertions.assertEquals("dbx", get.getAsJsonObject("value").get("data").getAsString());
JsonObject metadata = get.getAsJsonObject("metadata");
Assertions.assertEquals(0, metadata.get("numChildren").getAsInt());
Assertions.assertEquals(metadata.get("czxid").getAsLong(), metadata.get("createRevision").getAsLong());
Assertions.assertEquals(metadata.get("mzxid").getAsLong(), metadata.get("modRevision").getAsLong());
Assertions.assertEquals(metadata.get("dataLength").getAsInt(), metadata.get("valueSize").getAsInt());
}
}
@Test
void kvGetReadsZnodeCreatedWithoutDataAsEmptyUtf8() throws Exception {
try (TestingServer server = new TestingServer();
CuratorFramework curator = CuratorFrameworkFactory.newClient(
server.getConnectString(),
new ExponentialBackoffRetry(100, 3)
)) {
curator.start();
curator.getZookeeperClient().getZooKeeper().create(
"/empty",
null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT
);
connect(server);
JsonObject get = result(request(3, "kv_get", "{\"key\":\"/empty\"}"));
Assertions.assertTrue(get.get("found").getAsBoolean());
Assertions.assertEquals("utf8", get.getAsJsonObject("value").get("encoding").getAsString());
Assertions.assertEquals("", get.getAsJsonObject("value").get("data").getAsString());
Assertions.assertEquals(0, get.getAsJsonObject("metadata").get("valueSize").getAsInt());
}
}
@Test
void kvPutUpdatesExistingZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
result(request(
3,
"kv_put",
"{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx-updated\"}}"
));
JsonObject value = result(request(4, "kv_get", "{\"key\":\"/app/name\"}")).getAsJsonObject("value");
Assertions.assertEquals("dbx-updated", value.get("data").getAsString());
}
}
@Test
void kvPutCreatePersistentCreatesOnlyMissingZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject created = result(request(
2,
"kv_put",
"{\"key\":\"/app/created\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"},"
+ "\"writeMode\":\"create\",\"createMode\":\"persistent\"}"
));
JsonObject get = result(request(3, "kv_get", "{\"key\":\"/app/created\"}"));
JsonObject duplicate = error(request(
4,
"kv_put",
"{\"key\":\"/app/created\",\"value\":{\"encoding\":\"utf8\",\"data\":\"again\"},"
+ "\"writeMode\":\"create\",\"createMode\":\"persistent\"}"
));
Assertions.assertEquals("/app/created", requiredString(created, "key"));
Assertions.assertEquals("/app/created", requiredString(created, "createdKey"));
Assertions.assertEquals("dbx", get.getAsJsonObject("value").get("data").getAsString());
Assertions.assertTrue(duplicate.get("message").getAsString().contains("NodeExists"));
}
}
@Test
void kvPutCreateEphemeralCreatesEphemeralZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject created = result(request(
2,
"kv_put",
"{\"key\":\"/session/node\",\"value\":{\"encoding\":\"utf8\",\"data\":\"temporary\"},"
+ "\"writeMode\":\"create\",\"createMode\":\"ephemeral\"}"
));
JsonObject get = result(request(3, "kv_get", "{\"key\":\"/session/node\"}"));
Assertions.assertEquals("/session/node", requiredString(created, "createdKey"));
Assertions.assertEquals("temporary", get.getAsJsonObject("value").get("data").getAsString());
Assertions.assertTrue(get.getAsJsonObject("metadata").get("ephemeralOwner").getAsLong() > 0);
}
}
@Test
void kvPutCreatePersistentSequentialReturnsGeneratedPath() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject created = result(request(
2,
"kv_put",
"{\"key\":\"/jobs/job-\",\"value\":{\"encoding\":\"utf8\",\"data\":\"one\"},"
+ "\"writeMode\":\"create\",\"createMode\":\"persistent_sequential\"}"
));
String createdKey = requiredString(created, "createdKey");
JsonObject get = result(request(3, "kv_get", "{\"key\":\"" + createdKey + "\"}"));
Assertions.assertEquals(createdKey, requiredString(created, "key"));
Assertions.assertTrue(createdKey.startsWith("/jobs/job-"));
Assertions.assertTrue(createdKey.length() > "/jobs/job-".length());
Assertions.assertEquals("one", get.getAsJsonObject("value").get("data").getAsString());
Assertions.assertEquals(0, get.getAsJsonObject("metadata").get("ephemeralOwner").getAsLong());
}
}
@Test
void kvPutCreateEphemeralSequentialReturnsGeneratedEphemeralPath() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject created = result(request(
2,
"kv_put",
"{\"key\":\"/sessions/member-\",\"value\":{\"encoding\":\"utf8\",\"data\":\"online\"},"
+ "\"writeMode\":\"create\",\"createMode\":\"ephemeral_sequential\"}"
));
String createdKey = requiredString(created, "createdKey");
JsonObject get = result(request(3, "kv_get", "{\"key\":\"" + createdKey + "\"}"));
Assertions.assertEquals(createdKey, requiredString(created, "key"));
Assertions.assertTrue(createdKey.startsWith("/sessions/member-"));
Assertions.assertEquals("online", get.getAsJsonObject("value").get("data").getAsString());
Assertions.assertTrue(get.getAsJsonObject("metadata").get("ephemeralOwner").getAsLong() > 0);
}
}
@Test
void kvPutUpdateOnlyUpdatesExistingZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject missing = error(request(
2,
"kv_put",
"{\"key\":\"/app/missing\",\"value\":{\"encoding\":\"utf8\",\"data\":\"x\"},\"writeMode\":\"update\"}"
));
result(request(
3,
"kv_put",
"{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"before\"},\"writeMode\":\"create\"}"
));
JsonObject updated = result(request(
4,
"kv_put",
"{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"after\"},\"writeMode\":\"update\"}"
));
JsonObject value = result(request(5, "kv_get", "{\"key\":\"/app/name\"}")).getAsJsonObject("value");
Assertions.assertTrue(missing.get("message").getAsString().contains("NoNode"));
Assertions.assertTrue(updated.get("version").getAsInt() > 0);
Assertions.assertEquals("after", value.get("data").getAsString());
}
}
@Test
void kvGetReturnsBase64ForBinaryData() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/bin\",\"value\":{\"encoding\":\"base64\",\"data\":\"/wAB\"}}"));
JsonObject value = result(request(3, "kv_get", "{\"key\":\"/bin\"}")).getAsJsonObject("value");
Assertions.assertEquals("base64", value.get("encoding").getAsString());
Assertions.assertEquals("/wAB", value.get("data").getAsString());
}
}
@Test
void kvPutRejectsRootZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject error = error(request(
2,
"kv_put",
"{\"key\":\"/\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"
));
Assertions.assertEquals("Root znode cannot be modified", error.get("message").getAsString());
}
}
@Test
void kvDeleteReturnsZeroForMissingZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject delete = result(request(4, "kv_delete", "{\"key\":\"/missing\"}"));
Assertions.assertEquals(0, delete.get("deleted").getAsInt());
}
}
@Test
void kvDeleteRemovesLeafZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/leaf\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
JsonObject delete = result(request(3, "kv_delete", "{\"key\":\"/leaf\"}"));
JsonObject get = result(request(4, "kv_get", "{\"key\":\"/leaf\"}"));
Assertions.assertEquals(1, delete.get("deleted").getAsInt());
Assertions.assertFalse(get.get("found").getAsBoolean());
}
}
@Test
void kvDeleteRejectsNonRecursiveDeleteOfNonEmptyZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
JsonObject error = error(request(3, "kv_delete", "{\"key\":\"/app\"}"));
Assertions.assertTrue(error.get("message").getAsString().contains("not empty"));
}
}
@Test
void kvDeleteRecursiveDeletesFullSubtreeAndCountsZnodes() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
result(request(3, "kv_put", "{\"key\":\"/app/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}"));
JsonObject delete = result(request(4, "kv_delete", "{\"key\":\"/app\",\"recursive\":true}"));
JsonObject get = result(request(5, "kv_get", "{\"key\":\"/app/name\"}"));
Assertions.assertEquals(3, delete.get("deleted").getAsInt());
Assertions.assertFalse(get.get("found").getAsBoolean());
}
}
@Test
void kvDeleteRejectsRootZnode() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject error = error(request(2, "kv_delete", "{\"key\":\"/\",\"recursive\":true}"));
Assertions.assertEquals("Root znode cannot be deleted", error.get("message").getAsString());
}
}
@Test
void listPrefixListsDirectChildrenOfRootWhenRecursiveIsFalse() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}"));
JsonObject list = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\",\"recursive\":false}"));
Assertions.assertEquals(List.of("/app", "/config", "/zookeeper"), listedKeys(list));
Assertions.assertTrue(list.get("continuation").isJsonNull());
}
}
@Test
void listPrefixKeepsDirectChildMetadataForLazyExpansion() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}"));
JsonObject list = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\",\"recursive\":false}"));
JsonObject app = listedRow(list, "/app");
JsonObject config = listedRow(list, "/config");
Assertions.assertEquals(1, app.get("numChildren").getAsInt());
Assertions.assertEquals(0, config.get("numChildren").getAsInt());
Assertions.assertEquals(3, config.get("dataLength").getAsInt());
}
}
@Test
void listPrefixDefaultsToRecursiveForDbxKvBrowser() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}"));
JsonObject list = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\"}"));
Assertions.assertEquals(
List.of("/app", "/app/name", "/config", "/zookeeper", "/zookeeper/config", "/zookeeper/quota"),
listedKeys(list)
);
Assertions.assertTrue(list.get("continuation").isJsonNull());
}
}
@Test
void listPrefixListsRecursiveDescendantsOfRoot() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/name\",\"value\":{\"encoding\":\"utf8\",\"data\":\"dbx\"}}"));
result(request(3, "kv_put", "{\"key\":\"/config\",\"value\":{\"encoding\":\"utf8\",\"data\":\"cfg\"}}"));
JsonObject list = result(request(6, "kv_list_prefix", "{\"prefix\":\"/\",\"recursive\":true}"));
Assertions.assertEquals(
List.of("/app", "/app/name", "/config", "/zookeeper", "/zookeeper/config", "/zookeeper/quota"),
listedKeys(list)
);
}
}
@Test
void listPrefixReturnsEmptyResultForMissingPrefix() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
JsonObject list = result(request(7, "kv_list_prefix", "{\"prefix\":\"/missing\"}"));
Assertions.assertEquals(List.of(), listedKeys(list));
Assertions.assertTrue(list.get("continuation").isJsonNull());
}
}
@Test
void listPrefixPaginatesBySortedPath() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/c\",\"value\":{\"encoding\":\"utf8\",\"data\":\"c\"}}"));
result(request(3, "kv_put", "{\"key\":\"/a\",\"value\":{\"encoding\":\"utf8\",\"data\":\"a\"}}"));
result(request(4, "kv_put", "{\"key\":\"/b\",\"value\":{\"encoding\":\"utf8\",\"data\":\"b\"}}"));
JsonObject first = result(request(5, "kv_list_prefix", "{\"prefix\":\"/\",\"limit\":2}"));
String continuation = first.get("continuation").getAsString();
JsonObject second = result(request(
6,
"kv_list_prefix",
"{\"prefix\":\"/\",\"limit\":2,\"continuation\":\"" + continuation + "\"}"
));
String secondContinuation = second.get("continuation").getAsString();
JsonObject third = result(request(
7,
"kv_list_prefix",
"{\"prefix\":\"/\",\"limit\":2,\"continuation\":\"" + secondContinuation + "\"}"
));
Assertions.assertEquals(List.of("/a", "/b"), listedKeys(first));
Assertions.assertFalse(continuation.isBlank());
Assertions.assertEquals(List.of("/c", "/zookeeper"), listedKeys(second));
Assertions.assertFalse(secondContinuation.isBlank());
Assertions.assertEquals(List.of("/zookeeper/config", "/zookeeper/quota"), listedKeys(third));
Assertions.assertTrue(third.get("continuation").isJsonNull());
}
}
@Test
void listPrefixPaginatesDirectChildrenWhenRecursiveIsFalse() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/c/deep\",\"value\":{\"encoding\":\"utf8\",\"data\":\"c\"}}"));
result(request(3, "kv_put", "{\"key\":\"/app/a/deep\",\"value\":{\"encoding\":\"utf8\",\"data\":\"a\"}}"));
result(request(4, "kv_put", "{\"key\":\"/app/b/deep\",\"value\":{\"encoding\":\"utf8\",\"data\":\"b\"}}"));
JsonObject first = result(request(5, "kv_list_prefix", "{\"prefix\":\"/app\",\"recursive\":false,\"limit\":2}"));
String continuation = first.get("continuation").getAsString();
JsonObject second = result(request(
6,
"kv_list_prefix",
"{\"prefix\":\"/app\",\"recursive\":false,\"limit\":2,\"continuation\":\"" + continuation + "\"}"
));
Assertions.assertEquals(List.of("/app/a", "/app/b"), listedKeys(first));
Assertions.assertFalse(continuation.isBlank());
Assertions.assertEquals(List.of("/app/c"), listedKeys(second));
Assertions.assertTrue(second.get("continuation").isJsonNull());
}
}
@Test
void listPrefixRejectsContinuationForDifferentRequest() throws Exception {
try (TestingServer server = new TestingServer()) {
connect(server);
result(request(2, "kv_put", "{\"key\":\"/app/a\",\"value\":{\"encoding\":\"utf8\",\"data\":\"a\"}}"));
result(request(3, "kv_put", "{\"key\":\"/app/b\",\"value\":{\"encoding\":\"utf8\",\"data\":\"b\"}}"));
JsonObject first = result(request(4, "kv_list_prefix", "{\"prefix\":\"/app\",\"limit\":1}"));
String continuation = first.get("continuation").getAsString();
JsonObject error = error(request(
5,
"kv_list_prefix",
"{\"prefix\":\"/\",\"limit\":1,\"continuation\":\"" + continuation + "\"}"
));
Assertions.assertEquals("Continuation does not match request", error.get("message").getAsString());
}
}
private static JsonObject connect(TestingServer server) {
return result(request(1, "connect", "{\"connection\":{\"connect_string\":\"" + server.getConnectString() + "\"}}"));
}
private static String request(int id, String method, String params) {
return ZooKeeperAgent.handleRequest(
"{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}"
);
}
private static JsonObject result(String response) {
return JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result");
}
private static JsonObject error(String response) {
JsonObject object = JsonParser.parseString(response).getAsJsonObject();
Assertions.assertTrue(object.has("error"), "expected JSON-RPC error but got " + response);
return object.getAsJsonObject("error");
}
private static String requiredString(JsonObject object, String key) {
Assertions.assertTrue(object.has(key), "expected result to contain " + key);
return object.get(key).getAsString();
}
private static void restoreSystemProperty(String key, String value) {
if (value == null) {
System.clearProperty(key);
} else {
System.setProperty(key, value);
}
}
private static List<String> listedKeys(JsonObject listResult) {
List<String> keys = new ArrayList<>();
JsonArray rows = listResult.getAsJsonArray("keys");
for (JsonElement row : rows) {
keys.add(row.getAsJsonObject().get("key").getAsString());
}
return keys;
}
private static JsonObject listedRow(JsonObject listResult, String key) {
JsonArray rows = listResult.getAsJsonArray("keys");
for (JsonElement row : rows) {
JsonObject object = row.getAsJsonObject();
if (key.equals(object.get("key").getAsString())) {
return object;
}
}
Assertions.fail("expected listed row for " + key);
return new JsonObject();
}
private static final class DelayedTcpProxy implements AutoCloseable {
private final ServerSocket listener;
private final InetSocketAddress target;
private final long delayMs;
private final ExecutorService executor;
private final Set<Socket> sockets = ConcurrentHashMap.newKeySet();
private DelayedTcpProxy(String targetConnectString, long delayMs) throws IOException {
this.listener = new ServerSocket(0);
this.target = ZooKeeperAgent.parseEndpoint(targetConnectString);
this.delayMs = delayMs;
this.executor = Executors.newCachedThreadPool(task -> {
Thread thread = new Thread(task, "dbx-zookeeper-delayed-proxy");
thread.setDaemon(true);
return thread;
});
executor.submit(this::acceptConnections);
}
private String getConnectString() {
return "127.0.0.1:" + listener.getLocalPort();
}
private void acceptConnections() {
while (!listener.isClosed()) {
try {
Socket client = listener.accept();
sockets.add(client);
executor.submit(() -> forwardAfterDelay(client));
} catch (IOException error) {
if (!listener.isClosed()) {
throw new IllegalStateException("Delayed proxy failed to accept a connection", error);
}
}
}
}
private void forwardAfterDelay(Socket client) {
try {
Thread.sleep(delayMs);
Socket server = new Socket();
sockets.add(server);
server.connect(target);
executor.submit(() -> copyUntilClosed(client, server));
copyUntilClosed(server, client);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
closeSocket(client);
} catch (IOException error) {
closeSocket(client);
}
}
private void copyUntilClosed(Socket source, Socket destination) {
try {
source.getInputStream().transferTo(destination.getOutputStream());
} catch (IOException ignored) {
// Closing either side is expected when a probe or client disconnects.
} finally {
closeSocket(source);
closeSocket(destination);
}
}
private void closeSocket(Socket socket) {
sockets.remove(socket);
try {
socket.close();
} catch (IOException ignored) {
// Best effort cleanup for test sockets.
}
}
@Override
public void close() throws IOException {
listener.close();
for (Socket socket : sockets) {
closeSocket(socket);
}
executor.shutdownNow();
}
}
}
+7 -3
View File
@@ -374,15 +374,19 @@ func (m *DigestMD5Mechanism) step(challenge []byte) ([]byte, error) {
a2String := "AUTHENTICATE:" + digestUri
maxBuf := ""
if c["qop"] != AUTH {
if m.auth != AUTH {
a2String += ":00000000000000000000000000000000"
maxBuf = ",maxbuf=16777215"
}
// Set nonce count nc
nc := fmt.Sprintf("%08x", m.nonceCount)
charset := ""
if strings.EqualFold(c["charset"], "utf-8") {
charset = ",charset=utf-8"
}
// Create final response sent to server
resp := "qop=" + c["qop"] + ",realm=" + strconv.Quote(c["realm"]) + ",username=" + strconv.Quote(m.username) + ",nonce=" + strconv.Quote(m.nonce) +
",cnonce=" + strconv.Quote(m.cnonce) + ",nc=" + nc + ",digest-uri=" + strconv.Quote(digestUri) + ",response=" + m.getHash(digestUri, a2String, c) + maxBuf
resp := "qop=" + m.auth + ",realm=" + strconv.Quote(c["realm"]) + ",username=" + strconv.Quote(m.username) + ",nonce=" + strconv.Quote(m.nonce) +
",cnonce=" + strconv.Quote(m.cnonce) + ",nc=" + nc + ",digest-uri=" + strconv.Quote(digestUri) + ",response=" + m.getHash(digestUri, a2String, c) + charset + maxBuf
return []byte(resp), nil
}
+30
View File
@@ -153,3 +153,33 @@ func TestDigestMD5RejectsUnsupportedQOP(t *testing.T) {
t.Fatalf("expected unsupported QOP error, got %v", err)
}
}
func TestDigestMD5EchoesUTF8Charset(t *testing.T) {
mechanism := NewDigestMD5Mechanism("zookeeper", "user", "password")
client := NewSaslClient("zk-sasl-md5", mechanism)
if _, err := client.Start(); err != nil {
t.Fatal(err)
}
response, err := client.Step([]byte(`realm="zk-sasl-md5",nonce="nonce",qop="auth",charset=utf-8,algorithm=md5-sess`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(response), "charset=utf-8") {
t.Fatalf("response does not echo UTF-8 charset: %s", response)
}
}
func TestDigestMD5DefaultsMissingQOPToAuth(t *testing.T) {
mechanism := NewDigestMD5Mechanism("zookeeper", "user", "password")
client := NewSaslClient("zk-sasl-md5", mechanism)
if _, err := client.Start(); err != nil {
t.Fatal(err)
}
response, err := client.Step([]byte(`realm="zk-sasl-md5",nonce="nonce",charset=utf-8,algorithm=md5-sess`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(response), "qop=auth") || strings.Contains(string(response), "maxbuf=") {
t.Fatalf("response does not use the DIGEST-MD5 default qop=auth: %s", response)
}
}
@@ -43,6 +43,7 @@ class DriverReleasePackagesTest(unittest.TestCase):
"duckdb": "0.1.0",
"rabbitmq": "0.1.0",
"rocketmq": "0.1.0",
"zookeeper": "0.1.0",
"cassandra": "0.1.37",
"hive": "0.1.43",
"tdengine": "0.1.0",
@@ -299,6 +300,21 @@ class DriverReleasePackagesTest(unittest.TestCase):
self.assertFalse(source.exists())
self.assertEqual(versioned.read_bytes(), b"MZtest-hive-agent")
def test_versions_zookeeper_native_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = Path(temp_dir)
source = release_dir / "dbx-agent-zookeeper-linux-aarch64"
source.write_bytes(b"\x7fELFtest-zookeeper-agent")
versions = {driver: "0.1.0" for driver in NATIVE_DRIVERS}
versions["zookeeper"] = "0.1.8"
renamed = version_agent_artifacts(release_dir, versions)
versioned = release_dir / "dbx-agent-zookeeper-0.1.8-linux-aarch64"
self.assertEqual(renamed, [versioned])
self.assertFalse(source.exists())
self.assertEqual(versioned.read_bytes(), b"\x7fELFtest-zookeeper-agent")
def test_full_offline_bundle_includes_supported_windows_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
release_dir = Path(temp_dir)
+1
View File
@@ -25,6 +25,7 @@ NATIVE_ONLY_AGENT_MODULES = {
"xugu": "drivers/xugu",
"rabbitmq": "drivers/rabbitmq",
"rocketmq": "drivers/rocketmq",
"zookeeper": "drivers/zookeeper",
}
AUTO_VERSIONED_NATIVE_MODULES = {"duckdb"}
JDBC_ARCHITECTURE_ALLOWLIST = {
+2 -1
View File
@@ -148,11 +148,12 @@ class ValidateAgentsTest(unittest.TestCase):
"duckdb",
"rabbitmq",
"rocketmq",
"zookeeper",
"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", "rocketmq": "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", "zookeeper": "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", "rocketmq", "tdengine")
NATIVE_DRIVERS = ("cassandra", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "duckdb", "rabbitmq", "rocketmq", "zookeeper", "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', 'sqlserver-legacy'
'iris', 'etcd', 'kafka', 'sqlserver-legacy'
]
include(*(infrastructureModules + driverModules))