From 72dcd8355ab338638dd3b5e7cb03cb3dd121a0d9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 2 Sep 2026 18:03:11 +0800 Subject: [PATCH] feat(etcd): rewrite agent in Go and add v2 API support --- .github/scripts/bump-agent-versions.mjs | 7 +- .../scripts/reuse-agent-release-assets.mjs | 2 +- .github/workflows/agents-release.yml | 88 +- .gitignore | 3 + agents/README.md | 11 +- agents/docs/agent-protocol-v2.md | 2 +- agents/drivers/etcd-go/MIGRATION_PARITY.md | 53 + agents/drivers/etcd-go/auth.go | 261 +++ agents/drivers/etcd-go/client.go | 321 +++ agents/drivers/etcd-go/go.mod | 34 + agents/drivers/etcd-go/go.sum | 64 + agents/drivers/etcd-go/history.go | 255 +++ agents/drivers/etcd-go/integration_test.go | 515 +++++ agents/drivers/etcd-go/kv.go | 524 +++++ agents/drivers/etcd-go/lease.go | 339 ++++ agents/drivers/etcd-go/main.go | 468 +++++ agents/drivers/etcd-go/main_test.go | 502 +++++ agents/drivers/etcd-go/maintenance.go | 166 ++ agents/drivers/etcd-go/protocol_error.go | 143 ++ agents/drivers/etcd-go/status.go | 148 ++ agents/drivers/etcd-go/watch.go | 366 ++++ agents/drivers/etcd/build.gradle | 11 - .../java/com/dbx/agent/etcd/EtcdAgent.java | 1796 ----------------- .../com/dbx/agent/etcd/EtcdAgentTest.java | 475 ----- agents/drivers/etcd2-go/README.md | 70 + agents/drivers/etcd2-go/auth.go | 390 ++++ agents/drivers/etcd2-go/client.go | 457 +++++ agents/drivers/etcd2-go/go.mod | 3 + agents/drivers/etcd2-go/integration_test.go | 412 ++++ agents/drivers/etcd2-go/kv.go | 524 +++++ agents/drivers/etcd2-go/main.go | 422 ++++ agents/drivers/etcd2-go/main_test.go | 267 +++ agents/drivers/etcd2-go/protocol_error.go | 135 ++ agents/drivers/etcd2-go/status.go | 179 ++ agents/drivers/etcd2-go/watch.go | 395 ++++ agents/go-common/go-semver/go.mod | 3 + agents/go-common/go-semver/semver.go | 296 +++ agents/go-common/go-semver/sort.go | 38 + .../scripts/driver_release_packages_test.py | 12 + agents/scripts/validate_agents.py | 4 +- agents/scripts/version_agent_artifacts.py | 2 +- agents/settings.gradle | 2 +- agents/versions.json | 1 + .../connection/ConnectionDialog.vue | 17 + .../src/components/etcd/EtcdKeyBrowser.vue | 16 +- .../src/components/icons/DatabaseIcon.vue | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../connection/etcdConnectionDialog.spec.ts | 35 + .../src/types/generated/connectionProfiles.ts | 2 + .../assets/database-drivers.manifest.json | 9 + crates/dbx-core/src/agent_catalog.rs | 12 + crates/dbx-core/src/agent_kv.rs | 85 +- deploy/database/etcd/3.5/compose.yaml | 32 + deploy/database/etcd/3.5/recipe.json | 23 + plugins/connection-types/etcd.yaml | 6 + .../connection-types/profiles/catalog.yaml | 6 + 63 files changed, 8085 insertions(+), 2333 deletions(-) create mode 100644 agents/drivers/etcd-go/MIGRATION_PARITY.md create mode 100644 agents/drivers/etcd-go/auth.go create mode 100644 agents/drivers/etcd-go/client.go create mode 100644 agents/drivers/etcd-go/go.mod create mode 100644 agents/drivers/etcd-go/go.sum create mode 100644 agents/drivers/etcd-go/history.go create mode 100644 agents/drivers/etcd-go/integration_test.go create mode 100644 agents/drivers/etcd-go/kv.go create mode 100644 agents/drivers/etcd-go/lease.go create mode 100644 agents/drivers/etcd-go/main.go create mode 100644 agents/drivers/etcd-go/main_test.go create mode 100644 agents/drivers/etcd-go/maintenance.go create mode 100644 agents/drivers/etcd-go/protocol_error.go create mode 100644 agents/drivers/etcd-go/status.go create mode 100644 agents/drivers/etcd-go/watch.go delete mode 100644 agents/drivers/etcd/build.gradle delete mode 100644 agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java delete mode 100644 agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java create mode 100644 agents/drivers/etcd2-go/README.md create mode 100644 agents/drivers/etcd2-go/auth.go create mode 100644 agents/drivers/etcd2-go/client.go create mode 100644 agents/drivers/etcd2-go/go.mod create mode 100644 agents/drivers/etcd2-go/integration_test.go create mode 100644 agents/drivers/etcd2-go/kv.go create mode 100644 agents/drivers/etcd2-go/main.go create mode 100644 agents/drivers/etcd2-go/main_test.go create mode 100644 agents/drivers/etcd2-go/protocol_error.go create mode 100644 agents/drivers/etcd2-go/status.go create mode 100644 agents/drivers/etcd2-go/watch.go create mode 100644 agents/go-common/go-semver/go.mod create mode 100644 agents/go-common/go-semver/semver.go create mode 100644 agents/go-common/go-semver/sort.go create mode 100644 apps/desktop/src/lib/__tests__/connection/etcdConnectionDialog.spec.ts create mode 100644 deploy/database/etcd/3.5/compose.yaml create mode 100644 deploy/database/etcd/3.5/recipe.json diff --git a/.github/scripts/bump-agent-versions.mjs b/.github/scripts/bump-agent-versions.mjs index 1779488551..0cc6420d1c 100644 --- a/.github/scripts/bump-agent-versions.mjs +++ b/.github/scripts/bump-agent-versions.mjs @@ -61,11 +61,13 @@ const nativeDriverDirectories = { rocketmq: "rocketmq", zookeeper: "zookeeper", tdengine: "tdengine", + etcd: "etcd-go", + etcd2: "etcd2-go", }; const crateNativeDriverDirectories = { "sqlite-worker": "crates/dbx-sqlite-worker", }; -const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "sqlite-worker"]); +const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]); const nativeDriverSharedPaths = { hive: [ "agents/go-common/go-gssapi", @@ -76,6 +78,9 @@ const nativeDriverSharedPaths = { "agents/go-common/go-gssapi", "agents/go-common/gosasl", ], + etcd: [ + "agents/go-common/go-semver", + ], }; function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) { diff --git a/.github/scripts/reuse-agent-release-assets.mjs b/.github/scripts/reuse-agent-release-assets.mjs index 61f9159e24..110c042261 100644 --- a/.github/scripts/reuse-agent-release-assets.mjs +++ b/.github/scripts/reuse-agent-release-assets.mjs @@ -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", "zookeeper", "tdengine"]); +const NATIVE_MODULES = new Set(["duckdb", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2"]); const PLATFORMS = [ "macos-aarch64", "macos-x64", diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml index 590d66f51f..67a0b9067f 100644 --- a/.github/workflows/agents-release.yml +++ b/.github/workflows/agents-release.yml @@ -347,6 +347,86 @@ jobs: name: rocketmq-native path: "release-native/dbx-agent-rocketmq-*" + build-etcd-native: + needs: [bump-versions] + if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'etcd') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26.x" + - name: Test etcd native agent + working-directory: agents/drivers/etcd-go + run: go test ./... + - name: Cross-compile etcd native agent + shell: bash + run: | + mkdir -p release-native + cd agents/drivers/etcd-go + 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-etcd-${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: etcd-native + path: "release-native/dbx-agent-etcd-*" + + build-etcd2-native: + needs: [bump-versions] + if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'etcd2') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.24.x" + - name: Test etcd v2 native agent + working-directory: agents/drivers/etcd2-go + run: go test ./... + - name: Cross-compile etcd v2 native agent + shell: bash + run: | + mkdir -p release-native + cd agents/drivers/etcd2-go + 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-etcd2-${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: etcd2-native + path: "release-native/dbx-agent-etcd2-*" + build-zookeeper-native: needs: [bump-versions] if: ${{ contains(fromJSON(needs.bump-versions.outputs.native_modules), 'zookeeper') }} @@ -1137,7 +1217,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-zookeeper-native, build-cassandra-native, build-hive-native, build-kingbase-native, build-vastbase-native, build-neo4j-native, build-iotdb-native, build-duckdb-native, build-sqlite-worker-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-etcd-native, build-etcd2-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-sqlite-worker-native, build-tdengine-native, build-jre, reuse-previous-assets] if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} runs-on: ubuntu-latest steps: @@ -1272,6 +1352,8 @@ jobs: neo4j) echo "Neo4j" ;; iotdb) echo "Apache IoTDB" ;; tdengine) echo "TDengine" ;; + etcd) echo "etcd" ;; + etcd2) echo "etcd 2.x (v2 API)" ;; *) echo "$name" ;; esac } @@ -1335,7 +1417,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 sqlite-worker rabbitmq rocketmq zookeeper cassandra hive tdengine; do + for name in oracle xugu kingbase vastbase neo4j iotdb duckdb sqlite-worker rabbitmq rocketmq zookeeper cassandra hive tdengine etcd etcd2; do version=$(get_module_version "$name") [ -f "release/dbx-agent-${name}-${version}.jar" ] && continue native_json=$(generate_native_platforms "$name" "$version") @@ -1426,6 +1508,8 @@ jobs: neo4j) echo "Neo4j" ;; iotdb) echo "Apache IoTDB" ;; tdengine) echo "TDengine" ;; + etcd) echo "etcd" ;; + etcd2) echo "etcd 2.x (v2 API)" ;; *) echo "$name" ;; esac } diff --git a/.gitignore b/.gitignore index bdc5c2b75f..29987aa919 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules .trellis/ .worktrees/ openspec/ +.zcode/ .mcp.json AGENTS.md CLAUDE.md @@ -23,6 +24,8 @@ plugins/jdbc/target/ plugins/jdbc/dependency-reduced-pom.xml plugins/jdbc/lib/*.jar agents/drivers/duckdb/target/ +agents/drivers/etcd-go/etcd-go +agents/drivers/etcd2-go/etcd2-go plugins/jdbc/.gradle/ plugins/jdbc/build/ plugins/jdbc/dist/ diff --git a/agents/README.md b/agents/README.md index 6c9d8326a7..e422f9964f 100644 --- a/agents/README.md +++ b/agents/README.md @@ -45,7 +45,8 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo | yashandb | 崖山 YashanDB | YashanDB JDBC | | xugu | 虚谷 XuguDB | XuguDB Go native agent | | iotdb | Apache IoTDB | Apache IoTDB Go Client native agent | -| etcd | etcd | jetcd | +| etcd | etcd 3.3 degraded, 3.4+ full (v3 API) | etcd client/v3 native Go agent | +| etcd2 | etcd 2.0–2.3, 3.0–3.5 with --enable-v2 (v2 API) | etcd v2 HTTP/JSON native Go agent | | zookeeper | Apache ZooKeeper | go-zookeeper native agent | | rabbitmq | RabbitMQ | amqp091-go native agent | | rocketmq | Apache RocketMQ | rocketmq-admin-go native agent | @@ -53,7 +54,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo ## Multi-JRE Support -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. +Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, `zookeeper`, `etcd`, and `etcd2`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents. ## JDBC Connection Pooling @@ -79,7 +80,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), `drivers/rocketmq` (rocketmq-admin-go), and `drivers/zookeeper` (go-zookeeper) 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), `drivers/etcd-go` (etcd client/v3), and `drivers/etcd2-go` (etcd v2 HTTP API) 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`. @@ -103,7 +104,7 @@ Requires JDK 21 (Gradle toolchain auto-downloads if needed). (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`, `drivers/rocketmq`, and `drivers/zookeeper`. +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`, `drivers/zookeeper`, `drivers/etcd-go`, and `drivers/etcd2-go`. ### Local DBX Runtime Test @@ -117,7 +118,7 @@ cp agents/drivers//build/libs/*-all.jar ~/.dbx/agents/drivers/ 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`, `rocketmq`, and `zookeeper` 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`, `zookeeper`, `etcd`, and `etcd2` use an `agent` executable instead of `agent.jar`. TDengine builds `target/release/dbx-tdengine-driver` from `drivers/tdengine/Cargo.toml`. ## Versioning diff --git a/agents/docs/agent-protocol-v2.md b/agents/docs/agent-protocol-v2.md index 65029bfc66..d022130444 100644 --- a/agents/docs/agent-protocol-v2.md +++ b/agents/docs/agent-protocol-v2.md @@ -37,7 +37,7 @@ DBX uses unique short-lived logical sessions for independent Agent metadata task Runtime reuse keys include the Agent driver key, executable or JAR path, launch arguments, working directory, JRE selection, JVM options, classpath-affecting options, and native executable version boundary. Host, account, schema, and credentials belong to sessions and are not part of the runtime key. -Etcd and ZooKeeper retain the legacy path because they use the key-value Agent protocol rather than the SQL session contract. Older Agent binaries and JARs also remain on the legacy path. +ZooKeeper retains the legacy single-session path because its Agent does not advertise the `multi_session` capability; etcd Agents (v3 and v2) run on the shared multi-session path like the SQL Agents. Older Agent binaries and JARs also remain on the legacy path. ## Resource limits and recovery diff --git a/agents/drivers/etcd-go/MIGRATION_PARITY.md b/agents/drivers/etcd-go/MIGRATION_PARITY.md new file mode 100644 index 0000000000..f50a6aa8a0 --- /dev/null +++ b/agents/drivers/etcd-go/MIGRATION_PARITY.md @@ -0,0 +1,53 @@ +# etcd Java to Go migration parity + +Status date: 2026-09-02. + +The Java `EtcdAgent` (jetcd 0.8.x, 33 protocol methods) was replaced by this +native Go agent. The migration is complete only where a capability is +implemented, covered by automated tests, validated against a real etcd cluster, +and wired into the native build and release path. + +## Baseline + +- DBX Java baseline: jetcd 0.8.x `EtcdAgent`; all protocol shapes, capability + strings, and error signal texts were copied verbatim. +- Go client: `go.etcd.io/etcd/client/v3` v3.7.1 over gRPC. +- Capabilities (exact Java set, no `structured_error_v1`): `connect`, + `test_connection`, `kv`, `kv_ttl`, `kv_cas`, `kv_list_values`, `kv_status`, + `kv_history`, `etcd_compaction`, `etcd_defrag`, `etcd_watch`, `etcd_lease`, + `etcd_auth`, `multi_session`. +- Error signal strings (`ETCD_CAS_CONFLICT`, `ETCD_NOT_FOUND`, + `ETCD_COMPACTED`, `ETCD_WATCH_LIMIT`, …) are matched verbatim because the + host normalizes agent errors by message text + (`agent_kv.rs::normalize_agent_kv_error`). +- The shared `go-semver` module is vendored under `agents/go-common/go-semver` + because the upstream `v0.3.1` tag declares a broken module path. + +## Current matrix + +| Capability | Go code | Automated | Live server | Status | +| --- | --- | --- | --- | --- | +| connect / endpoints / TLS / basic auth | yes | yes | etcd 3.7.0, 3.5.21 | parity passed (auth enabled) | +| test_connection probe (PERMISSION_DENIED → limited) | yes | yes | yes | parity passed | +| kv list/get/put/delete/rename with CAS | yes | yes | yes | parity passed | +| TTL + preserveLease (3-attempt retry) | yes | yes | yes | parity passed | +| Lease list/get/grant/keepalive/revoke | yes | yes | yes | parity passed | +| LeaseLeases fallback on 3.3 (UNIMPLEMENTED/timeout → partial) | yes | yes | n/a (3.3 fixture not retained) | coded + unit tested | +| LeaseGrant with custom ID (raw etcdserverpb RPC) | yes | yes | yes | replaces Java reflection wire handling | +| kv_history (temporary watch, prevKV, progress notify) | yes | yes | yes | requires `clientv3.WithCreatedNotify()`; jetcd parity | +| kv_history compacted/future-revision errors | yes | yes | yes | parity passed | +| Watch start/poll/stop with full budget system | yes | yes | yes | 256 batches / 10k events / 8MiB per watch, 16MiB session aggregate | +| Status: members, alarms, latency fan-out, member dedup | yes | yes | yes | parity passed | +| Status raft fields as unsigned decimal strings | yes | yes | yes | bit-level parity with Java `longString(int64(uint64))` | +| Compaction (pre-validation, error propagation) | yes | yes | yes | Java check order preserved (params before connect) | +| Defrag (followers first, leader last, per-endpoint errors) | yes | yes | yes | parity passed | +| Auth users/roles (14 methods) | yes | yes | yes | parity passed | +| Multi-session protocol runtime (256 sessions, cancel_session) | yes | yes | yes | protocol flow live test passed | +| etcd 3.3 degraded mode | partial | partial | no | lease/history fallbacks coded; no retained 3.3 fixture | +| etcd 2.x | n/a | n/a | n/a | served by the dedicated `etcd2` agent, not this one | + +## Known divergences + +- None intentional. All observable protocol outputs are byte-for-byte + compatible with the Java agent; the raw protobuf RPCs used for lease + operations are an internal implementation change only. diff --git a/agents/drivers/etcd-go/auth.go b/agents/drivers/etcd-go/auth.go new file mode 100644 index 0000000000..ba557a9a59 --- /dev/null +++ b/agents/drivers/etcd-go/auth.go @@ -0,0 +1,261 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + clientv3 "go.etcd.io/etcd/client/v3" +) + +func (s *etcdSession) authUserList(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + response, err := client.Auth.UserList(ctx) + if err != nil { + return nil, err + } + return map[string]any{"users": response.Users}, nil +} + +func (s *etcdSession) authUserGet(params map[string]json.RawMessage) (any, error) { + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + response, err := client.Auth.UserGet(ctx, user) + if err != nil { + return nil, err + } + return map[string]any{"user": user, "roles": response.Roles}, nil +} + +func (s *etcdSession) authUserAdd(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + password, err := requiredString(params, "password") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, err := client.Auth.UserAdd(ctx, user, password); err != nil { + return nil, err + } + return map[string]bool{"created": true}, nil +} + +func (s *etcdSession) authUserDelete(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, err := client.Auth.UserDelete(ctx, user); err != nil { + return nil, err + } + return map[string]bool{"deleted": true}, nil +} + +func (s *etcdSession) authUserChangePassword(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + password, err := requiredString(params, "password") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, err := client.Auth.UserChangePassword(ctx, user, password); err != nil { + return nil, err + } + return map[string]bool{"changed": true}, nil +} + +func (s *etcdSession) authUserGrantRevokeRole(params map[string]json.RawMessage, grant bool) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if grant { + _, err = client.Auth.UserGrantRole(ctx, user, role) + } else { + _, err = client.Auth.UserRevokeRole(ctx, user, role) + } + if err != nil { + return nil, err + } + return map[string]bool{"updated": true}, nil +} + +func (s *etcdSession) authRoleList(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + response, err := client.Auth.RoleList(ctx) + if err != nil { + return nil, err + } + return map[string]any{"roles": response.Roles}, nil +} + +func (s *etcdSession) authRoleGet(params map[string]json.RawMessage) (any, error) { + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + response, err := client.Auth.RoleGet(ctx, role) + if err != nil { + return nil, err + } + permissions := []map[string]any{} + for _, permission := range response.Perm { + key := []byte(permission.Key) + rangeEnd := []byte(permission.RangeEnd) + resource := "prefix" + if len(key) == 1 && key[0] == 0 && len(rangeEnd) == 1 && rangeEnd[0] == 0 { + resource = "all" + } else if len(rangeEnd) == 0 { + resource = "key" + } + permissions = append(permissions, map[string]any{ + "access": strings.ToLower(permission.PermType.String()), + "key": bytesObject(key), + "rangeEnd": bytesObject(rangeEnd), + "resource": resource, + }) + } + return map[string]any{"role": role, "permissions": permissions}, nil +} + +func (s *etcdSession) authRoleAdd(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, err := client.Auth.RoleAdd(ctx, role); err != nil { + return nil, err + } + return map[string]bool{"created": true}, nil +} + +func (s *etcdSession) authRoleDelete(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, err := client.Auth.RoleDelete(ctx, role); err != nil { + return nil, err + } + return map[string]bool{"deleted": true}, nil +} + +func (s *etcdSession) authRolePermission(params map[string]json.RawMessage, grant bool) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + resource := stringOrDefault(params, "resource", "key") + all := resource == "all" + prefix := resource == "prefix" + var key string + var rangeEnd string + if all { + key = "\x00" + rangeEnd = "\x00" + } else { + key, err = keyBytesParam(params) + if err != nil { + return nil, err + } + if prefix { + rangeEnd = prefixEnd(key) + } + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if grant { + access, err := permissionType(stringOrDefault(params, "access", "")) + if err != nil { + return nil, err + } + if _, err := client.Auth.RoleGrantPermission(ctx, role, key, rangeEnd, access); err != nil { + return nil, err + } + } else { + if _, err := client.Auth.RoleRevokePermission(ctx, role, key, rangeEnd); err != nil { + return nil, err + } + } + return map[string]bool{"updated": true}, nil +} + +func permissionType(access string) (clientv3.PermissionType, error) { + permission, err := clientv3.StrToPermissionType(strings.ToUpper(access)) + if err != nil { + return 0, fmt.Errorf("ETCD_INVALID_ACCESS: access must be READ, WRITE, or READWRITE, got %s", access) + } + return permission, nil +} diff --git a/agents/drivers/etcd-go/client.go b/agents/drivers/etcd-go/client.go new file mode 100644 index 0000000000..92061a1e63 --- /dev/null +++ b/agents/drivers/etcd-go/client.go @@ -0,0 +1,321 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + defaultGrpcMaxInboundMessageSize = 32 * 1024 * 1024 + minGrpcMaxInboundMessageSize = 1024 * 1024 + maxGrpcMaxInboundMessageSize = 256 * 1024 * 1024 + grpcMaxInboundMessageSizeKey = "grpc_max_inbound_message_size" + defaultPort = 2379 +) + +// connectionParams mirrors the host-side etcd connection configuration. +type connectionParams struct { + EtcdEndpoints string `json:"etcd_endpoints"` + Endpoints string `json:"endpoints"` + ConnectionString string `json:"connection_string"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + 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"` + ConnectTimeoutSecs int `json:"connect_timeout_secs"` + GrpcMaxInboundMessageSize int `json:"grpc_max_inbound_message_size"` + URLParams string `json:"url_params"` +} + +func connectionObject(params map[string]json.RawMessage) (connectionParams, error) { + var connection connectionParams + if raw, ok := params["connection"]; ok && raw != nil { + if err := json.Unmarshal(raw, &connection); err != nil { + return connection, err + } + return connection, nil + } + if err := decodeParams(params, &connection); err != nil { + return connection, err + } + return connection, nil +} + +func (s *etcdSession) connectMap(params map[string]json.RawMessage) (any, error) { + if _, err := s.connect(params); err != nil { + return nil, err + } + return map[string]bool{"ok": true}, nil +} + +func (s *etcdSession) connect(params map[string]json.RawMessage) (any, error) { + connection, err := connectionObject(params) + if err != nil { + return nil, err + } + nextClient, err := buildClient(connection) + if err != nil { + return nil, err + } + endpointList := connectionEndpoints(connection) + if _, err := probeClient(nextClient, endpointList); err != nil { + _ = nextClient.Close() + return nil, err + } + s.close() + s.clientMu.Lock() + s.client = nextClient + s.connectedEndpoints = endpointList + s.clientMu.Unlock() + return map[string]bool{"ok": true}, nil +} + +func (s *etcdSession) activeClient() (*clientv3.Client, error) { + s.clientMu.Lock() + client := s.client + s.clientMu.Unlock() + if client == nil { + return nil, errors.New("Not connected") + } + return client, nil +} + +func (s *etcdSession) connectedEndpointList() []string { + s.clientMu.Lock() + defer s.clientMu.Unlock() + return append([]string(nil), s.connectedEndpoints...) +} + +func (s *etcdSession) validateConnection() (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + return probeClient(client, s.connectedEndpointList()) +} + +func (s *etcdSession) close() error { + s.watchesMu.Lock() + watches := s.watches + s.watches = map[string]*watchState{} + s.knownLeases = map[uint64]struct{}{} + s.watchBufferedBytes = 0 + s.watchesMu.Unlock() + for _, watch := range watches { + watch.close() + } + s.clientMu.Lock() + client := s.client + s.client = nil + s.connectedEndpoints = nil + s.clientMu.Unlock() + if client != nil { + _ = client.Close() + } + return nil +} + +func buildClient(connection connectionParams) (*clientv3.Client, error) { + endpoints := connectionEndpoints(connection) + config := clientv3.Config{ + Endpoints: endpoints, + DialTimeout: time.Duration(connectTimeoutSeconds(connection)) * time.Second, + } + if size := grpcMaxInboundMessageSize(connection); size > 0 { + config.MaxCallSendMsgSize = size + config.MaxCallRecvMsgSize = size + } + if strings.TrimSpace(connection.Username) != "" { + config.Username = connection.Username + config.Password = connection.Password + } + if connection.SSL { + tlsConfig, err := tlsConfigFor(connection) + if err != nil { + return nil, err + } + config.TLS = tlsConfig + } + return clientv3.New(config) +} + +func connectTimeoutSeconds(connection connectionParams) int { + seconds := connection.ConnectTimeoutSecs + if seconds == 0 { + seconds = rpcTimeoutSeconds + } + if seconds < 1 { + seconds = 1 + } + if seconds > 300 { + seconds = 300 + } + return seconds +} + +func grpcMaxInboundMessageSize(connection connectionParams) int { + configured := connection.GrpcMaxInboundMessageSize + if configured == 0 { + configured = intURLParamOrDefault(connection.URLParams, grpcMaxInboundMessageSizeKey, defaultGrpcMaxInboundMessageSize) + } + if configured < minGrpcMaxInboundMessageSize { + configured = minGrpcMaxInboundMessageSize + } + if configured > maxGrpcMaxInboundMessageSize { + configured = maxGrpcMaxInboundMessageSize + } + return configured +} + +func intURLParamOrDefault(params, key string, fallback int) int { + if strings.TrimSpace(params) == "" { + return fallback + } + for _, entry := range strings.Split(strings.TrimPrefix(params, "?"), "&") { + separator := strings.Index(entry, "=") + entryKey := entry + if separator >= 0 { + entryKey = entry[:separator] + } + if entryKey != key { + continue + } + if separator < 0 { + return fallback + } + value, err := strconv.Atoi(entry[separator+1:]) + if err != nil { + return fallback + } + return value + } + return fallback +} + +func tlsConfigFor(connection connectionParams) (*tls.Config, error) { + tlsConfig := &tls.Config{} + if ca := strings.TrimSpace(connection.CACertPath); ca != "" { + authorityPEM, err := os.ReadFile(ca) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(authorityPEM) { + return nil, fmt.Errorf("failed to parse CA certificate at %s", ca) + } + tlsConfig.RootCAs = pool + } + certPath := firstNonBlank(connection.ClientCertPath, connection.CertPath) + keyPath := firstNonBlank(connection.ClientKeyPath, connection.KeyPath) + if (certPath == "") != (keyPath == "") { + return nil, errors.New("Client certificate and key must be provided together") + } + if certPath != "" { + pair, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{pair} + } + return tlsConfig, nil +} + +func probeClient(client *clientv3.Client, endpoints []string) (map[string]any, error) { + var lastFailure error + for _, endpoint := range endpoints { + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + _, err := client.Maintenance.Status(ctx, endpoint) + cancel() + if err == nil { + return map[string]any{"ok": true, "endpoint": endpoint}, nil + } + // A restricted etcd user may not be allowed to call Maintenance.Status. + // PERMISSION_DENIED still proves that the channel reached an etcd server. + if status.Code(err) == codes.PermissionDenied { + return map[string]any{"ok": true, "endpoint": endpoint, "limited": true}, nil + } + lastFailure = err + } + if lastFailure != nil { + return nil, lastFailure + } + return nil, errors.New("No etcd endpoint configured") +} + +func connectionEndpoints(connection connectionParams) []string { + configured := firstNonBlank(connection.EtcdEndpoints, connection.Endpoints, connection.ConnectionString) + var result []string + if configured != "" { + for _, endpoint := range strings.FieldsFunc(configured, func(r rune) bool { return r == ',' || r == '\n' }) { + normalized := normalizeEndpoint(strings.TrimSpace(endpoint), connection.SSL) + if normalized != "" { + result = append(result, normalized) + } + } + } + if len(result) == 0 { + host := connection.Host + if host == "" { + host = "127.0.0.1" + } + port := connection.Port + if port == 0 { + port = defaultPort + } + result = append(result, normalizeEndpoint(fmt.Sprintf("%s:%d", host, port), connection.SSL)) + } + return result +} + +func normalizeEndpoint(endpoint string, tlsEnabled bool) string { + if strings.TrimSpace(endpoint) == "" { + return "" + } + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + return endpoint + } + if strings.HasPrefix(endpoint, "unix://") || strings.HasPrefix(endpoint, "unixs://") { + return endpoint + } + scheme := "http" + if tlsEnabled { + scheme = "https" + } + return scheme + "://" + endpoint +} + +func endpointHost(rawEndpoint string) string { + parsed, err := url.Parse(rawEndpoint) + if err != nil { + return "" + } + return parsed.Hostname() +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/agents/drivers/etcd-go/go.mod b/agents/drivers/etcd-go/go.mod new file mode 100644 index 0000000000..978263d25e --- /dev/null +++ b/agents/drivers/etcd-go/go.mod @@ -0,0 +1,34 @@ +module github.com/t8y2/dbx/agents/drivers/etcd-go + +go 1.26 + +// The v0.3.1 tag of coreos/go-semver declares the root module without the +// /semver suffix, so the nested module path etcd requires no longer resolves +// upstream. Vendored under go-common from the v0.3.1 tag, package unchanged. +replace github.com/coreos/go-semver/semver => ../../go-common/go-semver + +// The pseudo-version recorded in etcd's go.mod predates second-precision +// timestamps and is rejected by modern Go; point at the corrected form. +replace github.com/modern-go/concurrent => github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd + +require ( + go.etcd.io/etcd/api/v3 v3.7.1 + go.etcd.io/etcd/client/v3 v3.7.1 + google.golang.org/grpc v1.82.1 +) + +require ( + github.com/coreos/go-semver/semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.7.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/agents/drivers/etcd-go/go.sum b/agents/drivers/etcd-go/go.sum new file mode 100644 index 0000000000..37e7ea4afb --- /dev/null +++ b/agents/drivers/etcd-go/go.sum @@ -0,0 +1,64 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.etcd.io/etcd/api/v3 v3.7.1 h1:KJG0/DcWGfe3Y1otDf/fsBf0TSSgpxZ5RO/L8SFt73E= +go.etcd.io/etcd/api/v3 v3.7.1/go.mod h1:8bXIpCMeV7E3/XL0Ix123ATn3dB+0V7d9zklHbB0m78= +go.etcd.io/etcd/client/pkg/v3 v3.7.1 h1:rKYsj3pRkR0eK3yjT3XOgrhqfmIfj9pzNgxjh7mfFv4= +go.etcd.io/etcd/client/pkg/v3 v3.7.1/go.mod h1:cnzZGIUzSfjEwLC6UBVsSXlEK1eepS/JUD7wE6PLRT0= +go.etcd.io/etcd/client/v3 v3.7.1 h1:0PEMMC0KuZmVIN+RAbdqfkZ45pYTgKVtmBEbRCvZFUg= +go.etcd.io/etcd/client/v3 v3.7.1/go.mod h1:ffNqALa8tRCYhYo1F9oR489y23K39Gz+BSR3ApAGYq0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agents/drivers/etcd-go/history.go b/agents/drivers/etcd-go/history.go new file mode 100644 index 0000000000..23810d3793 --- /dev/null +++ b/agents/drivers/etcd-go/history.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strconv" + "sync" + "time" + + "go.etcd.io/etcd/api/v3/mvccpb" + "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" + clientv3 "go.etcd.io/etcd/client/v3" +) + +const ( + historyDefaultRevisionWindow = int64(10000) + historyLimitMax = 500 +) + +type historyCollector struct { + mu sync.Mutex + rows []map[string]any + limit int + truncated bool +} + +func (c *historyCollector) append(row map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.rows) == c.limit { + c.rows = c.rows[1:] + c.truncated = true + } + c.rows = append(c.rows, row) +} + +func (s *etcdSession) history(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + limit := intOrDefault(params, "limit", 100) + if limit < 1 { + limit = 1 + } + if limit > historyLimitMax { + limit = historyLimitMax + } + requestedEnd := longOrNull(params, "endRevision") + + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + latestOptions := []clientv3.OpOption{} + if requestedEnd != nil && *requestedEnd > 0 { + latestOptions = append(latestOptions, clientv3.WithRev(*requestedEnd)) + } + latest, err := client.Get(ctx, key, latestOptions...) + cancel() + if err != nil { + if errors.Is(err, rpctypes.ErrCompacted) { + if revision, ok := compactedRevisionOf(client, key); ok { + return nil, errors.New("ETCD_COMPACTED: requested history was compacted at revision " + longString(revision)) + } + return nil, errors.New("ETCD_COMPACTED: requested history was compacted") + } + return nil, err + } + var endRevision int64 + if requestedEnd != nil { + endRevision = *requestedEnd + } else { + endRevision = latest.Header.Revision + } + var targetKeyRevision int64 + if len(latest.Kvs) == 0 { + targetKeyRevision = endRevision + } else { + targetKeyRevision = latest.Kvs[0].ModRevision + } + requestedStart := longOrNull(params, "startRevision") + startRevision := historyStartRevision(requestedStart, targetKeyRevision) + if startRevision > targetKeyRevision { + return map[string]any{ + "events": []any{}, + "observedRevision": longString(endRevision), + "truncated": false, + }, nil + } + + collector := &historyCollector{limit: limit, truncated: requestedStart == nil && startRevision > 1} + created := make(chan struct{}) + var createdOnce sync.Once + completed := make(chan struct{}) + var completedOnce sync.Once + var failure error + var failureMu sync.Mutex + + watchCtx, watchCancel := context.WithCancel(context.Background()) + channel := client.Watch(watchCtx, key, + clientv3.WithRev(startRevision), + clientv3.WithPrevKV(), + clientv3.WithProgressNotify(), + // clientv3 only posts the created response when this option is set; + // the created gate relies on it (jetcd withCreateNotify parity). + clientv3.WithCreatedNotify(), + ) + go func() { + for response := range channel { + if response.Err() != nil { + failureMu.Lock() + failure = response.Err() + failureMu.Unlock() + completedOnce.Do(func() { close(completed) }) + continue + } + if response.Canceled { + if response.CompactRevision > 0 { + err := errors.New("ETCD_COMPACTED: requested history was compacted at revision " + longString(response.CompactRevision)) + failureMu.Lock() + failure = err + failureMu.Unlock() + } + completedOnce.Do(func() { close(completed) }) + continue + } + createdOnce.Do(func() { close(created) }) + for _, event := range response.Events { + item := event.Kv + revision := item.ModRevision + if revision > endRevision { + continue + } + var previous *mvccpb.KeyValue + if event.PrevKv != nil { + previous = event.PrevKv + } + row := map[string]any{ + "eventType": eventType(event), + "revision": longString(revision), + } + if event.Type == mvccpb.DELETE { + row["value"] = nil + } else { + row["value"] = valueObject(item.Value) + } + if previous != nil && previous.Version > 0 { + row["previousValue"] = valueObject(previous.Value) + } else { + row["previousValue"] = nil + } + if event.Type == mvccpb.DELETE && previous != nil { + row["metadata"] = metadataMap(previous) + } else { + row["metadata"] = metadataMap(item) + } + collector.append(row) + if revision >= targetKeyRevision { + completedOnce.Do(func() { close(completed) }) + } + } + if response.IsProgressNotify() && response.Header.Revision >= endRevision { + completedOnce.Do(func() { close(completed) }) + } + } + completedOnce.Do(func() { close(completed) }) + }() + defer watchCancel() + + select { + case <-created: + case <-time.After(5 * time.Second): + return nil, errors.New("ETCD_HISTORY_TIMEOUT: watcher was not created") + } + // For an existing exact key, its latest mod revision is an explicit + // replay boundary. This avoids relying on progress notifications, + // which older etcd/jetcd combinations do not consistently emit. + progressCtx, progressCancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + _ = client.RequestProgress(progressCtx) + progressCancel() + select { + case <-completed: + case <-time.After(15 * time.Second): + return nil, errors.New("ETCD_HISTORY_TIMEOUT: history replay did not reach the requested revision") + } + + failureMu.Lock() + historyFailure := failure + failureMu.Unlock() + if historyFailure != nil { + if errors.Is(historyFailure, rpctypes.ErrCompacted) { + if revision, ok := compactedRevisionOf(client, key); ok { + return nil, errors.New("ETCD_COMPACTED: requested history was compacted at revision " + longString(revision)) + } + return nil, errors.New("ETCD_COMPACTED: requested history was compacted") + } + return nil, errors.New("ETCD_HISTORY_FAILED: " + historyFailure.Error()) + } + + collector.mu.Lock() + rows := make([]map[string]any, len(collector.rows)) + copy(rows, collector.rows) + truncated := collector.truncated + collector.mu.Unlock() + sort.SliceStable(rows, func(i, j int) bool { + a, _ := strconv.ParseInt(rows[i]["revision"].(string), 10, 64) + b, _ := strconv.ParseInt(rows[j]["revision"].(string), 10, 64) + return a > b + }) + serialized := make([]any, len(rows)) + for i, row := range rows { + serialized[i] = row + } + return map[string]any{ + "events": serialized, + "observedRevision": longString(endRevision), + "truncated": truncated, + }, nil +} + +func historyStartRevision(requestedStart *int64, targetRevision int64) int64 { + if requestedStart != nil { + if *requestedStart < 1 { + return 1 + } + return *requestedStart + } + start := targetRevision - historyDefaultRevisionWindow + 1 + if start < 1 { + return 1 + } + return start +} + +// compactedRevisionOf recovers the compaction boundary that gRPC errors do not +// carry: a watch from revision 1 is cancelled immediately with the compacted +// revision in its response. +func compactedRevisionOf(client *clientv3.Client, key string) (int64, bool) { + watchCtx, watchCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer watchCancel() + channel := client.Watch(watchCtx, key, clientv3.WithRev(1)) + select { + case response := <-channel: + if response.CompactRevision > 0 { + return response.CompactRevision, true + } + return 0, false + case <-watchCtx.Done(): + return 0, false + } +} diff --git a/agents/drivers/etcd-go/integration_test.go b/agents/drivers/etcd-go/integration_test.go new file mode 100644 index 0000000000..c0fe8de449 --- /dev/null +++ b/agents/drivers/etcd-go/integration_test.go @@ -0,0 +1,515 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// TestLiveEtcdAgent exercises the full agent surface against a real etcd +// server. Enable with DBX_ETCD_LIVE=1; configure via DBX_ETCD_ENDPOINTS, +// DBX_ETCD_USER, and DBX_ETCD_PASSWORD (defaults match the deploy recipes: +// root/123456 on 127.0.0.1:10700 for 3.7). +func TestLiveEtcdAgent(t *testing.T) { + if os.Getenv("DBX_ETCD_LIVE") != "1" { + t.Skip("set DBX_ETCD_LIVE=1 to run the live etcd agent test") + } + endpoints := envOrDefault("DBX_ETCD_ENDPOINTS", "http://127.0.0.1:10700") + user := envOrDefault("DBX_ETCD_USER", "root") + password := envOrDefault("DBX_ETCD_PASSWORD", "123456") + + state := newEtcdSession() + connectParams := map[string]json.RawMessage{ + "etcd_endpoints": json.RawMessage(`"` + endpoints + `"`), + "username": json.RawMessage(`"` + user + `"`), + "password": json.RawMessage(`"` + password + `"`), + } + if _, err := state.connect(connectParams); err != nil { + t.Fatalf("connect failed: %v", err) + } + defer state.close() + + if result, err := state.validateConnection(); err != nil { + t.Fatalf("validate_connection failed: %v", err) + } else { + probe := result.(map[string]any) + if probe["ok"] != true { + t.Fatalf("unexpected probe result: %#v", probe) + } + } + + prefix := fmt.Sprintf("dbx/live/%d/", time.Now().UnixNano()) + + // --- KV basics ----------------------------------------------------- + putResult, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("world"), + })) + if err != nil { + t.Fatalf("put failed: %v", err) + } + baseRevision := revisionOf(t, putResult) + + binaryEncoded := base64Encode([]byte{0x00, 0xff, 0x81}) + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "binary"), + "value": json.RawMessage(`{"encoding":"base64","data":"` + binaryEncoded + `"}`), + })); err != nil { + t.Fatalf("binary put failed: %v", err) + } + + fetched, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "hello")})) + if err != nil { + t.Fatalf("get failed: %v", err) + } + row := fetched.(map[string]any) + if row["found"] != true || row["key"] != prefix+"hello" { + t.Fatalf("unexpected get result: %#v", row) + } + value := row["value"].(map[string]any) + if value["encoding"] != "utf8" || value["data"] != "world" { + t.Fatalf("unexpected value: %#v", value) + } + metadata := row["metadata"].(map[string]any) + if metadata["createRevision"] != metadata["modRevision"] { + t.Fatalf("fresh key revisions should match: %#v", metadata) + } + + binaryRow, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "binary")})) + if err != nil { + t.Fatalf("binary get failed: %v", err) + } + binaryValue := binaryRow.(map[string]any)["value"].(map[string]any) + if binaryValue["encoding"] != "base64" || binaryValue["data"] != binaryEncoded { + t.Fatalf("binary round trip broken: %#v", binaryValue) + } + + // CAS conflict on stale revision. + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("v2"), + "expectedModRevision": json.RawMessage(`1`), + })); err == nil || err.Error() != "ETCD_CAS_CONFLICT: key changed after it was loaded" { + t.Fatalf("expected CAS conflict, got %v", err) + } + + // CAS success with the fresh revision. + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("v3"), + "expectedModRevision": json.RawMessage(metadata["modRevision"].(string)), + })); err != nil { + t.Fatalf("CAS put failed: %v", err) + } + + // TTL put, lease metadata, and preserveLease. + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "leased"), + "value": jsonValue("expiring"), + "ttl": json.RawMessage(`300`), + })); err != nil { + t.Fatalf("ttl put failed: %v", err) + } + leased, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "leased")})) + if err != nil { + t.Fatalf("leased get failed: %v", err) + } + leasedMetadata := leased.(map[string]any)["metadata"].(map[string]any) + if _, hasTTL := leasedMetadata["ttl"]; !hasTTL { + t.Fatalf("leased key must expose ttl metadata: %#v", leasedMetadata) + } + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "leased"), + "value": jsonValue("renewed"), + "preserveLease": json.RawMessage(`true`), + })); err != nil { + t.Fatalf("preserveLease put failed: %v", err) + } + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("nope"), + "preserveLease": json.RawMessage(`true`), + })); err == nil || err.Error() != "Cannot preserve lease: key does not exist or has no lease" { + t.Fatalf("expected preserveLease rejection, got %v", err) + } + + // Rename, including conflict and missing-source paths. + if _, err := state.rename(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "old"), + "newKey": jsonString(prefix + "new"), + })); err == nil || err.Error() != "ETCD_NOT_FOUND: source key does not exist" { + t.Fatalf("expected rename not-found, got %v", err) + } + if _, err := state.rename(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "newKey": jsonString(prefix + "renamed"), + })); err != nil { + t.Fatalf("rename failed: %v", err) + } + if _, err := state.rename(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "binary"), + "newKey": jsonString(prefix + "renamed"), + })); err == nil || err.Error() != "ETCD_CAS_CONFLICT: source changed or target already exists" { + t.Fatalf("expected rename conflict, got %v", err) + } + + // list_prefix with continuation. + listed, err := state.listPrefix(paramsWith(map[string]json.RawMessage{ + "prefix": jsonString(prefix), + "limit": json.RawMessage(`2`), + "includeValues": json.RawMessage(`true`), + })) + if err != nil { + t.Fatalf("list_prefix failed: %v", err) + } + list := listed.(map[string]any) + if len(list["keys"].([]any)) != 2 { + t.Fatalf("expected limited list, got %#v", list) + } + continuation, ok := list["continuation"].(string) + if !ok || continuation == "" { + t.Fatalf("expected continuation, got %#v", list) + } + nextPage, err := state.listPrefix(paramsWith(map[string]json.RawMessage{ + "prefix": jsonString(prefix), + "limit": json.RawMessage(`100`), + "includeValues": json.RawMessage(`true`), + "continuation": json.RawMessage(`"` + continuation + `"`), + })) + if err != nil { + t.Fatalf("continuation list failed: %v", err) + } + nextKeys := nextPage.(map[string]any)["keys"].([]any) + if len(nextKeys) == 0 { + t.Fatalf("continuation must resume after the first page") + } + firstContinued := nextKeys[0].(map[string]any)["key"].(string) + if firstContinued == list["keys"].([]any)[1].(map[string]any)["key"].(string) { + t.Fatalf("continuation overlapped the first page") + } + + // --- history ------------------------------------------------------- + // Explicit startRevision keeps the window above any revision compacted by + // earlier verification runs (the flow itself compacts at the end). + history, err := state.history(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "renamed"), + "startRevision": json.RawMessage(longString(baseRevision)), + })) + if err != nil { + t.Fatalf("history failed: %v", err) + } + historyRow := history.(map[string]any) + events := historyRow["events"].([]any) + // The renamed key only exists from the rename revision onward; the old + // key's earlier revisions belong to the old key. + if len(events) != 1 { + t.Fatalf("expected exactly the rename event, got %#v", historyRow) + } + if historyRow["truncated"] != false { + t.Fatalf("history should not be truncated for a short window: %#v", historyRow) + } + firstEvent := events[0].(map[string]any) + if _, err := strconvParse(firstEvent["revision"].(string)); err != nil { + t.Fatalf("revisions must be strings: %#v", firstEvent) + } + + // --- watch --------------------------------------------------------- + watchStarted, err := state.watchStart(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "watched"), + "includePrevKv": json.RawMessage(`true`), + })) + if err != nil { + t.Fatalf("watch_start failed: %v", err) + } + watchID := watchStarted.(map[string]any)["watchId"].(string) + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "watched"), + "value": jsonValue("first"), + })); err != nil { + t.Fatalf("watched put failed: %v", err) + } + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "watched"), + "value": jsonValue("second"), + })); err != nil { + t.Fatalf("watched put 2 failed: %v", err) + } + deadline := time.Now().Add(10 * time.Second) + var watchBatches []any + for time.Now().Before(deadline) { + polled, err := state.watchPoll(paramsWith(map[string]json.RawMessage{"watchId": json.RawMessage(`"` + watchID + `"`)})) + if err != nil { + t.Fatalf("watch_poll failed: %v", err) + } + batches := polled.(map[string]any)["batches"].([]any) + watchBatches = append(watchBatches, batches...) + if len(watchBatches) >= 2 { + break + } + time.Sleep(100 * time.Millisecond) + } + if len(watchBatches) < 2 { + t.Fatalf("watch did not deliver both events: %#v", watchBatches) + } + secondBatch := watchBatches[len(watchBatches)-1].(map[string]any) + secondEvent := secondBatch["events"].([]map[string]any)[0] + if secondEvent["previousValue"].(map[string]any)["data"] != "first" { + t.Fatalf("prevKv missing on watch event: %#v", secondEvent) + } + stopped, err := state.watchStop(paramsWith(map[string]json.RawMessage{"watchId": json.RawMessage(`"` + watchID + `"`)})) + if err != nil || stopped.(map[string]bool)["stopped"] != true { + t.Fatalf("watch_stop failed: %#v %v", stopped, err) + } + if _, err := state.watchPoll(paramsWith(map[string]json.RawMessage{"watchId": json.RawMessage(`"` + watchID + `"`)})); err == nil || err.Error() != "ETCD_WATCH_NOT_FOUND: watch does not exist" { + t.Fatalf("expected watch-not-found after stop, got %v", err) + } + + // --- lease --------------------------------------------------------- + granted, err := state.leaseGrant(paramsWith(map[string]json.RawMessage{"ttl": json.RawMessage(`120`)})) + if err != nil { + t.Fatalf("lease_grant failed: %v", err) + } + leaseID := granted.(map[string]any)["id"].(string) + if _, err := state.leaseKeepAlive(paramsWith(map[string]json.RawMessage{"id": json.RawMessage(leaseID)})); err != nil { + t.Fatalf("lease_keepalive_once failed: %v", err) + } + leaseListed, err := state.leaseList(paramsWith(map[string]json.RawMessage{})) + if err != nil { + t.Fatalf("lease_list failed: %v", err) + } + leaseListRow := leaseListed.(map[string]any) + if leaseListRow["partial"] != false { + t.Fatalf("lease list must be complete on etcd >= 3.4: %#v", leaseListRow) + } + foundLease := false + for _, lease := range leaseListRow["leases"].([]map[string]any) { + if lease["id"] == leaseID { + foundLease = true + } + } + if !foundLease { + t.Fatalf("granted lease %s missing from list: %#v", leaseID, leaseListRow) + } + leaseDetail, err := state.leaseGet(paramsWith(map[string]json.RawMessage{ + "id": json.RawMessage(leaseID), + "includeKeys": json.RawMessage(`true`), + })) + if err != nil { + t.Fatalf("lease_get failed: %v", err) + } + detail := leaseDetail.(map[string]any) + if detail["grantedTtl"].(int64) != 120 && detail["grantedTtl"].(int64) < 100 { + t.Fatalf("unexpected granted ttl: %#v", detail) + } + if _, err := state.leaseRevoke(paramsWith(map[string]json.RawMessage{"id": json.RawMessage(leaseID)})); err != nil { + t.Fatalf("lease_revoke failed: %v", err) + } + + // --- auth ---------------------------------------------------------- + userName := fmt.Sprintf("dbx_live_%d", time.Now().UnixNano()%100000) + if _, err := state.authUserAdd(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "password": jsonString("secret123"), + })); err != nil { + t.Fatalf("user add failed: %v", err) + } + if _, err := state.authRoleAdd(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})); err != nil { + t.Fatalf("role add failed: %v", err) + } + if _, err := state.authRolePermission(paramsWith(map[string]json.RawMessage{ + "role": jsonString("dbx_live_role"), + "resource": jsonString("prefix"), + "key": jsonString(prefix), + "access": jsonString("READWRITE"), + }), true); err != nil { + t.Fatalf("role grant permission failed: %v", err) + } + roleDetail, err := state.authRoleGet(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})) + if err != nil { + t.Fatalf("role get failed: %v", err) + } + permissions := roleDetail.(map[string]any)["permissions"].([]map[string]any) + if len(permissions) != 1 || permissions[0]["access"] != "readwrite" || permissions[0]["resource"] != "prefix" { + t.Fatalf("unexpected permission shape: %#v", permissions) + } + if _, err := state.authUserGrantRevokeRole(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "role": jsonString("dbx_live_role"), + }), true); err != nil { + t.Fatalf("grant role failed: %v", err) + } + userDetail, err := state.authUserGet(paramsWith(map[string]json.RawMessage{"user": jsonString(userName)})) + if err != nil { + t.Fatalf("user get failed: %v", err) + } + if len(userDetail.(map[string]any)["roles"].([]string)) == 0 { + t.Fatalf("expected granted role: %#v", userDetail) + } + users, err := state.authUserList(paramsWith(map[string]json.RawMessage{})) + if err != nil { + t.Fatalf("user list failed: %v", err) + } + if !containsAny(users.(map[string]any)["users"].([]string), userName) { + t.Fatalf("user list missing created user: %#v", users) + } + if _, err := state.authUserGrantRevokeRole(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "role": jsonString("dbx_live_role"), + }), false); err != nil { + t.Fatalf("revoke role failed: %v", err) + } + if _, err := state.authRolePermission(paramsWith(map[string]json.RawMessage{ + "role": jsonString("dbx_live_role"), + "resource": jsonString("prefix"), + "key": jsonString(prefix), + }), false); err != nil { + t.Fatalf("role revoke permission failed: %v", err) + } + if _, err := state.authUserDelete(paramsWith(map[string]json.RawMessage{"user": jsonString(userName)})); err != nil { + t.Fatalf("user delete failed: %v", err) + } + if _, err := state.authRoleDelete(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})); err != nil { + t.Fatalf("role delete failed: %v", err) + } + + // --- status / maintenance ------------------------------------------ + status, err := state.status(paramsWith(map[string]json.RawMessage{})) + if err != nil { + t.Fatalf("kv_status failed: %v", err) + } + statusRow := status.(map[string]any) + if statusRow["clusterId"] == nil || statusRow["leaderId"] == nil { + t.Fatalf("status missing cluster identity: %#v", statusRow) + } + members := statusRow["members"].([]map[string]any) + if len(members) == 0 || members[0]["reachable"] != true || members[0]["version"] == nil { + t.Fatalf("unexpected member rows: %#v", members) + } + if keyCount, err := strconvParse(statusRow["keyCount"].(string)); err != nil || keyCount <= 0 { + t.Fatalf("keyCount must be a positive numeric string: %#v", statusRow["keyCount"]) + } + + compactTarget := revisionOf(t, putResult) + if compactTarget < baseRevision { + compactTarget = baseRevision + } + if _, err := state.compact(paramsWith(map[string]json.RawMessage{"revision": json.RawMessage(statusRow["revision"].(string))})); err != nil { + t.Fatalf("compact failed: %v", err) + } + if _, err := state.compact(paramsWith(map[string]json.RawMessage{"revision": json.RawMessage(`999999999999`)})); err == nil || !strings.Contains(err.Error(), "future revision") { + t.Fatalf("expected raw future-revision error (Java parity), got %v", err) + } + defragged, err := state.defrag(paramsWith(map[string]json.RawMessage{ + "endpoints": json.RawMessage(`["` + endpoints + `"]`), + })) + if err != nil { + t.Fatalf("defrag failed: %v", err) + } + defragRow := defragged.(map[string]any)["members"].([]map[string]any)[0] + if defragRow["status"] != "succeeded" { + t.Fatalf("defrag should succeed on a live member: %#v", defragRow) + } + + // --- delete + cleanup ---------------------------------------------- + for _, key := range []string{prefix + "binary", prefix + "leased", prefix + "renamed", prefix + "watched"} { + if _, err := state.delete(paramsWith(map[string]json.RawMessage{"key": jsonString(key)})); err != nil { + t.Fatalf("delete %s failed: %v", key, err) + } + } + if _, err := state.delete(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "renamed")})); err != nil { + t.Fatalf("delete missing key failed: %v", err) + } +} + +// TestLiveEtcdProtocolFlow drives the multi-session runtime exactly like the +// Rust host does: NDJSON lines with agentSessionId routing. +func TestLiveEtcdProtocolFlow(t *testing.T) { + if os.Getenv("DBX_ETCD_LIVE") != "1" { + t.Skip("set DBX_ETCD_LIVE=1 to run the live etcd agent test") + } + endpoints := envOrDefault("DBX_ETCD_ENDPOINTS", "http://127.0.0.1:10700") + user := envOrDefault("DBX_ETCD_USER", "root") + password := envOrDefault("DBX_ETCD_PASSWORD", "123456") + + server := newRuntimeServer() + connection := fmt.Sprintf(`{"etcd_endpoints":%q,"username":%q,"password":%q}`, endpoints, user, password) + + handshake, _ := server.handleLine(`{"id":1,"method":"handshake","params":{}}`) + if handshake.Error != nil { + t.Fatalf("handshake failed: %#v", handshake) + } + + openLine := fmt.Sprintf(`{"id":2,"method":"open_session","params":{"agentSessionId":"s1","connection":%s}}`, connection) + opened, _ := server.handleLine(openLine) + if opened.Error != nil { + t.Fatalf("open_session failed: %#v", opened) + } + + putLine := fmt.Sprintf(`{"id":3,"method":"kv_put","params":{"agentSessionId":"s1","key":"dbx:proto","value":{"encoding":"utf8","data":"flow"}}}`) + put, _ := server.handleLine(putLine) + if put.Error != nil { + t.Fatalf("kv_put failed: %#v", put) + } + + unknown, _ := server.handleLine(`{"id":4,"method":"kv_describe","params":{"agentSessionId":"s1"}}`) + if unknown.Error == nil || !strings.Contains(unknown.Error.Message, "unknown method") { + t.Fatalf("expected unknown-method error, got %#v", unknown) + } + + closed, _ := server.handleLine(`{"id":5,"method":"close_session","params":{"agentSessionId":"s1"}}`) + if closed.Error != nil { + t.Fatalf("close_session failed: %#v", closed) + } +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func paramsWith(params map[string]json.RawMessage) map[string]json.RawMessage { + return params +} + +func jsonString(value string) json.RawMessage { + encoded, _ := json.Marshal(value) + return json.RawMessage(encoded) +} + +func jsonValue(value string) json.RawMessage { + encoded, _ := json.Marshal(value) + return json.RawMessage(`{"encoding":"utf8","data":` + string(encoded) + `}`) +} + +func revisionOf(t *testing.T, result any) int64 { + t.Helper() + row, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %#v", result) + } + revision, err := strconvParse(row["revision"].(string)) + if err != nil { + t.Fatalf("revision not numeric: %#v", row["revision"]) + } + return revision +} + +func strconvParse(value string) (int64, error) { + var parsed int64 + _, err := fmt.Sscan(value, &parsed) + return parsed, err +} + +func containsAny(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/agents/drivers/etcd-go/kv.go b/agents/drivers/etcd-go/kv.go new file mode 100644 index 0000000000..beb4459cc9 --- /dev/null +++ b/agents/drivers/etcd-go/kv.go @@ -0,0 +1,524 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode/utf8" + + "go.etcd.io/etcd/api/v3/mvccpb" + clientv3 "go.etcd.io/etcd/client/v3" +) + +const defaultListLimit = 100 +const preserveLeaseMaxAttempts = 3 + +func (s *etcdSession) listPrefix(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + prefix := stringOrDefault(params, "prefix", "") + limit := intOrDefault(params, "limit", defaultListLimit) + if limit < 1 { + limit = 1 + } + revision := longOrNull(params, "revision") + includeValues := boolOrDefault(params, "includeValues", false) + continuation := stringOrNull(params, "continuation") + + start := prefixStart(prefix) + if continuation != nil && *continuation != "" { + decoded, err := base64.StdEncoding.DecodeString(*continuation) + if err != nil { + return nil, err + } + start = string(decoded) + } + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + options := []clientv3.OpOption{ + clientv3.WithRange(prefixEnd(prefix)), + clientv3.WithLimit(int64(limit)), + clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend), + } + if revision != nil && *revision > 0 { + options = append(options, clientv3.WithRev(*revision)) + } + response, err := client.Get(ctx, start, options...) + if err != nil { + return nil, err + } + + keys := make([]any, 0, len(response.Kvs)) + for _, item := range response.Kvs { + row := metadataMap(item) + row["key"] = displayBytes(item.Key) + row["keyBytes"] = bytesObject(item.Key) + if includeValues { + row["value"] = valueObject(item.Value) + } + keys = append(keys, row) + } + + result := map[string]any{"keys": keys} + if response.More && len(response.Kvs) > 0 { + result["continuation"] = nextContinuation(response.Kvs[len(response.Kvs)-1].Key) + } else { + result["continuation"] = nil + } + result["revision"] = longString(response.Header.Revision) + return result, nil +} + +func (s *etcdSession) get(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + revision := longOrNull(params, "revision") + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + options := []clientv3.OpOption{} + if revision != nil && *revision > 0 { + options = append(options, clientv3.WithRev(*revision)) + } + response, err := client.Get(ctx, key, options...) + if err != nil { + return nil, err + } + if len(response.Kvs) == 0 { + return map[string]any{"found": false, "key": nil, "value": nil, "metadata": nil}, nil + } + item := response.Kvs[0] + result := map[string]any{ + "found": true, + "key": displayBytes(item.Key), + "keyBytes": bytesObject(item.Key), + } + if !boolOrDefault(params, "metadataOnly", false) { + result["value"] = valueObject(item.Value) + } else { + result["value"] = nil + } + metadata, err := s.metadataWithTtl(item) + if err != nil { + return nil, err + } + result["metadata"] = metadata + return result, nil +} + +func (s *etcdSession) put(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + value, err := parseValueObject(rawObject(params, "value")) + if err != nil { + return nil, err + } + expectedModRevision := longOrNull(params, "expectedModRevision") + expectedCreateRevision := longOrNull(params, "expectedCreateRevision") + leaseValue := longOrNull(params, "lease") + ttlValue := longOrNull(params, "ttl") + preserveLease := boolOrDefault(params, "preserveLease", false) + hasLease := leaseValue != nil + hasTtl := ttlValue != nil + if (hasLease && hasTtl) || (preserveLease && (hasLease || hasTtl)) { + return nil, errors.New("lease, ttl, and preserveLease cannot be specified together") + } + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if preserveLease { + revision, err := putPreservingLease(client, ctx, key, value) + if err != nil { + return nil, err + } + return map[string]any{"revision": longString(revision)}, nil + } + + var leaseID clientv3.LeaseID + var grantedLeaseID clientv3.LeaseID + if hasTtl { + if *ttlValue <= 0 { + return nil, errors.New("ttl must be a positive integer") + } + grant, err := client.Lease.Grant(ctx, *ttlValue) + if err != nil { + return nil, err + } + grantedLeaseID = grant.ID + leaseID = grant.ID + } else if hasLease { + leaseID = clientv3.LeaseID(*leaseValue) + } + + revision, err := func() (int64, error) { + if expectedModRevision != nil || expectedCreateRevision != nil { + var comparisons []clientv3.Cmp + if expectedModRevision != nil { + comparisons = append(comparisons, clientv3.Compare(clientv3.ModRevision(key), "=", *expectedModRevision)) + } + if expectedCreateRevision != nil { + comparisons = append(comparisons, clientv3.Compare(clientv3.CreateRevision(key), "=", *expectedCreateRevision)) + } + txn := client.Txn(ctx).If(comparisons...).Then(clientv3.OpPut(key, value, clientv3.WithLease(leaseID))) + response, err := txn.Commit() + if err != nil { + return 0, err + } + if !response.Succeeded { + return 0, errors.New("ETCD_CAS_CONFLICT: key changed after it was loaded") + } + return response.Header.Revision, nil + } + response, err := client.Put(ctx, key, value, clientv3.WithLease(leaseID)) + if err != nil { + return 0, err + } + return response.Header.Revision, nil + }() + if err != nil && grantedLeaseID != 0 { + _, _ = client.Lease.Revoke(context.Background(), grantedLeaseID) + } + if err != nil { + return nil, err + } + return map[string]any{"revision": longString(revision)}, nil +} + +func putPreservingLease(client *clientv3.Client, ctx context.Context, key, value string) (int64, error) { + for attempt := 0; attempt < preserveLeaseMaxAttempts; attempt++ { + existing, err := client.Get(ctx, key) + if err != nil { + return 0, err + } + if len(existing.Kvs) == 0 || existing.Kvs[0].Lease <= 0 { + return 0, errors.New("Cannot preserve lease: key does not exist or has no lease") + } + current := existing.Kvs[0] + txn := client.Txn(ctx). + If(clientv3.Compare(clientv3.ModRevision(key), "=", current.ModRevision)). + Then(clientv3.OpPut(key, value, clientv3.WithLease(clientv3.LeaseID(current.Lease)))) + response, err := txn.Commit() + if err != nil { + return 0, err + } + if response.Succeeded { + return response.Header.Revision, nil + } + } + return 0, errors.New("Cannot preserve lease: key changed concurrently; retry the save") +} + +func (s *etcdSession) delete(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + expectedModRevision := longOrNull(params, "expectedModRevision") + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if expectedModRevision != nil { + txn := client.Txn(ctx). + If(clientv3.Compare(clientv3.ModRevision(key), "=", *expectedModRevision)). + Then(clientv3.OpDelete(key)) + response, err := txn.Commit() + if err != nil { + return nil, err + } + if !response.Succeeded { + return nil, errors.New("ETCD_CAS_CONFLICT: key changed after it was loaded") + } + return map[string]any{"deleted": int64(1), "revision": longString(response.Header.Revision)}, nil + } + response, err := client.Delete(ctx, key) + if err != nil { + return nil, err + } + return map[string]any{"deleted": response.Deleted, "revision": longString(response.Header.Revision)}, nil +} + +func (s *etcdSession) rename(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + sourceKey, err := keyBytesParam(params) + if err != nil { + return nil, err + } + newKey := stringOrNull(params, "newKey") + if newKey == nil || *newKey == "" { + return nil, errors.New("ETCD_NEWKEY_REQUIRED") + } + targetKey := *newKey + if sourceKey == targetKey { + return map[string]any{"renamed": true, "revision": nil}, nil + } + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + sourceResponse, err := client.Get(ctx, sourceKey) + if err != nil { + return nil, err + } + if len(sourceResponse.Kvs) == 0 { + return nil, errors.New("ETCD_NOT_FOUND: source key does not exist") + } + source := sourceResponse.Kvs[0] + expected := longOrNull(params, "expectedModRevision") + expectedRevision := source.ModRevision + if expected != nil { + expectedRevision = *expected + } + putOption := []clientv3.OpOption{} + if source.Lease != 0 { + putOption = append(putOption, clientv3.WithLease(clientv3.LeaseID(source.Lease))) + } + txn := client.Txn(ctx). + If( + clientv3.Compare(clientv3.ModRevision(sourceKey), "=", expectedRevision), + clientv3.Compare(clientv3.CreateRevision(targetKey), "=", 0), + ). + Then( + clientv3.OpPut(targetKey, string(source.Value), putOption...), + clientv3.OpDelete(sourceKey), + ) + response, err := txn.Commit() + if err != nil { + return nil, err + } + if !response.Succeeded { + return nil, errors.New("ETCD_CAS_CONFLICT: source changed or target already exists") + } + return map[string]any{"renamed": true, "revision": longString(response.Header.Revision)}, nil +} + +func (s *etcdSession) metadataWithTtl(item *mvccpb.KeyValue) (map[string]any, error) { + metadata := metadataMap(item) + if item.Lease > 0 { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + defer cancel() + lease, err := client.Lease.TimeToLive(ctx, clientv3.LeaseID(item.Lease)) + if err != nil { + return nil, err + } + metadata["ttl"] = lease.TTL + } + return metadata, nil +} + +func metadataMap(item *mvccpb.KeyValue) map[string]any { + return map[string]any{ + "createRevision": longString(item.CreateRevision), + "modRevision": longString(item.ModRevision), + "version": longString(item.Version), + "lease": longString(item.Lease), + "valueSize": len(item.Value), + } +} + +func prefixEnd(prefix string) string { + if prefix == "" { + return "\x00" + } + end := []byte(prefix) + for i := len(end) - 1; i >= 0; i-- { + if end[i] < 0xff { + end[i]++ + return string(end[:i+1]) + } + } + return "\x00" +} + +func prefixStart(prefix string) string { + if prefix == "" { + return "\x00" + } + return prefix +} + +func nextContinuation(key []byte) string { + next := make([]byte, len(key)+1) + copy(next, key) + return base64.StdEncoding.EncodeToString(next) +} + +func keyBytesParam(params map[string]json.RawMessage) (string, error) { + if encoded := rawObject(params, "keyBytes"); encoded != nil { + value, err := parseValueObject(encoded) + if err != nil { + return "", err + } + return value, nil + } + if raw, ok := params["key"]; ok && raw != nil && string(raw) != "null" { + var key string + if err := json.Unmarshal(raw, &key); err != nil { + return "", err + } + return key, nil + } + return "", errors.New("Key is required") +} + +func rawObject(params map[string]json.RawMessage, key string) map[string]json.RawMessage { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil || object == nil { + return nil + } + return object +} + +func bytesObject(bytes []byte) map[string]any { + return map[string]any{ + "encoding": "base64", + "data": base64.StdEncoding.EncodeToString(bytes), + } +} + +func valueObject(value []byte) map[string]any { + if utf8.Valid(value) { + return map[string]any{"encoding": "utf8", "data": string(value)} + } + return bytesObject(value) +} + +func displayBytes(bytes []byte) string { + if utf8.Valid(bytes) { + return string(bytes) + } + return base64.StdEncoding.EncodeToString(bytes) +} + +func parseValueObject(value map[string]json.RawMessage) (string, error) { + if value == nil { + value = map[string]json.RawMessage{} + } + encoding := stringOrDefault(value, "encoding", "utf8") + data := stringOrDefault(value, "data", "") + if encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + return string(decoded), nil + } + if encoding != "utf8" { + return "", fmt.Errorf("Unsupported value encoding: %s", encoding) + } + return data, nil +} + +func longString(value int64) string { + return strconv.FormatInt(value, 10) +} + +func unsignedLongString(value int64) string { + return strconv.FormatUint(uint64(value), 10) +} + +func stringOrNull(params map[string]json.RawMessage, key string) *string { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + var text *string + return text + } + return &value +} + +func stringOrDefault(params map[string]json.RawMessage, key string, fallback string) string { + value := stringOrNull(params, key) + if value == nil { + return fallback + } + return *value +} + +func intOrDefault(params map[string]json.RawMessage, key string, fallback int) int { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return fallback + } + var value int + if err := json.Unmarshal(raw, &value); err != nil { + return fallback + } + return value +} + +func boolOrDefault(params map[string]json.RawMessage, key string, fallback bool) bool { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return fallback + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + return fallback + } + return value +} + +func longOrNull(params map[string]json.RawMessage, key string) *int64 { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var value int64 + if err := json.Unmarshal(raw, &value); err != nil { + return nil + } + return &value +} + +func requiredPositiveLong(params map[string]json.RawMessage, field string) (int64, error) { + value := longOrNull(params, field) + if value == nil || *value <= 0 { + return 0, fmt.Errorf("ETCD_INVALID_%s: a positive integer is required", strings.ToUpper(field)) + } + return *value, nil +} + +func requiredString(params map[string]json.RawMessage, field string) (string, error) { + value := stringOrNull(params, field) + if value == nil || *value == "" { + return "", fmt.Errorf("ETCD_%s_REQUIRED", strings.ToUpper(field)) + } + return *value, nil +} diff --git a/agents/drivers/etcd-go/lease.go b/agents/drivers/etcd-go/lease.go new file mode 100644 index 0000000000..feecf77125 --- /dev/null +++ b/agents/drivers/etcd-go/lease.go @@ -0,0 +1,339 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "time" + + "go.etcd.io/etcd/api/v3/etcdserverpb" + clientv3 "go.etcd.io/etcd/client/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + maxLeaseAttachedKeys = 256 + defaultLeaseListLimit = 100 + maxLeaseListLimit = 200 + leaseListConcurrency = 8 + leaseListDeadlineSeconds = 5 +) + +type leaseListDeadline struct { + deadline time.Time +} + +func (d leaseListDeadline) remaining() (time.Duration, error) { + remaining := time.Until(d.deadline) + if remaining <= 0 { + return 0, errors.New("ETCD_LEASE_LIST_TIMEOUT") + } + return remaining, nil +} + +func (s *etcdSession) leaseList(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + deadline := leaseListDeadline{deadline: time.Now().Add(leaseListDeadlineSeconds * time.Second)} + limit := intOrDefault(params, "limit", defaultLeaseListLimit) + if limit < 1 { + limit = 1 + } + if limit > maxLeaseListLimit { + limit = maxLeaseListLimit + } + continuation := stringOrNull(params, "continuation") + var afterLeaseID *uint64 + if continuation != nil && *continuation != "" { + parsed, err := parseUnsignedLong(*continuation) + if err != nil { + return nil, err + } + afterLeaseID = &parsed + } + + partial := false + leaseIDs, err := clusterLeaseIDs(client, deadline) + if err != nil { + if isLeaseListFallbackError(err) { + leaseIDs = s.knownLeaseIDs() + partial = true + } else { + return nil, err + } + } else { + for _, id := range leaseIDs { + s.rememberLease(id) + } + } + leaseIDs = leasePageIDs(leaseIDs, afterLeaseID, limit+1) + hasMore := len(leaseIDs) > limit + if len(leaseIDs) > limit { + leaseIDs = leaseIDs[:limit] + } + + leases := []map[string]any{} + var lastProcessedID *uint64 + deadlineReached := false +chunkLoop: + for offset := 0; offset < len(leaseIDs); offset += leaseListConcurrency { + end := offset + leaseListConcurrency + if end > len(leaseIDs) { + end = len(leaseIDs) + } + chunkIDs := leaseIDs[offset:end] + + type ttlResult struct { + id uint64 + ttl int64 + granted int64 + err error + } + results := make([]chan ttlResult, len(chunkIDs)) + for index, id := range chunkIDs { + channel := make(chan ttlResult, 1) + results[index] = channel + go func(id uint64, channel chan ttlResult) { + remaining, err := deadline.remaining() + if err != nil { + channel <- ttlResult{id: id, err: err} + return + } + ctx, cancel := context.WithTimeout(context.Background(), remaining) + defer cancel() + response, err := client.Lease.TimeToLive(ctx, clientv3.LeaseID(int64(id))) + if err != nil { + channel <- ttlResult{id: id, err: err} + return + } + channel <- ttlResult{id: id, ttl: response.TTL, granted: response.GrantedTTL} + }(id, channel) + } + for _, channel := range results { + result := <-channel + id := result.id + if result.err != nil { + if errors.Is(result.err, errLeaseListDeadline) || isDeadlineError(result.err) { + partial = true + deadlineReached = true + break chunkLoop + } + if status.Code(result.err) == codes.NotFound { + s.forgetLease(id) + } else { + partial = true + } + lastProcessedID = &id + continue + } + leases = append(leases, map[string]any{ + "id": unsignedLongString(int64(id)), + "ttl": result.ttl, + "grantedTtl": result.granted, + }) + processed := id + lastProcessedID = &processed + } + } + + var nextContinuation any + if deadlineReached && len(leaseIDs) > 0 { + if lastProcessedID != nil { + nextContinuation = unsignedLongString(int64(*lastProcessedID)) + } else if afterLeaseID != nil { + nextContinuation = unsignedLongString(int64(*afterLeaseID)) + } else { + nextContinuation = "0" + } + } else if hasMore && len(leaseIDs) > 0 { + nextContinuation = unsignedLongString(int64(leaseIDs[len(leaseIDs)-1])) + } + return map[string]any{ + "leases": leases, + "partial": partial, + "nextContinuation": nextContinuation, + }, nil +} + +var errLeaseListDeadline = errors.New("ETCD_LEASE_LIST_TIMEOUT") + +func isDeadlineError(err error) bool { + return errors.Is(err, context.DeadlineExceeded) || status.Code(err) == codes.DeadlineExceeded +} + +func isLeaseListFallbackError(err error) bool { + if errors.Is(err, errLeaseListDeadline) || errors.Is(err, context.DeadlineExceeded) { + return true + } + code := status.Code(err) + return code == codes.Unimplemented || code == codes.DeadlineExceeded +} + +func parseUnsignedLong(value string) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid lease continuation: %s", value) + } + return parsed, nil +} + +func leasePageIDs(leaseIDs []uint64, afterLeaseID *uint64, fetchLimit int) []uint64 { + sorted := make([]uint64, len(leaseIDs)) + copy(sorted, leaseIDs) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + result := []uint64{} + for _, id := range sorted { + if afterLeaseID != nil && id <= *afterLeaseID { + continue + } + if len(result) >= fetchLimit { + break + } + result = append(result, id) + } + return result +} + +func clusterLeaseIDs(client *clientv3.Client, deadline leaseListDeadline) ([]uint64, error) { + remaining, err := deadline.remaining() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), remaining) + defer cancel() + leaseClient := etcdserverpb.NewLeaseClient(client.ActiveConnection()) + response, err := leaseClient.LeaseLeases(ctx, &etcdserverpb.LeaseLeasesRequest{}) + if err != nil { + return nil, err + } + ids := make([]uint64, 0, len(response.Leases)) + for _, lease := range response.Leases { + ids = append(ids, uint64(lease.ID)) + } + return ids, nil +} + +func (s *etcdSession) leaseGet(params map[string]json.RawMessage) (any, error) { + id, err := requiredPositiveLong(params, "id") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + options := []clientv3.LeaseOption{} + if boolOrDefault(params, "includeKeys", false) { + options = append(options, clientv3.WithAttachedKeys()) + } + response, err := client.Lease.TimeToLive(ctx, clientv3.LeaseID(id), options...) + if err != nil { + return nil, err + } + s.rememberLease(uint64(id)) + keys := []map[string]any{} + for index, key := range response.Keys { + if index >= maxLeaseAttachedKeys { + break + } + keys = append(keys, bytesObject(key)) + } + return map[string]any{ + "id": unsignedLongString(int64(response.ID)), + "ttl": response.TTL, + "grantedTtl": response.GrantedTTL, + "keys": keys, + "truncated": len(response.Keys) > maxLeaseAttachedKeys, + }, nil +} + +func (s *etcdSession) leaseGrant(params map[string]json.RawMessage) (any, error) { + ttl, err := requiredPositiveLong(params, "ttl") + if err != nil { + return nil, err + } + requestedID := longOrNull(params, "id") + if requestedID != nil && *requestedID < 0 { + return nil, errors.New("id must be a positive integer or 0") + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + var grantedID clientv3.LeaseID + var grantedTTL int64 + if requestedID == nil || *requestedID == 0 { + response, err := client.Lease.Grant(ctx, ttl) + if err != nil { + return nil, err + } + grantedID = response.ID + grantedTTL = response.TTL + } else { + leaseClient := etcdserverpb.NewLeaseClient(client.ActiveConnection()) + response, err := leaseClient.LeaseGrant(ctx, &etcdserverpb.LeaseGrantRequest{TTL: ttl, ID: *requestedID}) + if err != nil { + return nil, err + } + grantedID = clientv3.LeaseID(response.ID) + grantedTTL = response.TTL + } + s.rememberLease(uint64(grantedID)) + return map[string]any{ + "id": unsignedLongString(int64(grantedID)), + "ttl": grantedTTL, + }, nil +} + +func (s *etcdSession) leaseKeepAlive(params map[string]json.RawMessage) (any, error) { + id, err := requiredPositiveLong(params, "id") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + response, err := client.Lease.KeepAliveOnce(ctx, clientv3.LeaseID(id)) + if err != nil { + return nil, err + } + s.rememberLease(uint64(id)) + return map[string]any{ + "id": unsignedLongString(id), + "ttl": response.TTL, + }, nil +} + +func (s *etcdSession) leaseRevoke(params map[string]json.RawMessage) (any, error) { + id, err := requiredPositiveLong(params, "id") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, err := client.Lease.Revoke(ctx, clientv3.LeaseID(id)); err != nil { + return nil, err + } + s.forgetLease(uint64(id)) + return map[string]any{ + "id": unsignedLongString(id), + "revoked": true, + }, nil +} diff --git a/agents/drivers/etcd-go/main.go b/agents/drivers/etcd-go/main.go new file mode 100644 index 0000000000..e9556a34e1 --- /dev/null +++ b/agents/drivers/etcd-go/main.go @@ -0,0 +1,468 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "sync" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" +) + +const ( + protocolVersion = 2 + legacyAgentSessionID = "__legacy__" + maxAgentSessions = 256 + rpcTimeoutSeconds = 30 +) + +var capabilities = []string{ + "connect", "test_connection", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", + "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", + "multi_session", +} + +type request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +// etcdSession carries the per-connection etcd client and its derived state. +// Requests for one session are serialized by agentSession.mu; watch callbacks +// reach the maps through watchesMu instead so buffering never blocks dispatch. +type etcdSession struct { + clientMu sync.Mutex + client *clientv3.Client + connectedEndpoints []string + + watchesMu sync.Mutex + watches map[string]*watchState + knownLeases map[uint64]struct{} + watchBufferedBytes int64 + + activeCancelMu sync.Mutex + activeCancel context.CancelFunc +} + +func (s *etcdSession) reserveWatchBuffer(bytes int64) bool { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + if bytes < 0 || s.watchBufferedBytes+bytes > maxSessionWatchBufferBytes { + return false + } + s.watchBufferedBytes += bytes + return true +} + +func (s *etcdSession) releaseWatchBuffer(bytes int64) { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + s.watchBufferedBytes -= bytes + if s.watchBufferedBytes < 0 { + s.watchBufferedBytes = 0 + } +} + +func (s *etcdSession) rememberLease(id uint64) { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + s.knownLeases[id] = struct{}{} +} + +func (s *etcdSession) forgetLease(id uint64) { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + delete(s.knownLeases, id) +} + +func (s *etcdSession) knownLeaseIDs() []uint64 { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + ids := make([]uint64, 0, len(s.knownLeases)) + for id := range s.knownLeases { + ids = append(ids, id) + } + return ids +} + +type agentSession struct { + state *etcdSession + mu sync.Mutex +} + +func (a *agentSession) cancelActive() { + a.state.activeCancelMu.Lock() + cancel := a.state.activeCancel + a.state.activeCancelMu.Unlock() + if cancel != nil { + cancel() + } +} + +type runtimeServer struct { + mu sync.RWMutex + sessions map[string]*agentSession +} + +func main() { + configureRuntimeParallelism() + server := newRuntimeServer() + encoder := json.NewEncoder(os.Stdout) + var encoderMu sync.Mutex + var requests sync.WaitGroup + fmt.Fprintln(os.Stdout, `{"ready":true}`) + + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var envelope request + if json.Unmarshal([]byte(line), &envelope) == nil && envelope.Method == "shutdown" { + requests.Wait() + result, _ := server.handleLine(line) + encoderMu.Lock() + _ = encoder.Encode(result) + encoderMu.Unlock() + server.closeAll() + return + } + requests.Add(1) + go func(line string) { + defer requests.Done() + result, _ := server.handleLine(line) + encoderMu.Lock() + defer encoderMu.Unlock() + if err := encoder.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err) + } + }(line) + } + requests.Wait() + server.closeAll() +} + +func configureRuntimeParallelism() { + if raw := strings.TrimSpace(os.Getenv("DBX_AGENT_ETCD_GOMAXPROCS")); raw != "" { + if configured, err := strconv.Atoi(raw); err == nil && configured > 0 { + runtime.GOMAXPROCS(configured) + return + } + } + if strings.TrimSpace(os.Getenv("GOMAXPROCS")) != "" { + return + } + runtime.GOMAXPROCS(min(runtime.NumCPU(), 4)) +} + +func newRuntimeServer() *runtimeServer { + return &runtimeServer{sessions: map[string]*agentSession{}} +} + +func (r *runtimeServer) handleLine(line string) (response, bool) { + var req request + if err := json.Unmarshal([]byte(line), &req); err != nil { + return errorResponse(nil, "", "", err), false + } + if len(req.ID) == 0 { + req.ID = json.RawMessage("1") + } + result, shutdown, err := r.dispatch(req.Method, req.Params) + if err != nil { + return errorResponse(req.ID, req.Method, stringParam(req.Params, "agentSessionId"), err), false + } + return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown +} + +func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) { + switch method { + case "handshake": + return handshakeResult(), false, nil + case "open_session": + id := requiredSessionID(params) + if id == "" { + return nil, false, errors.New("agentSessionId is required") + } + return r.openSession(id, params) + case "close_session": + return r.closeSession(stringParam(params, "agentSessionId")), false, nil + case "validate_session": + session, err := r.session(requiredSessionID(params)) + if err != nil { + return nil, false, err + } + session.mu.Lock() + defer session.mu.Unlock() + result, err := session.state.validateConnection() + return result, false, err + case "cancel_session": + session, err := r.session(requiredSessionID(params)) + if err != nil { + return nil, false, err + } + session.cancelActive() + return map[string]bool{"ok": true}, false, nil + case "test_connection": + return r.testConnection(params) + case "connect": + _ = r.closeSession(legacyAgentSessionID) + return r.openSession(legacyAgentSessionID, params) + case "disconnect": + return r.closeSession(legacyAgentSessionID), false, nil + case "shutdown": + return map[string]bool{"ok": true}, true, r.closeAll() + default: + id := stringParam(params, "agentSessionId") + if id == "" { + id = legacyAgentSessionID + } + session, err := r.session(id) + if err != nil { + return nil, false, err + } + session.mu.Lock() + defer session.mu.Unlock() + result, err := session.state.handle(method, params) + return result, false, err + } +} + +func requiredSessionID(params map[string]json.RawMessage) string { + return strings.TrimSpace(stringParam(params, "agentSessionId")) +} + +func (r *runtimeServer) openSession(id string, params map[string]json.RawMessage) (any, bool, error) { + r.mu.Lock() + if _, exists := r.sessions[id]; exists { + r.mu.Unlock() + return nil, false, fmt.Errorf("Agent session already exists: %s", id) + } + if len(r.sessions) >= maxAgentSessions { + r.mu.Unlock() + return nil, false, fmt.Errorf("Agent session limit reached: %d", maxAgentSessions) + } + session := &agentSession{state: newEtcdSession()} + r.sessions[id] = session + r.mu.Unlock() + + if _, err := session.state.connect(params); err != nil { + r.mu.Lock() + delete(r.sessions, id) + r.mu.Unlock() + session.state.close() + return nil, false, err + } + return map[string]bool{"ok": true}, false, nil +} + +func (r *runtimeServer) session(id string) (*agentSession, error) { + r.mu.RLock() + session := r.sessions[id] + r.mu.RUnlock() + if session == nil { + return nil, fmt.Errorf("Agent session not found: %s", id) + } + return session, nil +} + +func (r *runtimeServer) closeSession(id string) any { + r.mu.Lock() + session := r.sessions[id] + delete(r.sessions, id) + r.mu.Unlock() + if session != nil { + session.cancelActive() + session.mu.Lock() + session.state.close() + session.mu.Unlock() + } + return map[string]bool{"ok": true} +} + +func (r *runtimeServer) closeAll() error { + r.mu.Lock() + sessions := r.sessions + r.sessions = map[string]*agentSession{} + r.mu.Unlock() + var firstErr error + for _, session := range sessions { + session.cancelActive() + if err := session.state.close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (r *runtimeServer) testConnection(params map[string]json.RawMessage) (any, bool, error) { + state := newEtcdSession() + defer state.close() + if _, err := state.connect(params); err != nil { + return nil, false, err + } + return map[string]bool{"ok": true}, false, nil +} + +func newEtcdSession() *etcdSession { + return &etcdSession{ + watches: map[string]*watchState{}, + knownLeases: map[uint64]struct{}{}, + } +} + +func handshakeResult() map[string]any { + return map[string]any{ + "protocolVersion": protocolVersion, + "agentProtocolVersion": protocolVersion, + "capabilities": capabilities, + } +} + +func (s *etcdSession) beginOperation() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + s.activeCancelMu.Lock() + s.activeCancel = cancel + s.activeCancelMu.Unlock() + return ctx, cancel +} + +func (s *etcdSession) endOperation(cancel context.CancelFunc) { + cancel() + s.activeCancelMu.Lock() + s.activeCancel = nil + s.activeCancelMu.Unlock() +} + +func (s *etcdSession) handle(method string, params map[string]json.RawMessage) (any, error) { + switch method { + case "handshake": + return handshakeResult(), nil + case "connect", "test_connection": + return s.connectMap(params) + case "validate_connection": + return s.validateConnection() + case "kv_list_prefix": + return s.listPrefix(params) + case "kv_get": + return s.get(params) + case "kv_put": + return s.put(params) + case "kv_delete": + return s.delete(params) + case "kv_rename": + return s.rename(params) + case "kv_history": + return s.history(params) + case "kv_status": + return s.status(params) + case "etcd_compact": + return s.compact(params) + case "etcd_defrag": + return s.defrag(params) + case "etcd_watch_start": + return s.watchStart(params) + case "etcd_watch_poll": + return s.watchPoll(params) + case "etcd_watch_stop": + return s.watchStop(params) + case "etcd_lease_list": + return s.leaseList(params) + case "etcd_lease_get": + return s.leaseGet(params) + case "etcd_lease_grant": + return s.leaseGrant(params) + case "etcd_lease_keepalive_once": + return s.leaseKeepAlive(params) + case "etcd_lease_revoke": + return s.leaseRevoke(params) + case "etcd_auth_user_list": + return s.authUserList(params) + case "etcd_auth_user_get": + return s.authUserGet(params) + case "etcd_auth_user_add": + return s.authUserAdd(params) + case "etcd_auth_user_delete": + return s.authUserDelete(params) + case "etcd_auth_user_change_password": + return s.authUserChangePassword(params) + case "etcd_auth_user_grant_role": + return s.authUserGrantRevokeRole(params, true) + case "etcd_auth_user_revoke_role": + return s.authUserGrantRevokeRole(params, false) + case "etcd_auth_role_list": + return s.authRoleList(params) + case "etcd_auth_role_get": + return s.authRoleGet(params) + case "etcd_auth_role_add": + return s.authRoleAdd(params) + case "etcd_auth_role_delete": + return s.authRoleDelete(params) + case "etcd_auth_role_grant_permission": + return s.authRolePermission(params, true) + case "etcd_auth_role_revoke_permission": + return s.authRolePermission(params, false) + case "disconnect": + s.close() + return map[string]bool{"ok": true}, nil + case "shutdown": + s.close() + return map[string]bool{"ok": true}, nil + default: + return nil, fmt.Errorf("unknown method: %s", method) + } +} + +func decodeParams(params map[string]json.RawMessage, target any) error { + if params == nil { + params = map[string]json.RawMessage{} + } + data, err := json.Marshal(params) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func stringParam(params map[string]json.RawMessage, key string) string { + if params == nil { + return "" + } + var result string + _ = json.Unmarshal(params[key], &result) + return result +} + +func intParam(params map[string]json.RawMessage, key string) int { + if params == nil { + return 0 + } + var result int + _ = json.Unmarshal(params[key], &result) + return result +} + +func boolParam(params map[string]json.RawMessage, key string) bool { + if params == nil { + return false + } + var result bool + _ = json.Unmarshal(params[key], &result) + return result +} diff --git a/agents/drivers/etcd-go/main_test.go b/agents/drivers/etcd-go/main_test.go new file mode 100644 index 0000000000..c1775aa86b --- /dev/null +++ b/agents/drivers/etcd-go/main_test.go @@ -0,0 +1,502 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "go.etcd.io/etcd/api/v3/mvccpb" +) + +func TestHandshakeAdvertisesCapabilities(t *testing.T) { + server := newRuntimeServer() + result, _, err := server.dispatch("handshake", nil) + if err != nil { + t.Fatalf("handshake failed: %v", err) + } + handshake := result.(map[string]any) + if handshake["protocolVersion"] != 2 || handshake["agentProtocolVersion"] != 2 { + t.Fatalf("unexpected protocol versions: %#v", handshake) + } + want := []string{ + "connect", "test_connection", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", + "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", + "multi_session", + } + got, ok := handshake["capabilities"].([]string) + if !ok { + t.Fatalf("capabilities missing: %#v", handshake) + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("capability mismatch:\n got %v\nwant %v", got, want) + } + for _, capability := range got { + if capability == "structured_error_v1" { + t.Fatalf("structured_error_v1 must not be advertised (Java parity)") + } + } +} + +func TestHandleLineReportsSessionNotFound(t *testing.T) { + server := newRuntimeServer() + response, _ := server.handleLine(`{"id":7,"method":"kv_get","params":{"key":"a"}}`) + if response.Error == nil { + t.Fatalf("expected error response, got %#v", response) + } + if response.Error.Code != -1 { + t.Fatalf("expected code -1, got %d", response.Error.Code) + } + if response.Error.Message != "Agent session not found: __legacy__" { + t.Fatalf("unexpected message: %q", response.Error.Message) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(response.ID, &raw); err == nil { + _ = raw + } + if string(response.ID) != "7" { + t.Fatalf("expected id 7, got %s", response.ID) + } +} + +func TestOpenSessionValidatesAgentSessionID(t *testing.T) { + server := newRuntimeServer() + _, _, err := server.dispatch("open_session", map[string]json.RawMessage{}) + if err == nil || !strings.Contains(err.Error(), "agentSessionId is required") { + t.Fatalf("expected agentSessionId requirement, got %v", err) + } +} + +func TestOpenSessionRejectsDuplicate(t *testing.T) { + server := newRuntimeServer() + server.sessions["dup"] = &agentSession{state: newEtcdSession()} + _, _, err := server.dispatch("open_session", map[string]json.RawMessage{ + "agentSessionId": json.RawMessage(`"dup"`), + }) + if err == nil || !strings.Contains(err.Error(), "Agent session already exists") { + t.Fatalf("expected duplicate rejection, got %v", err) + } +} + +func TestUnconnectedSessionErrors(t *testing.T) { + state := newEtcdSession() + if _, err := state.get(map[string]json.RawMessage{"key": json.RawMessage(`"k"`)}); err == nil || err.Error() != "Not connected" { + t.Fatalf("kv_get before connect: %v", err) + } + if _, err := state.history(map[string]json.RawMessage{"key": json.RawMessage(`"k"`)}); err == nil || err.Error() != "Not connected" { + t.Fatalf("kv_history before connect: %v", err) + } + if _, err := state.validateConnection(); err == nil || err.Error() != "Not connected" { + t.Fatalf("validate_connection before connect: %v", err) + } +} + +func TestConnectTimeoutBounds(t *testing.T) { + cases := []struct { + configured int + want int + }{ + {0, 30}, + {-5, 1}, + {1, 1}, + {30, 30}, + {300, 300}, + {999, 300}, + } + for _, testCase := range cases { + got := connectTimeoutSeconds(connectionParams{ConnectTimeoutSecs: testCase.configured}) + if got != testCase.want { + t.Fatalf("connectTimeoutSeconds(%d) = %d, want %d", testCase.configured, got, testCase.want) + } + } +} + +func TestGrpcMaxInboundMessageSize(t *testing.T) { + cases := []struct { + connection connectionParams + want int + }{ + {connectionParams{}, defaultGrpcMaxInboundMessageSize}, + {connectionParams{GrpcMaxInboundMessageSize: 1024}, minGrpcMaxInboundMessageSize}, + {connectionParams{GrpcMaxInboundMessageSize: 999 * 1024 * 1024}, maxGrpcMaxInboundMessageSize}, + {connectionParams{URLParams: "grpc_max_inbound_message_size=104857600"}, 100 * 1024 * 1024}, + {connectionParams{URLParams: "?grpc_max_inbound_message_size=64"}, minGrpcMaxInboundMessageSize}, + {connectionParams{URLParams: "grpc_max_inbound_message_size=abc"}, defaultGrpcMaxInboundMessageSize}, + {connectionParams{GrpcMaxInboundMessageSize: 52428800, URLParams: "grpc_max_inbound_message_size=1024"}, 50 * 1024 * 1024}, + } + for _, testCase := range cases { + got := grpcMaxInboundMessageSize(testCase.connection) + if got != testCase.want { + t.Fatalf("grpcMaxInboundMessageSize(%+v) = %d, want %d", testCase.connection, got, testCase.want) + } + } +} + +func TestConnectionEndpoints(t *testing.T) { + cases := []struct { + name string + connection connectionParams + want []string + }{ + {"host port fallback", connectionParams{Port: 2379}, []string{"http://127.0.0.1:2379"}}, + {"ssl scheme", connectionParams{Host: "db", Port: 1, SSL: true}, []string{"https://db:1"}}, + {"comma list", connectionParams{EtcdEndpoints: "a:1,b:2"}, []string{"http://a:1", "http://b:2"}}, + {"newline list", connectionParams{Endpoints: "a:1\nb:2"}, []string{"http://a:1", "http://b:2"}}, + {"precedence", connectionParams{EtcdEndpoints: "a:1", Endpoints: "b:2", ConnectionString: "c:3"}, []string{"http://a:1"}}, + {"existing scheme", connectionParams{EtcdEndpoints: "https://a:1,http://b:2"}, []string{"https://a:1", "http://b:2"}}, + {"blank entries", connectionParams{EtcdEndpoints: " a:1 , ,b:2"}, []string{"http://a:1", "http://b:2"}}, + } + for _, testCase := range cases { + got := connectionEndpoints(testCase.connection) + if strings.Join(got, "|") != strings.Join(testCase.want, "|") { + t.Fatalf("%s: got %v, want %v", testCase.name, got, testCase.want) + } + } +} + +func TestTLSConfigRequiresCertAndKeyTogether(t *testing.T) { + _, err := tlsConfigFor(connectionParams{SSL: true, ClientCertPath: "/tmp/cert.pem"}) + if err == nil || err.Error() != "Client certificate and key must be provided together" { + t.Fatalf("expected paired cert/key error, got %v", err) + } + _, err = tlsConfigFor(connectionParams{SSL: true, CertPath: "/tmp/cert.pem", KeyPath: "/tmp/key.pem"}) + if err == nil { + t.Fatalf("expected key pair load failure for missing files") + } +} + +func TestHistoryStartRevision(t *testing.T) { + cases := []struct { + requested *int64 + target int64 + want int64 + }{ + {nil, 5000, 1}, + {nil, 10000, 1}, + {nil, 10001, 2}, + {nil, 15000, 5001}, + {int64Ptr(200), 15000, 200}, + {int64Ptr(-5), 15000, 1}, + } + for _, testCase := range cases { + got := historyStartRevision(testCase.requested, testCase.target) + if got != testCase.want { + t.Fatalf("historyStartRevision(%v, %d) = %d, want %d", testCase.requested, testCase.target, got, testCase.want) + } + } +} + +func int64Ptr(value int64) *int64 { + return &value +} + +func TestHistoryCollectorBounds(t *testing.T) { + collector := &historyCollector{limit: 3} + for i := int64(1); i <= 5; i++ { + collector.append(map[string]any{"revision": longString(i)}) + } + if len(collector.rows) != 3 { + t.Fatalf("expected 3 retained rows, got %d", len(collector.rows)) + } + if !collector.truncated { + t.Fatalf("expected truncated flag") + } + if collector.rows[0]["revision"] != "3" || collector.rows[2]["revision"] != "5" { + t.Fatalf("expected newest rows retained, got %v", collector.rows) + } +} + +func TestPrefixEnd(t *testing.T) { + cases := []struct{ prefix, want string }{ + {"", "\x00"}, + {"abc", "abd"}, + {"a\xff", "b"}, + {"\xff\xff", "\x00"}, + {"a\xffb", "a\xffc"}, + } + for _, testCase := range cases { + if got := prefixEnd(testCase.prefix); got != testCase.want { + t.Fatalf("prefixEnd(%q) = %q, want %q", testCase.prefix, got, testCase.want) + } + } +} + +func TestValueEncodings(t *testing.T) { + utf8Value := valueObject([]byte("hello")) + if utf8Value["encoding"] != "utf8" || utf8Value["data"] != "hello" { + t.Fatalf("unexpected utf8 value: %#v", utf8Value) + } + binaryValue := valueObject([]byte{0xff, 0xfe}) + if binaryValue["encoding"] != "base64" { + t.Fatalf("expected base64 for binary: %#v", binaryValue) + } + roundTrip, err := parseValueObject(map[string]json.RawMessage{ + "encoding": json.RawMessage(`"base64"`), + "data": json.RawMessage(`"` + binaryValue["data"].(string) + `"`), + }) + if err != nil || roundTrip != string([]byte{0xff, 0xfe}) { + t.Fatalf("base64 round trip failed: %q %v", roundTrip, err) + } + if _, err := parseValueObject(map[string]json.RawMessage{"encoding": json.RawMessage(`"hex"`)}); err == nil { + t.Fatalf("expected unsupported encoding error") + } + if displayBytes([]byte{0xff}) != valueObject([]byte{0xff})["data"] { + t.Fatalf("displayBytes should fall back to base64") + } +} + +func TestNextContinuation(t *testing.T) { + encoded := nextContinuation([]byte("key")) + decoded, err := decodeBase64String(encoded) + if err != nil { + t.Fatalf("invalid continuation: %v", err) + } + if decoded != "key\x00" { + t.Fatalf("unexpected continuation start: %q", decoded) + } +} + +func decodeBase64String(value string) (string, error) { + decoded, err := base64Decode(value) + if err != nil { + return "", err + } + return string(decoded), nil +} + +func TestWatchEventBufferBytes(t *testing.T) { + item := testKeyValue(3, 10, 20) + previous := testKeyValue(2, 5, 7) + // 512 + 4*3 (key) + 4*10 (value) + 4*5 (previous value) = 584 + if got := watchEventBufferBytes(item, previous); got != 584 { + t.Fatalf("watchEventBufferBytes = %d, want 584", got) + } + previous.Version = 0 + if got := watchEventBufferBytes(item, previous); got != 564 { + t.Fatalf("zero-version previous ignored: %d, want 564", got) + } +} + +func TestWatchOverflowPreservesBufferedBatch(t *testing.T) { + session := newEtcdSession() + state := &watchState{watchID: "w", session: session} + smallBatch := []map[string]any{{"eventType": "put"}} + state.append(10, smallBatch, 128) + state.append(11, []map[string]any{{"eventType": "put"}}, maxWatchBufferBytes+1) + + // The buffered batch stays pollable; the terminal rides along once the + // buffer has fully drained within the same poll (Java parity). + polled := state.poll() + if len(polled["batches"].([]any)) != 1 { + t.Fatalf("overflow must keep the buffered batch pollable: %#v", polled) + } + terminal, ok := polled["terminal"].(map[string]any) + if !ok || terminal["reason"] != "overflow" { + t.Fatalf("expected overflow terminal after drain, got %#v", polled) + } + if terminal["message"] != "ETCD_WATCH_OVERFLOW: the event buffer reached its byte or event limit" { + t.Fatalf("unexpected overflow message: %v", terminal["message"]) + } + if session.watchBufferedBytesSnapshot() != 0 { + t.Fatalf("session budget not released: %d", session.watchBufferedBytesSnapshot()) + } +} + +func TestWatchPollReleasesSessionBudget(t *testing.T) { + session := newEtcdSession() + state := &watchState{watchID: "w", session: session} + state.append(1, []map[string]any{{"eventType": "put"}}, 1000) + if session.watchBufferedBytesSnapshot() != 1000 { + t.Fatalf("expected reserved budget 1000, got %d", session.watchBufferedBytesSnapshot()) + } + state.poll() + if session.watchBufferedBytesSnapshot() != 0 { + t.Fatalf("expected budget released after poll, got %d", session.watchBufferedBytesSnapshot()) + } +} + +func TestSessionAggregateBudgetTerminatesOnlyOffendingWatch(t *testing.T) { + session := newEtcdSession() + first := &watchState{watchID: "a", session: session} + second := &watchState{watchID: "b", session: session} + if !session.reserveWatchBuffer(maxSessionWatchBufferBytes / 2) { + t.Fatalf("first reservation should succeed") + } + second.append(1, []map[string]any{{"eventType": "put"}}, maxSessionWatchBufferBytes/2+1) + polled := second.poll() + terminal, ok := polled["terminal"].(map[string]any) + if !ok || terminal["reason"] != "overflow" { + t.Fatalf("expected aggregate overflow on second watch: %#v", polled) + } + if first.hasTerminal() { + t.Fatalf("first watch must stay healthy") + } + session.releaseWatchBuffer(maxSessionWatchBufferBytes / 2) +} + +func TestWatchPollRemovesTerminalWatch(t *testing.T) { + session := newEtcdSession() + state := &watchState{watchID: "w", session: session} + session.registerWatch("w", state) + state.fail("closed", "watch closed", nil) + params := map[string]json.RawMessage{"watchId": json.RawMessage(`"w"`)} + result, err := session.watchPoll(params) + if err != nil { + t.Fatalf("watchPoll failed: %v", err) + } + if _, ok := result.(map[string]any)["terminal"]; !ok { + t.Fatalf("expected terminal in poll result: %#v", result) + } + if session.watchCount() != 0 { + t.Fatalf("terminal watch must be removed from the session") + } +} + +func TestWatchPollUnknownID(t *testing.T) { + session := newEtcdSession() + _, err := session.watchPoll(map[string]json.RawMessage{"watchId": json.RawMessage(`"missing"`)}) + if err == nil || err.Error() != "ETCD_WATCH_NOT_FOUND: watch does not exist" { + t.Fatalf("expected watch-not-found, got %v", err) + } +} + +func TestWatchStartLimitReached(t *testing.T) { + session := newEtcdSession() + for i := 0; i < maxWatches; i++ { + session.registerWatch(string(rune('a'+i)), &watchState{watchID: string(rune('a' + i)), session: session}) + } + _, err := session.watchStart(map[string]json.RawMessage{"key": json.RawMessage(`"k"`)}) + if err == nil || !strings.HasPrefix(err.Error(), "ETCD_WATCH_LIMIT") { + t.Fatalf("expected watch limit error, got %v", err) + } +} + +func TestWatchScopeValidation(t *testing.T) { + session := newEtcdSession() + _, err := session.watchStart(map[string]json.RawMessage{"key": json.RawMessage(`"k"`), "scope": json.RawMessage(`"glob"`)}) + if err == nil || err.Error() != "ETCD_WATCH_SCOPE_INVALID: scope must be key or prefix" { + t.Fatalf("expected scope error, got %v", err) + } +} + +func TestLeasePageIDs(t *testing.T) { + ids := []uint64{30, 10, 20} + page := leasePageIDs(ids, nil, 2) + if page[0] != 10 || page[1] != 20 { + t.Fatalf("expected unsigned ascending order, got %v", page) + } + after := uint64(20) + page = leasePageIDs(ids, &after, 10) + if len(page) != 1 || page[0] != 30 { + t.Fatalf("expected only 30 after cursor 20, got %v", page) + } + // 2^63 boundary must compare unsigned, not signed. + big := []uint64{1, uint64(1) << 63} + page = leasePageIDs(big, nil, 2) + if page[0] != 1 || page[1] != uint64(1)<<63 { + t.Fatalf("unsigned ordering broken: %v", page) + } +} + +func TestParseUnsignedLong(t *testing.T) { + parsed, err := parseUnsignedLong("18446744073709551615") + if err != nil || parsed != 18446744073709551615 { + t.Fatalf("uint64 max parse failed: %d %v", parsed, err) + } + if _, err := parseUnsignedLong("nope"); err == nil { + t.Fatalf("expected parse failure") + } +} + +func TestAppendUnexecutedDefragMembers(t *testing.T) { + members := []map[string]any{{"endpoint": "a", "status": "failed"}} + appendUnexecutedDefragMembers(&members, []string{"a", "b", "c"}, "a") + if len(members) != 3 { + t.Fatalf("expected 3 rows, got %d", len(members)) + } + for _, row := range members[1:] { + if row["status"] != "not_executed" || row["durationMs"] != nil || row["error"] != nil { + t.Fatalf("unexpected not_executed row: %#v", row) + } + } +} + +func TestDefragRequiresEndpoints(t *testing.T) { + state := newEtcdSession() + _, err := state.defrag(map[string]json.RawMessage{}) + if err == nil || err.Error() != "ETCD_DEFRAG_TARGET_REQUIRED: at least one endpoint is required" { + t.Fatalf("expected defrag target error, got %v", err) + } +} + +func TestRequiredPositiveLongAndString(t *testing.T) { + _, err := requiredPositiveLong(map[string]json.RawMessage{"id": json.RawMessage(`0`)}, "id") + if err == nil || err.Error() != "ETCD_INVALID_ID: a positive integer is required" { + t.Fatalf("unexpected error: %v", err) + } + _, err = requiredString(map[string]json.RawMessage{}, "user") + if err == nil || err.Error() != "ETCD_USER_REQUIRED" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestKeyBytesParam(t *testing.T) { + if _, err := keyBytesParam(map[string]json.RawMessage{}); err == nil || err.Error() != "Key is required" { + t.Fatalf("expected Key is required, got %v", err) + } + encoded := base64Encode([]byte{0x01, 0x02}) + key, err := keyBytesParam(map[string]json.RawMessage{ + "keyBytes": json.RawMessage(`{"encoding":"base64","data":"` + encoded + `"}`), + }) + if err != nil || key != string([]byte{0x01, 0x02}) { + t.Fatalf("keyBytes decode failed: %q %v", key, err) + } +} + +func TestPutRejectsConflictingLeaseOptions(t *testing.T) { + state := newEtcdSession() + params := map[string]json.RawMessage{ + "key": json.RawMessage(`"k"`), + "value": json.RawMessage(`{"encoding":"utf8","data":"v"}`), + "ttl": json.RawMessage(`5`), + "lease": json.RawMessage(`7`), + } + // Java checks the active client before option exclusivity; without a + // connection the error must stay "Not connected". + _, err := state.put(params) + if err == nil || err.Error() != "Not connected" { + t.Fatalf("expected Not connected before option validation, got %v", err) + } +} + +func (s *etcdSession) watchBufferedBytesSnapshot() int64 { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + return s.watchBufferedBytes +} + +func (w *watchState) hasTerminal() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.terminalReason != "" +} + +func testKeyValue(keySize, valueSize, version int64) *mvccpb.KeyValue { + return &mvccpb.KeyValue{ + Key: bytesOfLength(keySize), + Value: bytesOfLength(valueSize), + Version: version, + } +} + +func bytesOfLength(size int64) []byte { + return make([]byte, size) +} + +func base64Decode(value string) ([]byte, error) { + return base64.StdEncoding.DecodeString(value) +} + +func base64Encode(value []byte) string { + return base64.StdEncoding.EncodeToString(value) +} diff --git a/agents/drivers/etcd-go/maintenance.go b/agents/drivers/etcd-go/maintenance.go new file mode 100644 index 0000000000..cb86924445 --- /dev/null +++ b/agents/drivers/etcd-go/maintenance.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "time" + + "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" + clientv3 "go.etcd.io/etcd/client/v3" +) + +func jsonUnmarshal(raw json.RawMessage, target any) error { + return json.Unmarshal(raw, target) +} + +func (s *etcdSession) compact(params map[string]json.RawMessage) (any, error) { + revision, err := requiredPositiveLong(params, "revision") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + _, err = client.Get(ctx, "\x00", clientv3.WithRange("\x00"), clientv3.WithCountOnly(), clientv3.WithRev(revision)) + cancel() + if err != nil { + if errors.Is(err, rpctypes.ErrCompacted) { + if compacted, ok := compactedRevisionOf(client, "\x00"); ok { + return nil, errors.New("ETCD_INVALID_REVISION: revision was already compacted at " + longString(compacted)) + } + return nil, errors.New("ETCD_INVALID_REVISION: revision was already compacted") + } + return nil, err + } + currentCtx, currentCancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + current, err := client.Get(currentCtx, "\x00", clientv3.WithRange("\x00"), clientv3.WithCountOnly()) + currentCancel() + if err != nil { + return nil, err + } + if revision > current.Header.Revision { + return nil, errors.New("ETCD_INVALID_REVISION: revision is newer than the current revision") + } + compactCtx, compactCancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + defer compactCancel() + if _, err := client.Compact(compactCtx, revision); err != nil { + return nil, err + } + return map[string]any{"revision": longString(revision)}, nil +} + +func (s *etcdSession) defrag(params map[string]json.RawMessage) (any, error) { + var targets []string + if raw := params["endpoints"]; len(raw) > 0 && string(raw) != "null" { + var list []string + if jsonUnmarshal(raw, &list) == nil { + for _, endpoint := range list { + if endpoint != "" && !containsString(targets, endpoint) { + targets = append(targets, endpoint) + } + } + } + } + if len(targets) == 0 { + return nil, errors.New("ETCD_DEFRAG_TARGET_REQUIRED: at least one endpoint is required") + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + + members := []map[string]any{} + remaining := targets + for len(remaining) > 0 { + endpoint, err := nextDefragEndpoint(client, remaining) + if err != nil { + return nil, err + } + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + _, defragErr := client.Maintenance.Defragment(ctx, endpoint) + cancel() + durationMs := time.Since(started).Milliseconds() + if defragErr != nil { + members = append(members, map[string]any{ + "endpoint": endpoint, + "status": "failed", + "durationMs": durationMs, + "error": defragErr.Error(), + }) + appendUnexecutedDefragMembers(&members, remaining, endpoint) + break + } + members = append(members, map[string]any{ + "endpoint": endpoint, + "status": "succeeded", + "durationMs": durationMs, + }) + remaining = removeString(remaining, endpoint) + } + return map[string]any{"members": members}, nil +} + +func appendUnexecutedDefragMembers(members *[]map[string]any, remaining []string, failedEndpoint string) { + for _, endpoint := range remaining { + if endpoint == failedEndpoint { + continue + } + *members = append(*members, map[string]any{ + "endpoint": endpoint, + "status": "not_executed", + "durationMs": nil, + "error": nil, + }) + } +} + +// nextDefragEndpoint re-evaluates leadership before each member so a leader +// change is not defragmented early. +func nextDefragEndpoint(client *clientv3.Client, remaining []string) (string, error) { + var leaderEndpoint string + for _, endpoint := range remaining { + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + statusResponse, err := client.Maintenance.Status(ctx, endpoint) + cancel() + if err != nil { + // The following defragment call reports this member as the failed step. + continue + } + if statusResponse.Header.MemberId == statusResponse.Leader { + leaderEndpoint = endpoint + break + } + } + for _, endpoint := range remaining { + if endpoint != leaderEndpoint { + return endpoint, nil + } + } + if leaderEndpoint == "" { + return remaining[0], nil + } + return leaderEndpoint, nil +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func removeString(values []string, target string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value != target { + result = append(result, value) + } + } + return result +} diff --git a/agents/drivers/etcd-go/protocol_error.go b/agents/drivers/etcd-go/protocol_error.go new file mode 100644 index 0000000000..1621ac0a19 --- /dev/null +++ b/agents/drivers/etcd-go/protocol_error.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data *rpcErrorData `json:"data,omitempty"` +} + +type rpcErrorData struct { + Category string `json:"category"` + Retryable bool `json:"retryable"` + SessionDisposition string `json:"sessionDisposition"` + Stage string `json:"stage"` + ContractVersion int `json:"contractVersion"` + OperationOutcome string `json:"operationOutcome"` + ExceptionClass string `json:"exceptionClass,omitempty"` + AgentSessionID string `json:"agentSessionId,omitempty"` +} + +func errorResponse(id json.RawMessage, method, agentSessionID string, err error) response { + return response{JSONRPC: "2.0", ID: id, Error: classifyRPCError(method, agentSessionID, err)} +} + +func classifyRPCError(method, agentSessionID string, err error) *rpcError { + stage := rpcErrorStage(method) + data := &rpcErrorData{ + Category: "protocol", + Retryable: false, + SessionDisposition: "keep", + Stage: stage, + ContractVersion: 1, + OperationOutcome: rpcOperationOutcome(stage), + ExceptionClass: safeRPCDiagnostic(errTypeName(err), 160), + AgentSessionID: strings.TrimSpace(agentSessionID), + } + + switch { + case errors.Is(err, context.Canceled): + data.Category = "canceled" + data.SessionDisposition = "quarantine" + case errors.Is(err, context.DeadlineExceeded) || isTimeoutError(err): + data.Category = "timeout" + data.SessionDisposition = "quarantine" + case isUnavailableError(err): + data.Category = "connection" + data.Retryable = stage == "connect" || stage == "validate" + if stage != "connect" { + data.SessionDisposition = "quarantine" + } + } + + return &rpcError{Code: -1, Message: err.Error(), Data: data} +} + +// isUnavailableError treats gRPC UNAVAILABLE (etcd server unreachable) as a +// connection failure, matching the Java agent's transport classification. +func isUnavailableError(err error) bool { + if status.Code(err) == codes.Unavailable { + return true + } + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { + return true + } + var networkError *net.OpError + if errors.As(err, &networkError) { + return true + } + lower := strings.ToLower(err.Error()) + for _, marker := range []string{ + "connection refused", "connection reset", "broken pipe", "connection closed", + "connection lost", "unexpected eof", "no route to host", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func errTypeName(err error) string { + if err == nil { + return "" + } + return strings.ReplaceAll(strings.TrimSpace(err.Error()), "\n", " ") +} + +func rpcErrorStage(method string) string { + switch method { + case "connect", "open_session", "test_connection": + return "connect" + case "validate_connection", "validate_session": + return "validate" + case "cancel_session": + return "cancel" + case "close_session", "disconnect", "shutdown": + return "close" + case "etcd_watch_poll", "etcd_lease_get": + return "fetch" + case "handshake", "": + return "request" + default: + return "execute" + } +} + +func rpcOperationOutcome(stage string) string { + switch stage { + case "request", "connect", "validate": + return "not_started" + default: + return "unknown" + } +} + +func isTimeoutError(err error) bool { + var timeout interface{ Timeout() bool } + return errors.As(err, &timeout) && timeout.Timeout() +} + +func safeRPCDiagnostic(value string, maxLength int) string { + var result strings.Builder + for _, char := range value { + if result.Len() >= maxLength { + break + } + if char >= 0x21 && char <= 0x7e { + result.WriteRune(char) + } + } + return result.String() +} diff --git a/agents/drivers/etcd-go/status.go b/agents/drivers/etcd-go/status.go new file mode 100644 index 0000000000..95f7d22726 --- /dev/null +++ b/agents/drivers/etcd-go/status.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "encoding/json" + "time" + + "go.etcd.io/etcd/api/v3/etcdserverpb" + clientv3 "go.etcd.io/etcd/client/v3" +) + +func (s *etcdSession) status(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + maintenance := client.Maintenance + membersByID := map[uint64]*etcdserverpb.Member{} + endpoints := s.connectedEndpointList() + memberCtx, memberCancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + memberList, err := client.Cluster.MemberList(memberCtx) + memberCancel() + if err == nil { + for _, member := range memberList.Members { + membersByID[member.ID] = member + for _, clientURL := range member.ClientURLs { + host := endpointHost(clientURL) + if host == "" || host == "0.0.0.0" || host == "::" { + continue + } + known := false + for _, existing := range endpoints { + if existing == clientURL { + known = true + break + } + } + if !known { + endpoints = append(endpoints, clientURL) + } + } + } + } + + alarms := []string{} + alarmCtx, alarmCancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + alarmResponse, err := maintenance.AlarmList(alarmCtx) + alarmCancel() + if err != nil { + alarms = append(alarms, "UNAVAILABLE: "+err.Error()) + } else { + for _, alarm := range alarmResponse.Alarms { + alarms = append(alarms, alarm.Alarm.String()+"@"+unsignedLongString(int64(alarm.MemberID))) + } + } + + countCtx, countCancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + countResponse, err := client.Get(countCtx, "\x00", clientv3.WithRange("\x00"), clientv3.WithCountOnly()) + countCancel() + if err != nil { + return nil, err + } + + type statusResult struct { + response *clientv3.StatusResponse + err error + started time.Time + } + statusRequests := make([]chan *statusResult, len(endpoints)) + for index, endpoint := range endpoints { + channel := make(chan *statusResult, 1) + statusRequests[index] = channel + go func(endpoint string, channel chan *statusResult) { + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + response, err := client.Maintenance.Status(ctx, endpoint) + cancel() + channel <- &statusResult{response: response, err: err, started: started} + }(endpoint, channel) + } + + statusMembers := []map[string]any{} + observedMemberIDs := map[string]struct{}{} + var clusterID *string + var leaderID *string + for index, channel := range statusRequests { + result := <-channel + row := map[string]any{"endpoint": endpoints[index]} + if result.err != nil { + row["memberId"] = nil + row["name"] = nil + row["version"] = nil + row["leaderId"] = nil + row["revision"] = nil + row["raftTerm"] = nil + row["raftIndex"] = nil + row["raftAppliedIndex"] = nil + row["dbSize"] = nil + row["dbSizeInUse"] = nil + row["learner"] = false + row["reachable"] = false + row["latencyMs"] = nil + row["errors"] = []string{result.err.Error()} + statusMembers = append(statusMembers, row) + continue + } + memberStatus := result.response + memberID := memberStatus.Header.MemberId + member := membersByID[memberID] + clusterIDValue := unsignedLongString(int64(memberStatus.Header.ClusterId)) + leaderIDValue := unsignedLongString(int64(memberStatus.Leader)) + clusterID = &clusterIDValue + leaderID = &leaderIDValue + row["memberId"] = unsignedLongString(int64(memberID)) + if member == nil { + row["name"] = nil + } else { + row["name"] = member.Name + } + row["version"] = memberStatus.Version + row["leaderId"] = unsignedLongString(int64(memberStatus.Leader)) + row["revision"] = longString(memberStatus.Header.Revision) + row["raftTerm"] = longString(int64(memberStatus.RaftTerm)) + row["raftIndex"] = longString(int64(memberStatus.RaftIndex)) + row["raftAppliedIndex"] = longString(int64(memberStatus.RaftAppliedIndex)) + row["dbSize"] = longString(memberStatus.DbSize) + row["dbSizeInUse"] = longString(memberStatus.DbSizeInUse) + row["learner"] = memberStatus.IsLearner + row["reachable"] = true + row["latencyMs"] = time.Since(result.started).Milliseconds() + memberIDKey := unsignedLongString(int64(memberID)) + if _, seen := observedMemberIDs[memberIDKey]; seen { + continue + } + observedMemberIDs[memberIDKey] = struct{}{} + statusMembers = append(statusMembers, row) + } + + result := map[string]any{ + "clusterId": clusterID, + "revision": longString(countResponse.Header.Revision), + "leaderId": leaderID, + "keyCount": longString(countResponse.Count), + "alarms": alarms, + "members": statusMembers, + } + return result, nil +} diff --git a/agents/drivers/etcd-go/watch.go b/agents/drivers/etcd-go/watch.go new file mode 100644 index 0000000000..19af8d97d6 --- /dev/null +++ b/agents/drivers/etcd-go/watch.go @@ -0,0 +1,366 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "go.etcd.io/etcd/api/v3/mvccpb" + clientv3 "go.etcd.io/etcd/client/v3" +) + +const ( + maxWatches = 4 + maxWatchBatches = 256 + maxWatchEvents = 10000 + maxWatchBufferBytes = int64(8 * 1024 * 1024) + maxSessionWatchBufferBytes = int64(16 * 1024 * 1024) +) + +type bufferedWatchBatch struct { + payload map[string]any + bufferedBytes int64 +} + +// watchState buffers watch events for the polling host. Terminal states are +// only surfaced after the buffer drains, mirroring the Java implementation. +type watchState struct { + watchID string + session *etcdSession + + mu sync.Mutex + batches []bufferedWatchBatch + eventCount int + bufferedBytes int64 + terminalReason string + terminalMessage string + compactedRevision *int64 + watchCancel context.CancelFunc + closed bool +} + +func (w *watchState) append(revision int64, events []map[string]any, batchBytes int64) { + w.mu.Lock() + defer w.mu.Unlock() + if w.terminalReason != "" { + return + } + if int64(len(w.batches)) >= maxWatchBatches || + int64(w.eventCount+len(events)) > maxWatchEvents || + batchBytes > maxWatchBufferBytes || + w.bufferedBytes+batchBytes > maxWatchBufferBytes || + !w.session.reserveWatchBuffer(batchBytes) { + w.overflowLocked() + return + } + w.batches = append(w.batches, bufferedWatchBatch{ + payload: map[string]any{"revision": longString(revision), "events": events}, + bufferedBytes: batchBytes, + }) + w.eventCount += len(events) + w.bufferedBytes += batchBytes +} + +func (w *watchState) overflowLocked() { + if w.terminalReason != "" { + return + } + w.terminalReason = "overflow" + w.terminalMessage = "ETCD_WATCH_OVERFLOW: the event buffer reached its byte or event limit" + w.closeWatcherLocked() +} + +func (w *watchState) fail(reason, message string, compacted *int64) { + w.mu.Lock() + defer w.mu.Unlock() + if w.terminalReason == "" { + w.terminalReason = reason + w.terminalMessage = message + w.compactedRevision = compacted + } +} + +func (w *watchState) poll() map[string]any { + w.mu.Lock() + defer w.mu.Unlock() + page := make([]any, 0, 64) + for len(w.batches) > 0 && len(page) < 64 { + batch := w.batches[0] + w.batches = w.batches[1:] + events := batch.payload["events"].([]map[string]any) + w.eventCount -= len(events) + w.bufferedBytes -= batch.bufferedBytes + w.session.releaseWatchBuffer(batch.bufferedBytes) + page = append(page, batch.payload) + } + result := map[string]any{"watchId": w.watchID, "batches": page} + if w.terminalReason != "" && len(w.batches) == 0 { + terminal := map[string]any{ + "reason": w.terminalReason, + "message": w.terminalMessage, + } + if w.compactedRevision != nil { + terminal["compactedRevision"] = longString(*w.compactedRevision) + } else { + terminal["compactedRevision"] = nil + } + result["terminal"] = terminal + } + return result +} + +func (w *watchState) close() { + w.mu.Lock() + w.failLocked("stopped", "watch stopped", nil) + w.clearBufferedLocked() + w.closed = true + cancel := w.watchCancel + w.watchCancel = nil + w.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (w *watchState) failLocked(reason, message string, compacted *int64) { + if w.terminalReason == "" { + w.terminalReason = reason + w.terminalMessage = message + w.compactedRevision = compacted + } +} + +func (w *watchState) clearBufferedLocked() { + if w.bufferedBytes > 0 { + w.session.releaseWatchBuffer(w.bufferedBytes) + } + w.batches = nil + w.eventCount = 0 + w.bufferedBytes = 0 +} + +func (w *watchState) attachWatcher(cancel context.CancelFunc) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed || w.terminalReason != "" { + cancel() + return + } + w.watchCancel = cancel +} + +func (w *watchState) closeWatcherLocked() { + cancel := w.watchCancel + w.watchCancel = nil + if cancel != nil { + go cancel() + } +} + +func watchEventBufferBytes(item, previous *mvccpb.KeyValue) int64 { + bytes := int64(512 + estimatedBufferedBytes(len(item.Key))) + bytes += estimatedBufferedBytes(len(item.Value)) + if previous != nil && previous.Version > 0 { + bytes += estimatedBufferedBytes(len(previous.Value)) + } + return bytes +} + +func estimatedBufferedBytes(sourceBytes int) int64 { + if sourceBytes < 0 { + return 0 + } + return int64(sourceBytes) * 4 +} + +func newWatchID() string { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return base64.StdEncoding.EncodeToString(bytes[:]) + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + hexed := hex.EncodeToString(bytes[:]) + return fmt.Sprintf("%s-%s-%s-%s-%s", hexed[0:8], hexed[8:12], hexed[12:16], hexed[16:20], hexed[20:32]) +} + +func (s *etcdSession) watchCount() int { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + return len(s.watches) +} + +func (s *etcdSession) registerWatch(id string, state *watchState) bool { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + if _, exists := s.watches[id]; exists { + return false + } + s.watches[id] = state + return true +} + +func (s *etcdSession) removeWatch(id string) *watchState { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + state := s.watches[id] + delete(s.watches, id) + return state +} + +func (s *etcdSession) watchStart(params map[string]json.RawMessage) (any, error) { + if s.watchCount() >= maxWatches { + return nil, fmt.Errorf("ETCD_WATCH_LIMIT: at most %d watches are allowed per connection", maxWatches) + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + scope := stringOrDefault(params, "scope", "key") + if scope != "key" && scope != "prefix" { + return nil, errors.New("ETCD_WATCH_SCOPE_INVALID: scope must be key or prefix") + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + requestedRevision := longOrNull(params, "startRevision") + var startedRevision int64 + if requestedRevision != nil && *requestedRevision > 0 { + startedRevision = *requestedRevision + } else { + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + response, err := client.Get(ctx, "\x00", clientv3.WithRange("\x00"), clientv3.WithCountOnly()) + cancel() + if err != nil { + return nil, err + } + startedRevision = response.Header.Revision + 1 + } + + watchID := newWatchID() + state := &watchState{watchID: watchID, session: s} + options := []clientv3.OpOption{clientv3.WithRev(startedRevision)} + if boolOrDefault(params, "includePrevKv", false) { + options = append(options, clientv3.WithPrevKV()) + } + if scope == "prefix" { + options = append(options, clientv3.WithRange(prefixEnd(key))) + } + watchCtx, watchCancel := context.WithCancel(context.Background()) + channel := client.Watch(watchCtx, key, options...) + go consumeWatchChannel(channel, state) + state.attachWatcher(watchCancel) + s.registerWatch(watchID, state) + return map[string]any{"watchId": watchID, "startedRevision": longString(startedRevision)}, nil +} + +func consumeWatchChannel(channel clientv3.WatchChan, state *watchState) { + for response := range channel { + if response.Err() != nil { + state.fail("error", response.Err().Error(), nil) + continue + } + if response.Canceled { + if response.CompactRevision > 0 { + compacted := response.CompactRevision + state.fail("compacted", "ETCD_COMPACTED", &compacted) + } else { + state.fail("closed", "watch closed", nil) + } + continue + } + if len(response.Events) == 0 { + continue + } + events := make([]map[string]any, 0, len(response.Events)) + bufferedBytes := int64(128) + overflowed := false + for _, event := range response.Events { + item := event.Kv + var previous *mvccpb.KeyValue + if event.PrevKv != nil { + previous = event.PrevKv + } + bufferedBytes += watchEventBufferBytes(item, previous) + if bufferedBytes > maxWatchBufferBytes { + state.failOverflow() + overflowed = true + break + } + events = append(events, watchEventRow(event, item, previous)) + } + if overflowed { + continue + } + state.append(response.Header.Revision, events, bufferedBytes) + } +} + +func (w *watchState) failOverflow() { + w.mu.Lock() + defer w.mu.Unlock() + w.overflowLocked() +} + +func watchEventRow(event *clientv3.Event, item, previous *mvccpb.KeyValue) map[string]any { + row := map[string]any{ + "eventType": eventType(event), + "revision": longString(item.ModRevision), + "key": displayBytes(item.Key), + "keyBytes": bytesObject(item.Key), + } + if event.Type == mvccpb.DELETE { + row["value"] = nil + } else { + row["value"] = valueObject(item.Value) + } + if previous != nil && previous.Version > 0 { + row["previousValue"] = valueObject(previous.Value) + } else { + row["previousValue"] = nil + } + if event.Type == mvccpb.DELETE && previous != nil { + row["metadata"] = metadataMap(previous) + } else { + row["metadata"] = metadataMap(item) + } + return row +} + +func eventType(event *clientv3.Event) string { + if event.Type == mvccpb.DELETE { + return "delete" + } + return "put" +} + +func (s *etcdSession) watchPoll(params map[string]json.RawMessage) (any, error) { + watchID := stringOrDefault(params, "watchId", "") + s.watchesMu.Lock() + state := s.watches[watchID] + s.watchesMu.Unlock() + if state == nil { + return nil, errors.New("ETCD_WATCH_NOT_FOUND: watch does not exist") + } + result := state.poll() + if _, terminal := result["terminal"]; terminal { + if removed := s.removeWatch(watchID); removed != nil { + removed.close() + } + } + return result, nil +} + +func (s *etcdSession) watchStop(params map[string]json.RawMessage) (any, error) { + if state := s.removeWatch(stringOrDefault(params, "watchId", "")); state != nil { + state.close() + } + return map[string]bool{"stopped": true}, nil +} diff --git a/agents/drivers/etcd/build.gradle b/agents/drivers/etcd/build.gradle deleted file mode 100644 index d37a950619..0000000000 --- a/agents/drivers/etcd/build.gradle +++ /dev/null @@ -1,11 +0,0 @@ -dependencies { - implementation 'com.google.code.gson:gson:2.12.1' - implementation 'io.etcd:jetcd-core:0.8.6' -} - -tasks.named('shadowJar') { - mergeServiceFiles() - manifest { - attributes('Agent-Label': 'etcd', 'Main-Class': 'com.dbx.agent.etcd.EtcdAgent') - } -} diff --git a/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java b/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java deleted file mode 100644 index db57ada6b3..0000000000 --- a/agents/drivers/etcd/src/main/java/com/dbx/agent/etcd/EtcdAgent.java +++ /dev/null @@ -1,1796 +0,0 @@ -package com.dbx.agent.etcd; - -import com.dbx.agent.AgentProtocol; -import com.dbx.agent.MultiSessionJsonRpcServer; -import com.dbx.agent.SessionRpcHandler; -import com.google.protobuf.CodedInputStream; -import com.google.protobuf.WireFormat; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import io.grpc.MethodDescriptor; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import io.grpc.netty.GrpcSslContexts; -import io.grpc.stub.ClientCalls; -import io.etcd.jetcd.ByteSequence; -import io.etcd.jetcd.Auth; -import io.etcd.jetcd.Client; -import io.etcd.jetcd.ClientBuilder; -import io.etcd.jetcd.Cluster; -import io.etcd.jetcd.KV; -import io.etcd.jetcd.KeyValue; -import io.etcd.jetcd.Lease; -import io.etcd.jetcd.Maintenance; -import io.etcd.jetcd.Watch; -import io.etcd.jetcd.cluster.Member; -import io.etcd.jetcd.cluster.MemberListResponse; -import io.etcd.jetcd.kv.TxnResponse; -import io.etcd.jetcd.lease.LeaseGrantResponse; -import io.etcd.jetcd.lease.LeaseTimeToLiveResponse; -import io.etcd.jetcd.api.VertxLeaseGrpc; -import io.etcd.jetcd.maintenance.AlarmMember; -import io.etcd.jetcd.maintenance.AlarmResponse; -import io.etcd.jetcd.maintenance.StatusResponse; -import io.etcd.jetcd.op.Cmp; -import io.etcd.jetcd.op.CmpTarget; -import io.etcd.jetcd.op.Op; -import io.etcd.jetcd.kv.DeleteResponse; -import io.etcd.jetcd.kv.GetResponse; -import io.etcd.jetcd.kv.PutResponse; -import io.etcd.jetcd.options.DeleteOption; -import io.etcd.jetcd.options.GetOption; -import io.etcd.jetcd.options.LeaseOption; -import io.etcd.jetcd.options.PutOption; -import io.etcd.jetcd.options.WatchOption; -import io.etcd.jetcd.watch.WatchEvent; -import io.etcd.jetcd.watch.WatchResponse; -import io.etcd.jetcd.auth.Permission; -import io.netty.handler.ssl.SslContext; -import io.netty.handler.ssl.SslContextBuilder; -import java.io.ByteArrayInputStream; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.lang.reflect.Field; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.time.Duration; -import java.util.Arrays; -import java.util.ArrayList; -import java.util.ArrayDeque; -import java.util.Base64; -import java.util.Collections; -import java.util.Deque; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Callable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.ConcurrentHashMap; -import java.util.UUID; - -public final class EtcdAgent { - private static final Gson GSON = new Gson(); - private static final int DEFAULT_LIMIT = 100; - private static final int RPC_TIMEOUT_SECONDS = 30; - static final int DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE = 32 * 1024 * 1024; - static final int MIN_GRPC_MAX_INBOUND_MESSAGE_SIZE = 1024 * 1024; - static final int MAX_GRPC_MAX_INBOUND_MESSAGE_SIZE = 256 * 1024 * 1024; - private static final String GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY = "grpc_max_inbound_message_size"; - private static final int PRESERVE_LEASE_MAX_ATTEMPTS = 3; - private static final long HISTORY_DEFAULT_REVISION_WINDOW = 10_000L; - private static final List CAPABILITIES = Collections.unmodifiableList(Arrays.asList( - AgentProtocol.CAPABILITY_CONNECT, - AgentProtocol.CAPABILITY_TEST_CONNECTION, - AgentProtocol.CAPABILITY_KV, - AgentProtocol.CAPABILITY_KV_TTL, - AgentProtocol.CAPABILITY_KV_CAS, - AgentProtocol.CAPABILITY_KV_LIST_VALUES, - AgentProtocol.CAPABILITY_KV_STATUS, - AgentProtocol.CAPABILITY_KV_HISTORY, - AgentProtocol.CAPABILITY_ETCD_COMPACTION, - AgentProtocol.CAPABILITY_ETCD_DEFRAG, - AgentProtocol.CAPABILITY_ETCD_WATCH, - AgentProtocol.CAPABILITY_ETCD_LEASE, - AgentProtocol.CAPABILITY_ETCD_AUTH, - AgentProtocol.CAPABILITY_MULTI_SESSION - )); - private static final int MAX_WATCHES = 4; - private static final int MAX_WATCH_BATCHES = 256; - private static final int MAX_WATCH_EVENTS = 10_000; - static final long MAX_WATCH_BUFFER_BYTES = 8L * 1024 * 1024; - static final long MAX_SESSION_WATCH_BUFFER_BYTES = 16L * 1024 * 1024; - private static final int MAX_LEASE_ATTACHED_KEYS = 256; - private static final int DEFAULT_LEASE_LIST_LIMIT = 100; - private static final int MAX_LEASE_LIST_LIMIT = 200; - private static final int LEASE_LIST_CONCURRENCY = 8; - private static final int LEASE_LIST_DEADLINE_SECONDS = 5; - private static final MethodDescriptor.Marshaller BYTE_ARRAY_MARSHALLER = new MethodDescriptor.Marshaller<>() { - @Override - public InputStream stream(byte[] value) { - return new ByteArrayInputStream(value); - } - - @Override - public byte[] parse(InputStream stream) { - try { - return stream.readAllBytes(); - } catch (IOException error) { - throw new IllegalStateException("Failed to read etcd gRPC response", error); - } - } - }; - private static final MethodDescriptor LEASES_METHOD = MethodDescriptor.newBuilder() - .setType(MethodDescriptor.MethodType.UNARY) - .setFullMethodName(MethodDescriptor.generateFullMethodName("etcdserverpb.Lease", "LeaseLeases")) - .setRequestMarshaller(BYTE_ARRAY_MARSHALLER) - .setResponseMarshaller(BYTE_ARRAY_MARSHALLER) - .build(); - private static final ThreadLocal CURRENT_SESSION = new ThreadLocal<>(); - private static final EtcdSessionState LEGACY_SESSION = new EtcdSessionState(); - - private EtcdAgent() { - } - - private static Object handshakeResult() { - return new HandshakeResult(AgentProtocol.MULTI_SESSION_PROTOCOL_VERSION, AgentProtocol.MULTI_SESSION_PROTOCOL_VERSION, CAPABILITIES); - } - - private static Object connect(JsonObject params) throws Exception { - EtcdSessionState state = sessionState(); - JsonObject connection = connectionObject(params); - Client nextClient = buildClient(connection); - try { - probeClient(nextClient, endpoints(connection)); - } catch (Exception error) { - nextClient.close(); - throw error; - } - closeClient(); - state.client = nextClient; - state.kv = nextClient.getKVClient(); - state.connectedEndpoints = endpoints(connection); - return Collections.singletonMap("ok", true); - } - - static Client buildClient(JsonObject connection) throws Exception { - List endpoints = endpoints(connection); - ClientBuilder builder = Client.builder() - .endpoints(endpoints.toArray(String[]::new)) - .connectTimeout(Duration.ofSeconds(connectTimeoutSeconds(connection))) - .maxInboundMessageSize(grpcMaxInboundMessageSize(connection)); - String username = stringOrEmpty(connection, "username"); - String password = stringOrEmpty(connection, "password"); - if (!username.isBlank()) { - builder.user(byteSequence(username)); - builder.password(byteSequence(password)); - } - if (boolOrDefault(connection, "ssl", false)) { - builder.sslContext(sslContext(connection)); - } - return builder.build(); - } - - static int connectTimeoutSeconds(JsonObject connection) { - return Math.min(300, Math.max(1, intOrDefault(connection, "connect_timeout_secs", RPC_TIMEOUT_SECONDS))); - } - - static int grpcMaxInboundMessageSize(JsonObject connection) { - int configured = intOrDefault( - connection, - GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY, - intUrlParamOrDefault( - stringOrEmpty(connection, "url_params"), - GRPC_MAX_INBOUND_MESSAGE_SIZE_KEY, - DEFAULT_GRPC_MAX_INBOUND_MESSAGE_SIZE - ) - ); - return Math.min(MAX_GRPC_MAX_INBOUND_MESSAGE_SIZE, Math.max(MIN_GRPC_MAX_INBOUND_MESSAGE_SIZE, configured)); - } - - private static Map validateConnectedClient() throws Exception { - EtcdSessionState state = sessionState(); - Client active = requireClient(); - return probeClient(active, state.connectedEndpoints); - } - - private static Map probeClient(Client candidate, List endpoints) throws Exception { - Maintenance maintenance = candidate.getMaintenanceClient(); - Exception lastFailure = null; - for (String endpoint : endpoints) { - try { - maintenance.statusMember(endpoint).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - Map result = new LinkedHashMap<>(); - result.put("ok", true); - result.put("endpoint", endpoint); - return result; - } catch (Exception error) { - Throwable cause = rootCause(error); - // A restricted etcd user may not be allowed to call Maintenance.Status. - // PERMISSION_DENIED still proves that the channel reached an etcd server. - if (Status.fromThrowable(cause).getCode() == Status.Code.PERMISSION_DENIED) { - Map result = new LinkedHashMap<>(); - result.put("ok", true); - result.put("endpoint", endpoint); - result.put("limited", true); - return result; - } - lastFailure = error; - } - } - if (lastFailure != null) { - throw lastFailure; - } - throw new IllegalStateException("No etcd endpoint configured"); - } - - static List endpoints(JsonObject connection) { - String configured = firstNonBlank( - stringOrNull(connection, "etcd_endpoints"), - stringOrNull(connection, "endpoints"), - stringOrNull(connection, "connection_string") - ); - List result = new ArrayList<>(); - if (configured != null) { - for (String endpoint : configured.split("[,\\n]")) { - String normalized = normalizeEndpoint(endpoint.trim(), boolOrDefault(connection, "ssl", false)); - if (!normalized.isBlank()) { - result.add(normalized); - } - } - } - if (result.isEmpty()) { - String host = stringOrDefault(connection, "host", "127.0.0.1"); - int port = intOrDefault(connection, "port", 2379); - result.add(normalizeEndpoint(host + ":" + port, boolOrDefault(connection, "ssl", false))); - } - return result; - } - - private static String normalizeEndpoint(String endpoint, boolean tls) { - if (endpoint.isBlank()) { - return ""; - } - if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) { - return endpoint; - } - return (tls ? "https://" : "http://") + endpoint; - } - - private static SslContext sslContext(JsonObject connection) throws Exception { - SslContextBuilder builder = GrpcSslContexts.forClient(); - String ca = stringOrEmpty(connection, "ca_cert_path"); - if (!ca.isBlank()) { - builder.trustManager(new File(ca)); - } - String cert = firstNonBlank(stringOrNull(connection, "client_cert_path"), stringOrNull(connection, "cert_path")); - String key = firstNonBlank(stringOrNull(connection, "client_key_path"), stringOrNull(connection, "key_path")); - if ((cert == null) != (key == null)) { - throw new IllegalArgumentException("Client certificate and key must be provided together"); - } - if (cert != null) { - builder.keyManager(new File(cert), new File(key)); - } - return builder.build(); - } - - private static Object listPrefix(JsonObject params) throws Exception { - KV active = requireKv(); - String prefix = stringOrDefault(params, "prefix", ""); - int limit = intOrDefault(params, "limit", DEFAULT_LIMIT); - Long revision = longOrNull(params, "revision"); - boolean includeValues = boolOrDefault(params, "includeValues", false); - String continuation = stringOrNull(params, "continuation"); - ByteSequence start = continuation == null || continuation.isBlank() - ? prefixStart(prefix) - : ByteSequence.from(Base64.getDecoder().decode(continuation)); - GetOption.Builder optionBuilder = GetOption.newBuilder() - .withRange(prefixEnd(byteSequence(prefix))) - .withLimit(Math.max(1, limit)) - .withSortField(GetOption.SortTarget.KEY) - .withSortOrder(GetOption.SortOrder.ASCEND); - if (revision != null && revision > 0) { - optionBuilder.withRevision(revision); - } - GetOption option = optionBuilder.build(); - GetResponse response = active.get(start, option).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - - List> keys = new ArrayList<>(); - List kvs = response.getKvs(); - for (KeyValue item : kvs) { - Map row = metadata(item); - row.put("key", displayBytes(item.getKey().getBytes())); - row.put("keyBytes", bytesObject(item.getKey().getBytes())); - if (includeValues) { - row.put("value", valueObject(item.getValue().getBytes())); - } - keys.add(row); - } - - Map result = new LinkedHashMap<>(); - result.put("keys", keys); - result.put("continuation", response.isMore() && !kvs.isEmpty() ? nextContinuation(kvs.get(kvs.size() - 1)) : null); - result.put("revision", longString(response.getHeader().getRevision())); - return result; - } - - private static Object get(JsonObject params) throws Exception { - KV active = requireKv(); - ByteSequence key = keyBytes(params); - Long revision = longOrNull(params, "revision"); - GetOption.Builder optionBuilder = GetOption.newBuilder(); - if (revision != null && revision > 0) { - optionBuilder.withRevision(revision); - } - GetResponse response = active.get(key, optionBuilder.build()).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - Map result = new LinkedHashMap<>(); - if (response.getKvs().isEmpty()) { - result.put("found", false); - result.put("key", null); - result.put("value", null); - result.put("metadata", null); - return result; - } - KeyValue item = response.getKvs().get(0); - result.put("found", true); - result.put("key", displayBytes(item.getKey().getBytes())); - result.put("keyBytes", bytesObject(item.getKey().getBytes())); - if (!boolOrDefault(params, "metadataOnly", false)) { - result.put("value", valueObject(item.getValue().getBytes())); - } else { - result.put("value", null); - } - result.put("metadata", metadataWithTtl(item)); - return result; - } - - private static Object put(JsonObject params) throws Exception { - KV active = requireKv(); - ByteSequence key = keyBytes(params); - byte[] value = parseValue(params.getAsJsonObject("value")); - Long expectedModRevision = longOrNull(params, "expectedModRevision"); - Long expectedCreateRevision = longOrNull(params, "expectedCreateRevision"); - JsonElement leaseElement = params.get("lease"); - JsonElement ttlElement = params.get("ttl"); - boolean hasLease = leaseElement != null && !leaseElement.isJsonNull(); - boolean hasTtl = ttlElement != null && !ttlElement.isJsonNull(); - boolean preserveLease = boolOrDefault(params, "preserveLease", false); - if ((hasLease && hasTtl) || (preserveLease && (hasLease || hasTtl))) { - throw new IllegalArgumentException("lease, ttl, and preserveLease cannot be specified together"); - } - if (preserveLease) { - long revision = putPreservingLease(active, key, ByteSequence.from(value)); - return Collections.singletonMap("revision", longString(revision)); - } - PutOption option = PutOption.DEFAULT; - long grantedLeaseId = 0; - if (hasTtl) { - long ttl = ttlElement.getAsLong(); - if (ttl <= 0) throw new IllegalArgumentException("ttl must be a positive integer"); - LeaseGrantResponse grant = requireClient().getLeaseClient().grant(ttl).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - grantedLeaseId = grant.getID(); - option = PutOption.newBuilder().withLeaseId(grantedLeaseId).build(); - } else if (hasLease) { - option = PutOption.newBuilder().withLeaseId(leaseElement.getAsLong()).build(); - } - final long leaseToCleanUp = grantedLeaseId; - final Lease leaseClient = leaseToCleanUp == 0 ? null : requireClient().getLeaseClient(); - final PutOption writeOption = option; - return cleanUpGrantedLeaseOnFailure(leaseToCleanUp, leaseClient, () -> { - if (expectedModRevision != null || expectedCreateRevision != null) { - List comparisons = new ArrayList<>(); - if (expectedModRevision != null) { - comparisons.add(new Cmp(key, Cmp.Op.EQUAL, CmpTarget.modRevision(expectedModRevision))); - } - if (expectedCreateRevision != null) { - comparisons.add(new Cmp(key, Cmp.Op.EQUAL, CmpTarget.createRevision(expectedCreateRevision))); - } - TxnResponse response = active.txn() - .If(comparisons.toArray(Cmp[]::new)) - .Then(Op.put(key, ByteSequence.from(value), writeOption)) - .commit() - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (!response.isSucceeded()) { - throw new IllegalStateException("ETCD_CAS_CONFLICT: key changed after it was loaded"); - } - return Collections.singletonMap("revision", longString(response.getHeader().getRevision())); - } - PutResponse response = active.put(key, ByteSequence.from(value), writeOption) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Collections.singletonMap("revision", longString(response.getHeader().getRevision())); - }); - } - - static T cleanUpGrantedLeaseOnFailure(long grantedLeaseId, Lease leaseClient, Callable write) throws Exception { - try { - return write.call(); - } catch (Exception error) { - if (grantedLeaseId != 0) { - try { - leaseClient.revoke(grantedLeaseId).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (Exception revokeError) { - error.addSuppressed(revokeError); - } - } - throw error; - } - } - - static long putPreservingLease(KV active, ByteSequence key, ByteSequence value) throws Exception { - for (int attempt = 0; attempt < PRESERVE_LEASE_MAX_ATTEMPTS; attempt++) { - GetResponse existing = active.get(key, GetOption.DEFAULT).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (existing.getKvs().isEmpty() || existing.getKvs().get(0).getLease() <= 0) { - throw new IllegalStateException("Cannot preserve lease: key does not exist or has no lease"); - } - - KeyValue current = existing.getKvs().get(0); - PutOption option = PutOption.builder().withLeaseId(current.getLease()).build(); - TxnResponse response = active.txn() - .If(new Cmp(key, Cmp.Op.EQUAL, CmpTarget.modRevision(current.getModRevision()))) - .Then(Op.put(key, value, option)) - .commit() - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (response.isSucceeded()) { - return response.getHeader().getRevision(); - } - } - - throw new IllegalStateException("Cannot preserve lease: key changed concurrently; retry the save"); - } - - private static Object delete(JsonObject params) throws Exception { - KV active = requireKv(); - ByteSequence key = keyBytes(params); - Long expectedModRevision = longOrNull(params, "expectedModRevision"); - if (expectedModRevision != null) { - TxnResponse txnResponse = active.txn() - .If(new Cmp(key, Cmp.Op.EQUAL, CmpTarget.modRevision(expectedModRevision))) - .Then(Op.delete(key, DeleteOption.DEFAULT)) - .commit() - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (!txnResponse.isSucceeded()) { - throw new IllegalStateException("ETCD_CAS_CONFLICT: key changed after it was loaded"); - } - Map result = new LinkedHashMap<>(); - result.put("deleted", 1); - result.put("revision", longString(txnResponse.getHeader().getRevision())); - return result; - } - DeleteResponse response = active.delete(key).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - Map result = new LinkedHashMap<>(); - result.put("deleted", response.getDeleted()); - result.put("revision", longString(response.getHeader().getRevision())); - return result; - } - - private static Object rename(JsonObject params) throws Exception { - KV active = requireKv(); - ByteSequence sourceKey = keyBytes(params); - ByteSequence targetKey = byteSequence(params.get("newKey").getAsString()); - if (Arrays.equals(sourceKey.getBytes(), targetKey.getBytes())) { - Map unchanged = new LinkedHashMap<>(); - unchanged.put("renamed", true); - unchanged.put("revision", null); - return unchanged; - } - - GetResponse sourceResponse = active.get(sourceKey).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (sourceResponse.getKvs().isEmpty()) { - throw new IllegalStateException("ETCD_NOT_FOUND: source key does not exist"); - } - KeyValue source = sourceResponse.getKvs().get(0); - Long expected = longOrNull(params, "expectedModRevision"); - long expectedRevision = expected == null ? source.getModRevision() : expected; - PutOption putOption = source.getLease() == 0 - ? PutOption.DEFAULT - : PutOption.newBuilder().withLeaseId(source.getLease()).build(); - - TxnResponse response = active.txn() - .If( - new Cmp(sourceKey, Cmp.Op.EQUAL, CmpTarget.modRevision(expectedRevision)), - new Cmp(targetKey, Cmp.Op.EQUAL, CmpTarget.createRevision(0)) - ) - .Then( - Op.put(targetKey, source.getValue(), putOption), - Op.delete(sourceKey, DeleteOption.DEFAULT) - ) - .commit() - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (!response.isSucceeded()) { - throw new IllegalStateException("ETCD_CAS_CONFLICT: source changed or target already exists"); - } - Map result = new LinkedHashMap<>(); - result.put("renamed", true); - result.put("revision", longString(response.getHeader().getRevision())); - return result; - } - - private static Object history(JsonObject params) throws Exception { - requireKv(); - ByteSequence key = keyBytes(params); - int limit = Math.min(Math.max(1, intOrDefault(params, "limit", 100)), 500); - Long requestedEnd = longOrNull(params, "endRevision"); - GetOption.Builder latestOption = GetOption.newBuilder(); - if (requestedEnd != null && requestedEnd > 0) { - latestOption.withRevision(requestedEnd); - } - GetResponse latest = sessionState().kv.get(key, latestOption.build()).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - long endRevision = requestedEnd == null ? latest.getHeader().getRevision() : requestedEnd; - long targetKeyRevision = latest.getKvs().isEmpty() - ? endRevision - : latest.getKvs().get(0).getModRevision(); - Long requestedStart = longOrNull(params, "startRevision"); - long startRevision = historyStartRevision(requestedStart, targetKeyRevision); - if (startRevision > targetKeyRevision) { - return Map.of( - "events", Collections.emptyList(), - "observedRevision", longString(endRevision), - "truncated", false - ); - } - - Deque> events = new ArrayDeque<>(limit); - AtomicBoolean truncated = new AtomicBoolean(requestedStart == null && startRevision > 1L); - AtomicReference failure = new AtomicReference<>(); - CountDownLatch created = new CountDownLatch(1); - CountDownLatch completed = new CountDownLatch(1); - Watch watch = sessionState().client.getWatchClient(); - WatchOption option = WatchOption.newBuilder() - .withRevision(startRevision) - .withPrevKV(true) - .withProgressNotify(true) - .withCreateNotify(true) - .build(); - - Watch.Watcher watcher = watch.watch(key, option, new Watch.Listener() { - @Override - public void onNext(WatchResponse response) { - if (response.isCreatedNotify()) { - created.countDown(); - } - for (WatchEvent event : response.getEvents()) { - KeyValue item = event.getKeyValue(); - long revision = item.getModRevision(); - if (revision > endRevision) { - continue; - } - Map row = new LinkedHashMap<>(); - row.put("eventType", event.getEventType() == WatchEvent.EventType.DELETE ? "delete" : "put"); - row.put("revision", longString(revision)); - row.put( - "value", - event.getEventType() == WatchEvent.EventType.DELETE - ? null - : valueObject(item.getValue().getBytes()) - ); - KeyValue previous = event.getPrevKV(); - row.put( - "previousValue", - previous != null && previous.getVersion() > 0 - ? valueObject(previous.getValue().getBytes()) - : null - ); - row.put( - "metadata", - event.getEventType() == WatchEvent.EventType.DELETE && previous != null - ? metadata(previous) - : metadata(item) - ); - appendBoundedHistory(events, row, limit, truncated); - if (revision >= targetKeyRevision) { - completed.countDown(); - } - } - if (response.isProgressNotify() && response.getHeader().getRevision() >= endRevision) { - completed.countDown(); - } - } - - @Override - public void onError(Throwable throwable) { - failure.set(throwable); - completed.countDown(); - } - - @Override - public void onCompleted() { - completed.countDown(); - } - }); - - try { - if (!created.await(5, TimeUnit.SECONDS)) { - throw new IllegalStateException("ETCD_HISTORY_TIMEOUT: watcher was not created"); - } - // For an existing exact key, its latest mod revision is an explicit - // replay boundary. This avoids relying on progress notifications, - // which older etcd/jetcd combinations do not consistently emit. - watcher.requestProgress(); - if (!completed.await(15, TimeUnit.SECONDS)) { - throw new IllegalStateException("ETCD_HISTORY_TIMEOUT: history replay did not reach the requested revision"); - } - } finally { - watcher.close(); - } - if (failure.get() != null) { - Throwable cause = rootCause(failure.get()); - if (cause instanceof io.etcd.jetcd.common.exception.CompactedException compacted) { - throw new IllegalStateException( - "ETCD_COMPACTED: requested history was compacted at revision " + compacted.getCompactedRevision() - ); - } - throw new IllegalStateException("ETCD_HISTORY_FAILED: " + safeMessage(cause)); - } - - List> page; - synchronized (events) { - page = new ArrayList<>(events); - } - page.sort(Comparator.comparingLong(row -> -Long.parseLong((String) row.get("revision")))); - Map result = new LinkedHashMap<>(); - result.put("events", page); - result.put("observedRevision", longString(endRevision)); - result.put("truncated", truncated.get()); - return result; - } - - static long historyStartRevision(Long requestedStart, long targetRevision) { - if (requestedStart != null) { - return Math.max(1L, requestedStart); - } - return Math.max(1L, targetRevision - HISTORY_DEFAULT_REVISION_WINDOW + 1L); - } - - static void appendBoundedHistory( - Deque events, - T event, - int limit, - AtomicBoolean truncated - ) { - synchronized (events) { - if (events.size() == limit) { - events.removeFirst(); - truncated.set(true); - } - events.addLast(event); - } - } - - private static Object status() throws Exception { - requireKv(); - Maintenance maintenance = sessionState().client.getMaintenanceClient(); - Cluster cluster = sessionState().client.getClusterClient(); - Map membersById = new HashMap<>(); - List endpoints = new ArrayList<>(sessionState().connectedEndpoints); - try { - MemberListResponse memberList = cluster.listMember().get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - for (Member member : memberList.getMembers()) { - membersById.put(member.getId(), member); - for (java.net.URI uri : member.getClientURIs()) { - if (uri.getHost() == null || "0.0.0.0".equals(uri.getHost()) || "::".equals(uri.getHost())) { - continue; - } - String endpoint = uri.toString(); - if (!endpoints.contains(endpoint)) { - endpoints.add(endpoint); - } - } - } - } catch (Exception ignored) { - // Status still provides useful information for configured endpoints. - } - - List alarms = new ArrayList<>(); - try { - AlarmResponse alarmResponse = maintenance.listAlarms().get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - for (AlarmMember alarm : alarmResponse.getAlarms()) { - alarms.add(alarm.getAlarmType().name() + "@" + Long.toUnsignedString(alarm.getMemberId())); - } - } catch (Exception error) { - alarms.add("UNAVAILABLE: " + safeMessage(rootCause(error))); - } - - GetOption countOption = GetOption.newBuilder() - .withRange(ByteSequence.from(new byte[] {0})) - .withCountOnly(true) - .build(); - GetResponse countResponse = sessionState().kv.get(ByteSequence.from(new byte[] {0}), countOption) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - - List> statusMembers = new ArrayList<>(); - Set observedMemberIds = new HashSet<>(); - String clusterId = null; - String revision = longString(countResponse.getHeader().getRevision()); - String leaderId = null; - Map> statusRequests = new LinkedHashMap<>(); - Map statusStartedNanos = new LinkedHashMap<>(); - for (String endpoint : endpoints) { - statusStartedNanos.put(endpoint, System.nanoTime()); - statusRequests.put(endpoint, maintenance.statusMember(endpoint)); - } - for (String endpoint : endpoints) { - Map row = new LinkedHashMap<>(); - row.put("endpoint", endpoint); - long started = statusStartedNanos.get(endpoint); - try { - StatusResponse memberStatus = statusRequests.get(endpoint) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - long latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); - long memberId = memberStatus.getHeader().getMemberId(); - Member member = membersById.get(memberId); - clusterId = unsignedLongString(memberStatus.getHeader().getClusterId()); - leaderId = unsignedLongString(memberStatus.getLeader()); - row.put("memberId", unsignedLongString(memberId)); - row.put("name", member == null ? null : member.getName()); - row.put("version", memberStatus.getVersion()); - row.put("leaderId", unsignedLongString(memberStatus.getLeader())); - row.put("revision", longString(memberStatus.getHeader().getRevision())); - row.put("raftTerm", longString(memberStatus.getRaftTerm())); - row.put("raftIndex", longString(memberStatus.getRaftIndex())); - row.put("raftAppliedIndex", longString(memberStatus.getRaftAppliedIndex())); - row.put("dbSize", longString(memberStatus.getDbSize())); - row.put("dbSizeInUse", longString(memberStatus.getDbSizeInUse())); - row.put("learner", memberStatus.isLearner()); - row.put("reachable", true); - row.put("latencyMs", latencyMs); - row.put("errors", memberStatus.getErrorList()); - if (!observedMemberIds.add(unsignedLongString(memberId))) { - continue; - } - } catch (Exception error) { - statusRequests.get(endpoint).cancel(true); - row.put("memberId", null); - row.put("name", null); - row.put("version", null); - row.put("leaderId", null); - row.put("revision", null); - row.put("raftTerm", null); - row.put("raftIndex", null); - row.put("raftAppliedIndex", null); - row.put("dbSize", null); - row.put("dbSizeInUse", null); - row.put("learner", false); - row.put("reachable", false); - row.put("latencyMs", null); - row.put("errors", List.of(safeMessage(rootCause(error)))); - } - statusMembers.add(row); - } - - Map result = new LinkedHashMap<>(); - result.put("clusterId", clusterId); - result.put("revision", revision); - result.put("leaderId", leaderId); - result.put("keyCount", longString(countResponse.getCount())); - result.put("alarms", alarms); - result.put("members", statusMembers); - return result; - } - - private static Object compact(JsonObject params) throws Exception { - long revision = requiredPositiveLong(params, "revision"); - try { - requireKv().get(ByteSequence.from(new byte[] {0}), GetOption.newBuilder() - .withRange(ByteSequence.from(new byte[] {0})) - .withCountOnly(true) - .withRevision(revision) - .build()) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (Exception error) { - Throwable cause = rootCause(error); - if (cause instanceof io.etcd.jetcd.common.exception.CompactedException compacted) { - throw new IllegalArgumentException( - "ETCD_INVALID_REVISION: revision was already compacted at " + compacted.getCompactedRevision() - ); - } - throw error; - } - GetResponse current = requireKv().get(ByteSequence.from(new byte[] {0}), GetOption.newBuilder() - .withRange(ByteSequence.from(new byte[] {0})).withCountOnly(true).build()) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (revision > current.getHeader().getRevision()) { - throw new IllegalArgumentException("ETCD_INVALID_REVISION: revision is newer than the current revision"); - } - requireKv().compact(revision).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("revision", longString(revision)); - } - - private static Object defrag(JsonObject params) throws Exception { - JsonElement endpointsValue = params.get("endpoints"); - if (endpointsValue == null || !endpointsValue.isJsonArray() || endpointsValue.getAsJsonArray().isEmpty()) { - throw new IllegalArgumentException("ETCD_DEFRAG_TARGET_REQUIRED: at least one endpoint is required"); - } - Maintenance maintenance = requireClient().getMaintenanceClient(); - List> members = new ArrayList<>(); - List remaining = new ArrayList<>(); - for (JsonElement value : endpointsValue.getAsJsonArray()) { - String endpoint = value.getAsString(); - if (!endpoint.isBlank() && !remaining.contains(endpoint)) remaining.add(endpoint); - } - while (!remaining.isEmpty()) { - String endpoint = nextDefragEndpoint(maintenance, remaining); - long started = System.nanoTime(); - Map row = new LinkedHashMap<>(); - row.put("endpoint", endpoint); - try { - maintenance.defragmentMember(endpoint).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - row.put("status", "succeeded"); - row.put("durationMs", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - } catch (Exception error) { - row.put("status", "failed"); - row.put("durationMs", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); - row.put("error", safeMessage(rootCause(error))); - members.add(row); - appendUnexecutedDefragMembers(members, remaining, endpoint); - break; - } - members.add(row); - remaining.remove(endpoint); - } - return Map.of("members", members); - } - - static void appendUnexecutedDefragMembers( - List> members, - List remaining, - String failedEndpoint - ) { - for (String endpoint : remaining) { - if (endpoint.equals(failedEndpoint)) continue; - Map row = new LinkedHashMap<>(); - row.put("endpoint", endpoint); - row.put("status", "not_executed"); - row.put("durationMs", null); - row.put("error", null); - members.add(row); - } - } - - /** Re-evaluate leadership before each member so a leader change is not defragmented early. */ - private static String nextDefragEndpoint(Maintenance maintenance, List remaining) throws Exception { - String leaderEndpoint = null; - for (String endpoint : remaining) { - try { - StatusResponse status = maintenance.statusMember(endpoint).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (status.getHeader().getMemberId() == status.getLeader()) { - leaderEndpoint = endpoint; - break; - } - } catch (Exception ignored) { - // The following defragment call reports this member as the failed step. - } - } - for (String endpoint : remaining) { - if (!endpoint.equals(leaderEndpoint)) return endpoint; - } - return leaderEndpoint == null ? remaining.get(0) : leaderEndpoint; - } - - private static Object watchStart(JsonObject params) throws Exception { - EtcdSessionState session = sessionState(); - if (session.watches.size() >= MAX_WATCHES) { - throw new IllegalStateException("ETCD_WATCH_LIMIT: at most " + MAX_WATCHES + " watches are allowed per connection"); - } - ByteSequence key = keyBytes(params); - String scope = stringOrDefault(params, "scope", "key"); - if (!"key".equals(scope) && !"prefix".equals(scope)) { - throw new IllegalArgumentException("ETCD_WATCH_SCOPE_INVALID: scope must be key or prefix"); - } - Long requestedRevision = longOrNull(params, "startRevision"); - long startedRevision; - if (requestedRevision != null && requestedRevision > 0) { - startedRevision = requestedRevision; - } else { - GetResponse response = requireKv().get(ByteSequence.from(new byte[] {0}), GetOption.newBuilder() - .withRange(ByteSequence.from(new byte[] {0})).withCountOnly(true).build()) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - startedRevision = response.getHeader().getRevision() + 1; - } - String watchId = UUID.randomUUID().toString(); - EtcdWatchState state = new EtcdWatchState(watchId, session); - WatchOption.Builder option = WatchOption.newBuilder().withRevision(startedRevision) - .withPrevKV(boolOrDefault(params, "includePrevKv", false)); - if ("prefix".equals(scope)) option.withRange(prefixEnd(key)); - try { - Watch.Watcher watcher = requireClient().getWatchClient().watch(key, option.build(), new Watch.Listener() { - @Override - public void onNext(WatchResponse response) { - if (response.getEvents().isEmpty()) return; - List> events = new ArrayList<>(); - long bufferedBytes = 128; - for (WatchEvent event : response.getEvents()) { - KeyValue item = event.getKeyValue(); - KeyValue previous = event.getPrevKV(); - bufferedBytes += watchEventBufferBytes(item, previous); - if (bufferedBytes > MAX_WATCH_BUFFER_BYTES) { - state.overflow(); - return; - } - Map row = new LinkedHashMap<>(); - row.put("eventType", event.getEventType() == WatchEvent.EventType.DELETE ? "delete" : "put"); - row.put("revision", longString(item.getModRevision())); - row.put("key", displayBytes(item.getKey().getBytes())); - row.put("keyBytes", bytesObject(item.getKey().getBytes())); - row.put("value", event.getEventType() == WatchEvent.EventType.DELETE ? null : valueObject(item.getValue().getBytes())); - row.put("previousValue", previous != null && previous.getVersion() > 0 ? valueObject(previous.getValue().getBytes()) : null); - row.put("metadata", event.getEventType() == WatchEvent.EventType.DELETE && previous != null ? metadata(previous) : metadata(item)); - events.add(row); - } - state.append(response.getHeader().getRevision(), events, bufferedBytes); - } - - @Override - public void onError(Throwable error) { - Throwable cause = rootCause(error); - if (cause instanceof io.etcd.jetcd.common.exception.CompactedException compacted) { - state.fail("compacted", "ETCD_COMPACTED", compacted.getCompactedRevision()); - } else { - state.fail("error", safeMessage(cause), null); - } - } - - @Override - public void onCompleted() { - state.fail("closed", "watch closed", null); - } - }); - state.setWatcher(watcher); - session.watches.put(watchId, state); - } catch (Exception error) { - state.close(); - throw error; - } - return Map.of("watchId", watchId, "startedRevision", longString(startedRevision)); - } - - private static Object watchPoll(JsonObject params) throws Exception { - return pollWatchState(sessionState(), stringOrEmpty(params, "watchId")); - } - - static Map pollWatchState(EtcdSessionState session, String watchId) { - EtcdWatchState state = session.watches.get(watchId); - if (state == null) throw new IllegalStateException("ETCD_WATCH_NOT_FOUND: watch does not exist"); - Map result = state.poll(); - if (result.containsKey("terminal") && session.watches.remove(watchId, state)) { - state.close(); - } - return result; - } - - private static Object watchStop(JsonObject params) { - EtcdWatchState state = sessionState().watches.remove(stringOrEmpty(params, "watchId")); - if (state != null) state.close(); - return Map.of("stopped", true); - } - - private static Object leaseList(JsonObject params) throws Exception { - long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(LEASE_LIST_DEADLINE_SECONDS); - int limit = Math.min(Math.max(1, intOrDefault(params, "limit", DEFAULT_LEASE_LIST_LIMIT)), MAX_LEASE_LIST_LIMIT); - String continuation = stringOrNull(params, "continuation"); - Long afterLeaseId = continuation == null || continuation.isBlank() - ? null - : Long.parseUnsignedLong(continuation); - boolean partial = false; - List leaseIds; - try { - leaseIds = clusterLeaseIds(remainingMillis(deadlineNanos)); - sessionState().knownLeases.addAll(leaseIds); - } catch (ReflectiveOperationException error) { - leaseIds = new ArrayList<>(sessionState().knownLeases); - partial = true; - } catch (java.util.concurrent.TimeoutException error) { - leaseIds = new ArrayList<>(sessionState().knownLeases); - partial = true; - } catch (StatusRuntimeException error) { - if (error.getStatus().getCode() != Status.Code.UNIMPLEMENTED - && error.getStatus().getCode() != Status.Code.DEADLINE_EXCEEDED) { - throw error; - } - leaseIds = new ArrayList<>(sessionState().knownLeases); - partial = true; - } - leaseIds = leasePageIds(leaseIds, afterLeaseId, limit + 1); - boolean hasMore = leaseIds.size() > limit; - List pageIds = new ArrayList<>(leaseIds.subList(0, Math.min(limit, leaseIds.size()))); - - List> leases = new ArrayList<>(); - Long lastProcessedId = null; - boolean deadlineReached = false; - outer: - for (int offset = 0; offset < pageIds.size(); offset += LEASE_LIST_CONCURRENCY) { - List chunkIds = pageIds.subList(offset, Math.min(offset + LEASE_LIST_CONCURRENCY, pageIds.size())); - Map> requests = new LinkedHashMap<>(); - for (Long id : chunkIds) { - requests.put(id, requireClient().getLeaseClient().timeToLive(id, LeaseOption.DEFAULT)); - } - for (Map.Entry> request : requests.entrySet()) { - try { - LeaseTimeToLiveResponse response = request.getValue().get( - remainingMillis(deadlineNanos), - TimeUnit.MILLISECONDS - ); - Map row = new LinkedHashMap<>(); - row.put("id", longString(response.getID())); - row.put("ttl", response.getTTL()); - row.put("grantedTtl", response.getGrantedTTL()); - leases.add(row); - lastProcessedId = request.getKey(); - } catch (java.util.concurrent.TimeoutException error) { - requests.values().forEach(future -> future.cancel(true)); - partial = true; - deadlineReached = true; - break outer; - } catch (Exception error) { - Throwable cause = rootCause(error); - if (Status.fromThrowable(cause).getCode() == Status.Code.NOT_FOUND) { - sessionState().knownLeases.remove(request.getKey()); - } else { - partial = true; - } - lastProcessedId = request.getKey(); - } - } - } - String nextContinuation = null; - if (deadlineReached && !pageIds.isEmpty()) { - nextContinuation = unsignedLongString(lastProcessedId != null ? lastProcessedId : afterLeaseId != null ? afterLeaseId : 0L); - } else if (hasMore && !pageIds.isEmpty()) { - nextContinuation = unsignedLongString(pageIds.get(pageIds.size() - 1)); - } - Map result = new LinkedHashMap<>(); - result.put("leases", leases); - result.put("partial", partial); - result.put("nextContinuation", nextContinuation); - return result; - } - - static List leasePageIds(List leaseIds, Long afterLeaseId, int fetchLimit) { - return leaseIds.stream() - .sorted(Long::compareUnsigned) - .filter(id -> afterLeaseId == null || Long.compareUnsigned(id, afterLeaseId) > 0) - .limit(Math.max(0, fetchLimit)) - .toList(); - } - - private static long remainingMillis(long deadlineNanos) throws java.util.concurrent.TimeoutException { - long remaining = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime()); - if (remaining <= 0) throw new java.util.concurrent.TimeoutException("ETCD_LEASE_LIST_TIMEOUT"); - return remaining; - } - - private static List clusterLeaseIds(long timeoutMillis) throws Exception { - VertxLeaseGrpc.LeaseVertxStub stub = leaseStub(requireClient().getLeaseClient()); - byte[] response = ClientCalls.blockingUnaryCall( - stub.getChannel(), - LEASES_METHOD, - stub.getCallOptions().withDeadlineAfter(timeoutMillis, TimeUnit.MILLISECONDS), - new byte[0] - ); - return leaseIdsFromResponse(response); - } - - static List leaseIdsFromResponse(byte[] response) throws IOException { - List ids = new ArrayList<>(); - CodedInputStream input = CodedInputStream.newInstance(response); - while (!input.isAtEnd()) { - int tag = input.readTag(); - if (tag == 0) break; - if (WireFormat.getTagFieldNumber(tag) != 2 || WireFormat.getTagWireType(tag) != WireFormat.WIRETYPE_LENGTH_DELIMITED) { - input.skipField(tag); - continue; - } - CodedInputStream lease = CodedInputStream.newInstance(input.readByteArray()); - while (!lease.isAtEnd()) { - int leaseTag = lease.readTag(); - if (leaseTag == 0) break; - if (WireFormat.getTagFieldNumber(leaseTag) == 1 && WireFormat.getTagWireType(leaseTag) == WireFormat.WIRETYPE_VARINT) { - ids.add(lease.readInt64()); - } else { - lease.skipField(leaseTag); - } - } - } - return ids; - } - - private static Object leaseGet(JsonObject params) throws Exception { - long id = requiredPositiveLong(params, "id"); - LeaseOption option = boolOrDefault(params, "includeKeys", false) ? LeaseOption.newBuilder().withAttachedKeys().build() : LeaseOption.DEFAULT; - LeaseTimeToLiveResponse response = requireClient().getLeaseClient().timeToLive(id, option) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - sessionState().knownLeases.add(id); - List> keys = new ArrayList<>(); - List attachedKeys = response.getKeys(); - for (int index = 0; index < attachedKeys.size() && index < MAX_LEASE_ATTACHED_KEYS; index++) { - keys.add(bytesObject(attachedKeys.get(index).getBytes())); - } - Map result = new LinkedHashMap<>(); - result.put("id", longString(response.getID())); - result.put("ttl", response.getTTL()); - result.put("grantedTtl", response.getGrantedTTL()); - result.put("keys", keys); - result.put("truncated", attachedKeys.size() > MAX_LEASE_ATTACHED_KEYS); - return result; - } - - private static Object leaseGrant(JsonObject params) throws Exception { - long ttl = requiredPositiveLong(params, "ttl"); - Long requestedId = longOrNull(params, "id"); - if (requestedId != null && requestedId < 0) throw new IllegalArgumentException("id must be a positive integer or 0"); - LeaseGrantResponse response = requestedId == null || requestedId == 0 - ? requireClient().getLeaseClient().grant(ttl).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS) - : grantLeaseWithRequestedId(ttl, requestedId); - sessionState().knownLeases.add(response.getID()); - return Map.of("id", longString(response.getID()), "ttl", response.getTTL()); - } - - private static LeaseGrantResponse grantLeaseWithRequestedId(long ttl, long requestedId) throws Exception { - Lease lease = requireClient().getLeaseClient(); - try { - VertxLeaseGrpc.LeaseVertxStub stub = leaseStub(lease); - io.etcd.jetcd.api.LeaseGrantResponse response = stub - .withDeadlineAfter(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .leaseGrant(io.etcd.jetcd.api.LeaseGrantRequest.newBuilder().setTTL(ttl).setID(requestedId).build()) - .toCompletionStage() - .toCompletableFuture() - .get(RPC_TIMEOUT_SECONDS + 1L, TimeUnit.SECONDS); - return new LeaseGrantResponse(response); - } catch (ReflectiveOperationException error) { - throw new IllegalStateException("Custom Lease ID is unavailable in the installed Jetcd version", error); - } - } - - private static VertxLeaseGrpc.LeaseVertxStub leaseStub(Lease lease) throws ReflectiveOperationException { - Field stubField = lease.getClass().getDeclaredField("stub"); - stubField.setAccessible(true); - return (VertxLeaseGrpc.LeaseVertxStub) stubField.get(lease); - } - - private static Object leaseKeepAlive(JsonObject params) throws Exception { - long id = requiredPositiveLong(params, "id"); - long ttl = requireClient().getLeaseClient().keepAliveOnce(id).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS).getTTL(); - sessionState().knownLeases.add(id); - return Map.of("id", longString(id), "ttl", ttl); - } - - private static Object leaseRevoke(JsonObject params) throws Exception { - long id = requiredPositiveLong(params, "id"); - requireClient().getLeaseClient().revoke(id).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - sessionState().knownLeases.remove(id); - return Map.of("id", longString(id), "revoked", true); - } - - private static Object authUserList() throws Exception { - return Map.of("users", requireClient().getAuthClient().userList().get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS).getUsers()); - } - - private static Object authUserGet(JsonObject params) throws Exception { - String user = requiredString(params, "user"); - return Map.of("user", user, "roles", requireClient().getAuthClient().userGet(byteSequence(user)) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS).getRoles()); - } - - private static Object authUserAdd(JsonObject params) throws Exception { - requireClient().getAuthClient().userAdd(byteSequence(requiredString(params, "user")), byteSequence(requiredString(params, "password"))) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("created", true); - } - - private static Object authUserDelete(JsonObject params) throws Exception { - requireClient().getAuthClient().userDelete(byteSequence(requiredString(params, "user"))).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("deleted", true); - } - - private static Object authUserChangePassword(JsonObject params) throws Exception { - requireClient().getAuthClient().userChangePassword(byteSequence(requiredString(params, "user")), byteSequence(requiredString(params, "password"))) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("changed", true); - } - - private static Object authUserRole(JsonObject params, boolean grant) throws Exception { - Auth auth = requireClient().getAuthClient(); - ByteSequence user = byteSequence(requiredString(params, "user")); - ByteSequence role = byteSequence(requiredString(params, "role")); - if (grant) auth.userGrantRole(user, role).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - else auth.userRevokeRole(user, role).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("updated", true); - } - - private static Object authRoleList() throws Exception { - return Map.of("roles", requireClient().getAuthClient().roleList().get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS).getRoles()); - } - - private static Object authRoleGet(JsonObject params) throws Exception { - String role = requiredString(params, "role"); - List> permissions = new ArrayList<>(); - for (Permission permission : requireClient().getAuthClient().roleGet(byteSequence(role)).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS).getPermissions()) { - Map item = new LinkedHashMap<>(); - item.put("access", permission.getPermType().name().toLowerCase()); - item.put("key", bytesObject(permission.getKey().getBytes())); - item.put("rangeEnd", bytesObject(permission.getRangeEnd().getBytes())); - byte[] keyBytes = permission.getKey().getBytes(); - byte[] rangeEndBytes = permission.getRangeEnd().getBytes(); - String resource = keyBytes.length == 1 && keyBytes[0] == 0 - && rangeEndBytes.length == 1 && rangeEndBytes[0] == 0 - ? "all" - : rangeEndBytes.length == 0 ? "key" : "prefix"; - item.put("resource", resource); - permissions.add(item); - } - return Map.of("role", role, "permissions", permissions); - } - - private static Object authRoleAdd(JsonObject params) throws Exception { - requireClient().getAuthClient().roleAdd(byteSequence(requiredString(params, "role"))).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("created", true); - } - - private static Object authRoleDelete(JsonObject params) throws Exception { - requireClient().getAuthClient().roleDelete(byteSequence(requiredString(params, "role"))).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return Map.of("deleted", true); - } - - private static Object authRolePermission(JsonObject params, boolean grant) throws Exception { - Auth auth = requireClient().getAuthClient(); - ByteSequence role = byteSequence(requiredString(params, "role")); - String resource = stringOrDefault(params, "resource", "key"); - boolean all = "all".equals(resource); - boolean prefix = "prefix".equals(resource); - ByteSequence key = all ? ByteSequence.from(new byte[] {0}) : keyBytes(params); - ByteSequence rangeEnd = all - ? ByteSequence.from(new byte[] {0}) - : prefix ? prefixEnd(key) : ByteSequence.from(new byte[0]); - if (grant) { - Permission.Type access = Permission.Type.valueOf(requiredString(params, "access").toUpperCase()); - auth.roleGrantPermission(role, key, rangeEnd, access).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } else { - auth.roleRevokePermission(role, key, rangeEnd).get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } - return Map.of("updated", true); - } - - private static long requiredPositiveLong(JsonObject params, String field) { - Long value = longOrNull(params, field); - if (value == null || value <= 0) throw new IllegalArgumentException("ETCD_INVALID_" + field.toUpperCase() + ": a positive integer is required"); - return value; - } - - private static String requiredString(JsonObject params, String field) { - String value = stringOrNull(params, field); - if (value == null || value.isBlank()) throw new IllegalArgumentException("ETCD_" + field.toUpperCase() + "_REQUIRED"); - return value; - } - - static long watchEventBufferBytes(KeyValue item, KeyValue previous) { - long bytes = 512L + estimatedBufferedBytes(item.getKey().size()); - bytes += estimatedBufferedBytes(item.getValue().size()); - if (previous != null && previous.getVersion() > 0) { - bytes += estimatedBufferedBytes(previous.getValue().size()); - } - return bytes; - } - - static long estimatedBufferedBytes(int sourceBytes) { - return Math.max(0L, sourceBytes) * 4L; - } - - static final class BufferedWatchBatch { - private final Map payload; - private final long bufferedBytes; - - private BufferedWatchBatch(Map payload, long bufferedBytes) { - this.payload = payload; - this.bufferedBytes = bufferedBytes; - } - } - - static final class EtcdWatchState { - private final String watchId; - private final EtcdSessionState session; - private final Deque batches = new ArrayDeque<>(); - private int eventCount; - private long bufferedBytes; - private String terminalReason; - private String terminalMessage; - private Long compactedRevision; - private Watch.Watcher watcher; - - EtcdWatchState(String watchId, EtcdSessionState session) { - this.watchId = watchId; - this.session = session; - } - - synchronized void append(long revision, List> events, long batchBytes) { - if (terminalReason != null) return; - if (batches.size() >= MAX_WATCH_BATCHES - || eventCount + events.size() > MAX_WATCH_EVENTS - || batchBytes > MAX_WATCH_BUFFER_BYTES - || bufferedBytes + batchBytes > MAX_WATCH_BUFFER_BYTES - || !session.reserveWatchBuffer(batchBytes)) { - overflow(); - return; - } - Map batch = new LinkedHashMap<>(); - batch.put("revision", longString(revision)); - batch.put("events", events); - batches.addLast(new BufferedWatchBatch(batch, batchBytes)); - eventCount += events.size(); - bufferedBytes += batchBytes; - } - - synchronized void overflow() { - if (terminalReason != null) return; - terminalReason = "overflow"; - terminalMessage = "ETCD_WATCH_OVERFLOW: the event buffer reached its byte or event limit"; - closeWatcher(); - } - - private synchronized void fail(String reason, String message, Long compacted) { - if (terminalReason == null) { - terminalReason = reason; - terminalMessage = message; - compactedRevision = compacted; - } - } - - synchronized Map poll() { - List> page = new ArrayList<>(); - while (!batches.isEmpty() && page.size() < 64) { - BufferedWatchBatch batch = batches.removeFirst(); - eventCount -= ((List) batch.payload.get("events")).size(); - bufferedBytes -= batch.bufferedBytes; - session.releaseWatchBuffer(batch.bufferedBytes); - page.add(batch.payload); - } - Map result = new LinkedHashMap<>(); - result.put("watchId", watchId); - result.put("batches", page); - if (terminalReason != null && batches.isEmpty()) { - Map terminal = new LinkedHashMap<>(); - terminal.put("reason", terminalReason); - terminal.put("message", terminalMessage); - terminal.put("compactedRevision", compactedRevision == null ? null : longString(compactedRevision)); - result.put("terminal", terminal); - } - return result; - } - - private synchronized void close() { - fail("stopped", "watch stopped", null); - clearBuffered(); - closeWatcher(); - } - - private void clearBuffered() { - if (bufferedBytes > 0) session.releaseWatchBuffer(bufferedBytes); - batches.clear(); - eventCount = 0; - bufferedBytes = 0; - } - - private synchronized void setWatcher(Watch.Watcher createdWatcher) { - if (terminalReason != null) { - createdWatcher.close(); - } else { - watcher = createdWatcher; - } - } - - private void closeWatcher() { - if (watcher != null) { - watcher.close(); - watcher = null; - } - } - } - - private static Object dispatch(String method, JsonObject params) throws Exception { - return switch (method) { - case AgentProtocol.METHOD_HANDSHAKE -> handshakeResult(); - case AgentProtocol.METHOD_CONNECT, AgentProtocol.METHOD_TEST_CONNECTION -> connect(params); - case AgentProtocol.METHOD_VALIDATE_CONNECTION -> validateConnectedClient(); - 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.KV_METHOD_RENAME -> rename(params); - case AgentProtocol.KV_METHOD_HISTORY -> history(params); - case AgentProtocol.KV_METHOD_STATUS -> status(); - case AgentProtocol.ETCD_METHOD_COMPACT -> compact(params); - case AgentProtocol.ETCD_METHOD_DEFRAG -> defrag(params); - case AgentProtocol.ETCD_METHOD_WATCH_START -> watchStart(params); - case AgentProtocol.ETCD_METHOD_WATCH_POLL -> watchPoll(params); - case AgentProtocol.ETCD_METHOD_WATCH_STOP -> watchStop(params); - case AgentProtocol.ETCD_METHOD_LEASE_LIST -> leaseList(params); - case AgentProtocol.ETCD_METHOD_LEASE_GET -> leaseGet(params); - case AgentProtocol.ETCD_METHOD_LEASE_GRANT -> leaseGrant(params); - case AgentProtocol.ETCD_METHOD_LEASE_KEEPALIVE -> leaseKeepAlive(params); - case AgentProtocol.ETCD_METHOD_LEASE_REVOKE -> leaseRevoke(params); - case AgentProtocol.ETCD_METHOD_AUTH_USER_LIST -> authUserList(); - case AgentProtocol.ETCD_METHOD_AUTH_USER_GET -> authUserGet(params); - case AgentProtocol.ETCD_METHOD_AUTH_USER_ADD -> authUserAdd(params); - case AgentProtocol.ETCD_METHOD_AUTH_USER_DELETE -> authUserDelete(params); - case AgentProtocol.ETCD_METHOD_AUTH_USER_CHANGE_PASSWORD -> authUserChangePassword(params); - case AgentProtocol.ETCD_METHOD_AUTH_USER_GRANT_ROLE -> authUserRole(params, true); - case AgentProtocol.ETCD_METHOD_AUTH_USER_REVOKE_ROLE -> authUserRole(params, false); - case AgentProtocol.ETCD_METHOD_AUTH_ROLE_LIST -> authRoleList(); - case AgentProtocol.ETCD_METHOD_AUTH_ROLE_GET -> authRoleGet(params); - case AgentProtocol.ETCD_METHOD_AUTH_ROLE_ADD -> authRoleAdd(params); - case AgentProtocol.ETCD_METHOD_AUTH_ROLE_DELETE -> authRoleDelete(params); - case AgentProtocol.ETCD_METHOD_AUTH_ROLE_GRANT_PERMISSION -> authRolePermission(params, true); - case AgentProtocol.ETCD_METHOD_AUTH_ROLE_REVOKE_PERMISSION -> authRolePermission(params, false); - case AgentProtocol.METHOD_DISCONNECT -> { - closeClient(); - yield Collections.singletonMap("ok", true); - } - case AgentProtocol.METHOD_SHUTDOWN -> { - closeClient(); - 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); - - CURRENT_SESSION.set(LEGACY_SESSION); - 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); - } finally { - CURRENT_SESSION.remove(); - } - - 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 KV requireKv() { - KV kv = sessionState().kv; - if (kv == null) { - throw new IllegalStateException("Not connected"); - } - return kv; - } - - private static EtcdSessionState sessionState() { - EtcdSessionState state = CURRENT_SESSION.get(); - if (state == null) throw new IllegalStateException("No active etcd Agent session"); - return state; - } - - private static Client requireClient() { - Client client = sessionState().client; - if (client == null) throw new IllegalStateException("Not connected"); - return client; - } - - private static void closeClient() { - EtcdSessionState state = sessionState(); - for (EtcdWatchState watch : state.watches.values()) watch.close(); - state.watches.clear(); - state.knownLeases.clear(); - if (state.kv != null) { - state.kv.close(); - state.kv = null; - } - if (state.client != null) { - state.client.close(); - state.client = null; - } - state.connectedEndpoints = Collections.emptyList(); - } - - private static ByteSequence byteSequence(String value) { - return ByteSequence.from(value, java.nio.charset.StandardCharsets.UTF_8); - } - - private static ByteSequence prefixEnd(ByteSequence prefix) { - byte[] bytes = prefix.getBytes(); - if (bytes.length == 0) { - return ByteSequence.from(new byte[] {0}); - } - byte[] end = Arrays.copyOf(bytes, bytes.length); - for (int i = end.length - 1; i >= 0; i--) { - if ((end[i] & 0xff) < 0xff) { - end[i]++; - return ByteSequence.from(Arrays.copyOf(end, i + 1)); - } - } - return ByteSequence.from(new byte[] {0}); - } - - private static ByteSequence prefixStart(String prefix) { - if (prefix.isEmpty()) { - return ByteSequence.from(new byte[] {0}); - } - return byteSequence(prefix); - } - - private static String nextContinuation(KeyValue item) { - byte[] key = item.getKey().getBytes(); - byte[] next = Arrays.copyOf(key, key.length + 1); - next[next.length - 1] = 0; - return Base64.getEncoder().encodeToString(next); - } - - private static Map metadata(KeyValue item) { - Map metadata = new LinkedHashMap<>(); - metadata.put("createRevision", longString(item.getCreateRevision())); - metadata.put("modRevision", longString(item.getModRevision())); - metadata.put("version", longString(item.getVersion())); - metadata.put("lease", longString(item.getLease())); - metadata.put("valueSize", item.getValue().size()); - return metadata; - } - - private static Map metadataWithTtl(KeyValue item) throws Exception { - Map metadata = metadata(item); - if (item.getLease() > 0) { - LeaseTimeToLiveResponse lease = requireClient().getLeaseClient() - .timeToLive(item.getLease(), LeaseOption.DEFAULT) - .get(RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS); - metadata.put("ttl", lease.getTTL()); - } - return metadata; - } - - private static ByteSequence keyBytes(JsonObject params) { - JsonElement encoded = params.get("keyBytes"); - if (encoded != null && encoded.isJsonObject()) { - return ByteSequence.from(parseValue(encoded.getAsJsonObject())); - } - JsonElement key = params.get("key"); - if (key == null || key.isJsonNull()) { - throw new IllegalArgumentException("Key is required"); - } - return byteSequence(key.getAsString()); - } - - private static Map bytesObject(byte[] bytes) { - Map result = new LinkedHashMap<>(); - result.put("encoding", "base64"); - result.put("data", Base64.getEncoder().encodeToString(bytes)); - return result; - } - - private static Map valueObject(byte[] bytes) { - Map value = new LinkedHashMap<>(); - 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) { - 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(java.nio.charset.StandardCharsets.UTF_8); - } - - private static String displayBytes(byte[] bytes) { - String utf8 = strictUtf8(bytes); - return utf8 == null ? Base64.getEncoder().encodeToString(bytes) : utf8; - } - - private static String strictUtf8(byte[] bytes) { - CharsetDecoder decoder = java.nio.charset.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 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 int intUrlParamOrDefault(String params, String key, int fallback) { - if (params == null || params.isBlank()) { - return fallback; - } - for (String entry : params.replaceFirst("^\\?", "").split("&")) { - int separator = entry.indexOf('='); - String entryKey = separator < 0 ? entry : entry.substring(0, separator); - if (!key.equals(entryKey)) { - continue; - } - String value = separator < 0 ? "" : entry.substring(separator + 1); - try { - return Integer.parseInt(value); - } catch (NumberFormatException ignored) { - return fallback; - } - } - return fallback; - } - - private static boolean boolOrDefault(JsonObject object, String key, boolean fallback) { - JsonElement element = object.get(key); - return element == null || element.isJsonNull() ? fallback : element.getAsBoolean(); - } - - private static Long longOrNull(JsonObject object, String key) { - JsonElement element = object.get(key); - return element == null || element.isJsonNull() ? null : element.getAsLong(); - } - - private static String longString(long value) { - return Long.toString(value); - } - - private static String unsignedLongString(long value) { - return Long.toUnsignedString(value); - } - - private static Throwable rootCause(Throwable throwable) { - Throwable current = throwable; - while (current.getCause() != null && current.getCause() != current) { - current = current.getCause(); - } - return current; - } - - private static String safeMessage(Throwable throwable) { - String message = throwable == null ? null : throwable.getMessage(); - return message == null || message.isBlank() ? String.valueOf(throwable) : message; - } - - 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 { - MultiSessionJsonRpcServer.forSessionHandlers(EtcdSessionHandler::new).run(); - } - - private static final class HandshakeResult { - private final int protocolVersion; - private final int agentProtocolVersion; - private final List capabilities; - - private HandshakeResult(int protocolVersion, int agentProtocolVersion, List capabilities) { - this.protocolVersion = protocolVersion; - this.agentProtocolVersion = agentProtocolVersion; - this.capabilities = capabilities; - } - } - - static final class EtcdSessionState { - private Client client; - private KV kv; - private List connectedEndpoints = Collections.emptyList(); - private final Map watches = new ConcurrentHashMap<>(); - private final Set knownLeases = ConcurrentHashMap.newKeySet(); - private long watchBufferedBytes; - - private synchronized boolean reserveWatchBuffer(long bytes) { - if (bytes < 0 || watchBufferedBytes + bytes > MAX_SESSION_WATCH_BUFFER_BYTES) return false; - watchBufferedBytes += bytes; - return true; - } - - private synchronized void releaseWatchBuffer(long bytes) { - watchBufferedBytes = Math.max(0, watchBufferedBytes - Math.max(0, bytes)); - } - - synchronized long watchBufferedBytes() { - return watchBufferedBytes; - } - - void addWatch(String watchId, EtcdWatchState watch) { - watches.put(watchId, watch); - } - - int watchCount() { - return watches.size(); - } - } - - private static final class EtcdSessionHandler implements SessionRpcHandler { - private final EtcdSessionState state = new EtcdSessionState(); - - @Override - public Object handshake() { - return handshakeResult(); - } - - @Override - public Object connect(JsonObject params) throws Exception { - return withSession(() -> EtcdAgent.connect(params)); - } - - @Override - public Object handle(String method, JsonObject params) throws Exception { - return withSession(() -> dispatch(method, params)); - } - - @Override - public void close() { - try { - withSession(() -> { - closeClient(); - return null; - }); - } catch (Exception ignored) { - } - } - - private T withSession(Callable task) throws Exception { - CURRENT_SESSION.set(state); - try { - return task.call(); - } finally { - CURRENT_SESSION.remove(); - } - } - } -} diff --git a/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java b/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java deleted file mode 100644 index f74e0c7a62..0000000000 --- a/agents/drivers/etcd/src/test/java/com/dbx/agent/etcd/EtcdAgentTest.java +++ /dev/null @@ -1,475 +0,0 @@ -package com.dbx.agent.etcd; - -import com.google.protobuf.ByteString; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import io.etcd.jetcd.ByteSequence; -import io.etcd.jetcd.KV; -import io.etcd.jetcd.Lease; -import io.etcd.jetcd.Txn; -import io.etcd.jetcd.api.KeyValue; -import io.etcd.jetcd.api.RangeResponse; -import io.etcd.jetcd.api.ResponseHeader; -import io.etcd.jetcd.kv.GetResponse; -import io.etcd.jetcd.kv.TxnResponse; -import java.lang.reflect.Proxy; -import java.nio.charset.StandardCharsets; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Deque; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -final class EtcdAgentTest { - @Test - void handshakeAdvertisesKvCapability() { - String response = EtcdAgent.handleRequest( - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"handshake\",\"params\":{}}" - ); - - JsonObject result = JsonParser.parseString(response).getAsJsonObject().getAsJsonObject("result"); - - Assertions.assertEquals(2, result.get("protocolVersion").getAsInt()); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_ttl\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_cas\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_list_values\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_status\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"kv_history\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_compaction\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_defrag\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_watch\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_lease\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"etcd_auth\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"multi_session\""))); - Assertions.assertTrue(result.getAsJsonArray("capabilities").contains(JsonParser.parseString("\"connect\""))); - } - - @Test - void endpointsUseConfiguredListAndScheme() { - JsonObject connection = JsonParser.parseString( - "{\"endpoints\":\"etcd-1:2379,https://etcd-2:2379\",\"ssl\":true}" - ).getAsJsonObject(); - - Assertions.assertEquals( - List.of("https://etcd-1:2379", "https://etcd-2:2379"), - EtcdAgent.endpoints(connection) - ); - } - - @Test - void endpointsFallbackToHostPort() { - JsonObject connection = JsonParser.parseString( - "{\"host\":\"127.0.0.1\",\"port\":2379}" - ).getAsJsonObject(); - - Assertions.assertEquals(List.of("http://127.0.0.1:2379"), EtcdAgent.endpoints(connection)); - } - - @Test - void connectTimeoutUsesConfiguredValueAndSafeBounds() { - Assertions.assertEquals( - 45, - EtcdAgent.connectTimeoutSeconds(JsonParser.parseString("{\"connect_timeout_secs\":45}").getAsJsonObject()) - ); - Assertions.assertEquals( - 1, - EtcdAgent.connectTimeoutSeconds(JsonParser.parseString("{\"connect_timeout_secs\":0}").getAsJsonObject()) - ); - Assertions.assertEquals( - 300, - EtcdAgent.connectTimeoutSeconds(JsonParser.parseString("{\"connect_timeout_secs\":999}").getAsJsonObject()) - ); - } - - @Test - void grpcInboundLimitDefaultsTo32MiBAndUsesSafeBounds() { - Assertions.assertEquals( - 32 * 1024 * 1024, - EtcdAgent.grpcMaxInboundMessageSize(new JsonObject()) - ); - Assertions.assertEquals( - 64 * 1024 * 1024, - EtcdAgent.grpcMaxInboundMessageSize( - JsonParser.parseString("{\"grpc_max_inbound_message_size\":67108864}").getAsJsonObject() - ) - ); - Assertions.assertEquals( - 1024 * 1024, - EtcdAgent.grpcMaxInboundMessageSize( - JsonParser.parseString("{\"grpc_max_inbound_message_size\":0}").getAsJsonObject() - ) - ); - Assertions.assertEquals( - 256 * 1024 * 1024, - EtcdAgent.grpcMaxInboundMessageSize( - JsonParser.parseString("{\"grpc_max_inbound_message_size\":536870912}").getAsJsonObject() - ) - ); - } - - @Test - void grpcInboundLimitCanBeConfiguredThroughConnectionUrlParams() { - JsonObject connection = JsonParser.parseString( - "{\"url_params\":\"foo=bar&grpc_max_inbound_message_size=50331648\"}" - ).getAsJsonObject(); - - Assertions.assertEquals(48 * 1024 * 1024, EtcdAgent.grpcMaxInboundMessageSize(connection)); - } - - @Test - void validateConnectionRequiresAnActiveSession() { - String response = EtcdAgent.handleRequest( - "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"validate_connection\",\"params\":{}}" - ); - - 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 kvMethodDispatchReturnsJsonRpcErrorWhenDisconnected() { - String response = EtcdAgent.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 historyMethodIsRegistered() { - String response = EtcdAgent.handleRequest( - "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"kv_history\",\"params\":{\"key\":\"/app/name\",\"limit\":100}}" - ); - - 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 historyDefaultsToABoundedRevisionWindow() { - Assertions.assertEquals(1L, EtcdAgent.historyStartRevision(null, 100L)); - Assertions.assertEquals(10_001L, EtcdAgent.historyStartRevision(null, 20_000L)); - Assertions.assertEquals(42L, EtcdAgent.historyStartRevision(42L, 20_000L)); - } - - @Test - void historyRetainsOnlyTheNewestRequestedEvents() { - Deque events = new ArrayDeque<>(); - AtomicBoolean truncated = new AtomicBoolean(); - - for (int value = 1; value <= 5; value++) { - EtcdAgent.appendBoundedHistory(events, value, 3, truncated); - } - - Assertions.assertEquals(List.of(3, 4, 5), List.copyOf(events)); - Assertions.assertTrue(truncated.get()); - } - - @Test - void preserveLeaseRetriesWithTheLatestLeaseAfterConcurrentChange() throws Exception { - AtomicInteger getCalls = new AtomicInteger(); - AtomicInteger txnCalls = new AtomicInteger(); - KV active = scriptedKv( - getCalls, - txnCalls, - List.of(getResponse("/app/name", 10, 101), getResponse("/app/name", 11, 202)), - List.of(txnResponse(false, 10), txnResponse(true, 12)) - ); - - long revision = EtcdAgent.putPreservingLease(active, bytes("/app/name"), bytes("updated")); - - Assertions.assertEquals(12, revision); - Assertions.assertEquals(2, getCalls.get()); - Assertions.assertEquals(2, txnCalls.get()); - } - - @Test - void preserveLeaseRejectsMissingOrUnleasedKeys() { - KV missing = scriptedKv( - new AtomicInteger(), - new AtomicInteger(), - List.of(emptyGetResponse()), - List.of() - ); - KV unleased = scriptedKv( - new AtomicInteger(), - new AtomicInteger(), - List.of(getResponse("/app/name", 10, 0)), - List.of() - ); - - IllegalStateException missingError = Assertions.assertThrows( - IllegalStateException.class, - () -> EtcdAgent.putPreservingLease(missing, bytes("/app/name"), bytes("updated")) - ); - IllegalStateException unleasedError = Assertions.assertThrows( - IllegalStateException.class, - () -> EtcdAgent.putPreservingLease(unleased, bytes("/app/name"), bytes("updated")) - ); - - Assertions.assertEquals("Cannot preserve lease: key does not exist or has no lease", missingError.getMessage()); - Assertions.assertEquals("Cannot preserve lease: key does not exist or has no lease", unleasedError.getMessage()); - } - - @Test - void preserveLeaseFailsClearlyAfterBoundedConcurrentChanges() { - AtomicInteger getCalls = new AtomicInteger(); - AtomicInteger txnCalls = new AtomicInteger(); - KV active = scriptedKv( - getCalls, - txnCalls, - List.of( - getResponse("/app/name", 10, 101), - getResponse("/app/name", 11, 202), - getResponse("/app/name", 12, 303) - ), - List.of(txnResponse(false, 10), txnResponse(false, 11), txnResponse(false, 12)) - ); - - IllegalStateException error = Assertions.assertThrows( - IllegalStateException.class, - () -> EtcdAgent.putPreservingLease(active, bytes("/app/name"), bytes("updated")) - ); - - Assertions.assertEquals("Cannot preserve lease: key changed concurrently; retry the save", error.getMessage()); - Assertions.assertEquals(3, getCalls.get()); - Assertions.assertEquals(3, txnCalls.get()); - } - - @Test - void grantedLeaseIsRevokedWhenWriteFailsAfterCreation() { - AtomicInteger revokeCalls = new AtomicInteger(); - AtomicInteger revokedLeaseId = new AtomicInteger(); - Lease lease = (Lease) Proxy.newProxyInstance( - Lease.class.getClassLoader(), - new Class[] { Lease.class }, - (proxy, method, args) -> { - if (method.getName().equals("revoke")) { - revokeCalls.incrementAndGet(); - revokedLeaseId.set(Math.toIntExact((long) args[0])); - return CompletableFuture.completedFuture(null); - } - if (method.getName().equals("close")) return null; - throw new UnsupportedOperationException(method.getName()); - } - ); - - TimeoutException error = Assertions.assertThrows( - TimeoutException.class, - () -> EtcdAgent.cleanUpGrantedLeaseOnFailure(123, lease, () -> { - throw new TimeoutException("write timed out"); - }) - ); - - Assertions.assertEquals("write timed out", error.getMessage()); - Assertions.assertEquals(1, revokeCalls.get()); - Assertions.assertEquals(123, revokedLeaseId.get()); - } - - @Test - void defragFailureMarksEveryRemainingEndpointAsNotExecuted() { - List> members = new ArrayList<>(); - - EtcdAgent.appendUnexecutedDefragMembers( - members, - List.of("http://follower-2:2379", "http://follower-3:2379", "http://leader:2379"), - "http://follower-2:2379" - ); - - Assertions.assertEquals( - List.of("http://follower-3:2379", "http://leader:2379"), - members.stream().map(member -> member.get("endpoint")).toList() - ); - Assertions.assertTrue(members.stream().allMatch(member -> "not_executed".equals(member.get("status")))); - } - - @Test - void leaseListResponseParserReadsEveryLeaseId() throws Exception { - byte[] response = new byte[] { - 0x12, 0x02, 0x08, 0x7b, - 0x12, 0x03, 0x08, (byte) 0xc8, 0x03 - }; - - Assertions.assertEquals(List.of(123L, 456L), EtcdAgent.leaseIdsFromResponse(response)); - } - - @Test - void leaseListPageUsesAStableCursorAndBoundsTheResult() { - List leaseIds = new ArrayList<>(); - for (long id = 1000; id >= 1; id--) leaseIds.add(id); - - List first = EtcdAgent.leasePageIds(leaseIds, null, 101); - List second = EtcdAgent.leasePageIds(leaseIds, first.get(99), 101); - - Assertions.assertEquals(101, first.size()); - Assertions.assertEquals(1L, first.get(0)); - Assertions.assertEquals(101L, first.get(100)); - Assertions.assertEquals(101L, second.get(0)); - Assertions.assertEquals(201L, second.get(100)); - } - - @Test - void watchBufferRejectsLargeValueAndPreviousValuePayloads() { - long payloadBytes = EtcdAgent.estimatedBufferedBytes(2 * 1024 * 1024) - + EtcdAgent.estimatedBufferedBytes(2 * 1024 * 1024); - - Assertions.assertTrue(payloadBytes > EtcdAgent.MAX_WATCH_BUFFER_BYTES); - } - - @Test - void watchOverflowPreservesBufferedPayloadAndReportsTerminalState() { - EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState(); - EtcdAgent.EtcdWatchState watch = new EtcdAgent.EtcdWatchState("watch-1", session); - List> events = List.of(Map.of("eventType", "put")); - - watch.append(1, events, 4L * 1024 * 1024); - Assertions.assertEquals(4L * 1024 * 1024, session.watchBufferedBytes()); - - watch.append(2, events, 5L * 1024 * 1024); - Map result = watch.poll(); - - Assertions.assertEquals(0, session.watchBufferedBytes()); - List batches = (List) result.get("batches"); - Assertions.assertEquals(1, batches.size()); - Assertions.assertEquals("1", ((Map) batches.get(0)).get("revision")); - Assertions.assertEquals("overflow", ((Map) result.get("terminal")).get("reason")); - } - - @Test - void watchPollReleasesTheSessionByteBudget() { - EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState(); - EtcdAgent.EtcdWatchState watch = new EtcdAgent.EtcdWatchState("watch-1", session); - - watch.append(1, List.of(Map.of("eventType", "put")), 1024); - Assertions.assertEquals(1024, session.watchBufferedBytes()); - - watch.poll(); - - Assertions.assertEquals(0, session.watchBufferedBytes()); - } - - @Test - void aggregateSessionBudgetTerminatesOnlyTheWatchThatExceedsIt() { - EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState(); - List> events = List.of(Map.of("eventType", "put")); - EtcdAgent.EtcdWatchState first = new EtcdAgent.EtcdWatchState("watch-1", session); - EtcdAgent.EtcdWatchState second = new EtcdAgent.EtcdWatchState("watch-2", session); - EtcdAgent.EtcdWatchState third = new EtcdAgent.EtcdWatchState("watch-3", session); - - first.append(1, events, EtcdAgent.MAX_WATCH_BUFFER_BYTES); - second.append(1, events, EtcdAgent.MAX_WATCH_BUFFER_BYTES); - third.append(1, events, 1); - - Assertions.assertEquals(EtcdAgent.MAX_SESSION_WATCH_BUFFER_BYTES, session.watchBufferedBytes()); - Assertions.assertEquals("overflow", ((Map) third.poll().get("terminal")).get("reason")); - Assertions.assertEquals(EtcdAgent.MAX_SESSION_WATCH_BUFFER_BYTES, session.watchBufferedBytes()); - } - - @Test - void terminalPollRemovesTheWatchSlot() { - EtcdAgent.EtcdSessionState session = new EtcdAgent.EtcdSessionState(); - EtcdAgent.EtcdWatchState watch = new EtcdAgent.EtcdWatchState("watch-1", session); - session.addWatch("watch-1", watch); - watch.overflow(); - - Map result = EtcdAgent.pollWatchState(session, "watch-1"); - - Assertions.assertEquals("overflow", ((Map) result.get("terminal")).get("reason")); - Assertions.assertEquals(0, session.watchCount()); - } - - private static KV scriptedKv( - AtomicInteger getCalls, - AtomicInteger txnCalls, - List getResponses, - List txnResponses - ) { - Deque gets = new ArrayDeque<>(getResponses); - Deque txns = new ArrayDeque<>(txnResponses); - return (KV) Proxy.newProxyInstance( - KV.class.getClassLoader(), - new Class[] { KV.class }, - (proxy, method, args) -> { - if (method.getName().equals("get")) { - getCalls.incrementAndGet(); - return CompletableFuture.completedFuture(gets.removeFirst()); - } - if (method.getName().equals("txn")) { - return scriptedTxn(txnCalls, txns); - } - if (method.getName().equals("close")) { - return null; - } - throw new UnsupportedOperationException(method.getName()); - } - ); - } - - private static Txn scriptedTxn(AtomicInteger txnCalls, Deque responses) { - return (Txn) Proxy.newProxyInstance( - Txn.class.getClassLoader(), - new Class[] { Txn.class }, - (proxy, method, args) -> { - if (Arrays.asList("If", "Then", "Else").contains(method.getName())) { - return proxy; - } - if (method.getName().equals("commit")) { - txnCalls.incrementAndGet(); - return CompletableFuture.completedFuture(responses.removeFirst()); - } - throw new UnsupportedOperationException(method.getName()); - } - ); - } - - private static ByteSequence bytes(String value) { - return ByteSequence.from(value, StandardCharsets.UTF_8); - } - - private static GetResponse getResponse(String key, long modRevision, long lease) { - KeyValue keyValue = KeyValue.newBuilder() - .setKey(ByteString.copyFromUtf8(key)) - .setModRevision(modRevision) - .setLease(lease) - .build(); - RangeResponse response = RangeResponse.newBuilder() - .setHeader(header(modRevision)) - .addKvs(keyValue) - .build(); - return new GetResponse(response, ByteSequence.EMPTY); - } - - private static GetResponse emptyGetResponse() { - return new GetResponse( - RangeResponse.newBuilder().setHeader(header(1)).build(), - ByteSequence.EMPTY - ); - } - - private static TxnResponse txnResponse(boolean succeeded, long revision) { - io.etcd.jetcd.api.TxnResponse response = io.etcd.jetcd.api.TxnResponse.newBuilder() - .setHeader(header(revision)) - .setSucceeded(succeeded) - .build(); - return new TxnResponse(response, ByteSequence.EMPTY); - } - - private static ResponseHeader header(long revision) { - return ResponseHeader.newBuilder().setRevision(revision).build(); - } -} diff --git a/agents/drivers/etcd2-go/README.md b/agents/drivers/etcd2-go/README.md new file mode 100644 index 0000000000..92c01d7155 --- /dev/null +++ b/agents/drivers/etcd2-go/README.md @@ -0,0 +1,70 @@ +# etcd v2 native agent (`etcd2`) + +Native Go agent speaking the etcd **v2 HTTP/JSON API** with the standard DBX +agent protocol (NDJSON JSON-RPC over stdio). Registered as agentKey `etcd2`, +selected through the `etcd-v2` driver profile of the `etcd` connection type. + +## Supported servers + +| Server | Support | +| --- | --- | +| etcd 2.0–2.3 (pure v2) | fully supported; validated against 2.3.8 | +| etcd 3.0–3.5 started with `--enable-v2` | same code path | +| etcd 3.4–3.5 default configuration | rejected at connect: v2 is disabled by default (`ETCD_V2_API_DISABLED`) | +| etcd 3.6+ | impossible: the v2 API was removed upstream | + +## Capabilities + +`connect`, `test_connection`, `kv`, `kv_ttl`, `kv_cas`, `kv_list_values`, +`kv_status`, `etcd_watch`, `etcd_auth`, `multi_session`. The v3-only +capabilities (`kv_history`, `etcd_lease`, `etcd_compaction`, `etcd_defrag`) +are deliberately absent; the host blocks those calls before dispatch and the +UI hides the corresponding workspaces for `etcd-v2` connections. + +## v2 API semantics and deliberate limits + +- **TTL**: v2 attaches TTLs directly to keys (`?ttl=`). Key editing offers + permanent/TTL expiry only; binding a lease is rejected with + `ETCD_V2_LEASE_UNSUPPORTED` because v2 has no lease concept. +- **CAS**: writes use `prevIndex` (modifiedIndex) and `prevExist`, matching + the v3 modRevision contract. `expectedCreateRevision` is only honored when + `0` (key-must-not-exist); a nonzero value is rejected with + `ETCD_V2_CAS_UNSUPPORTED` because v2 does not expose createdIndex for + compare-and-swap. +- **Rename**: check-then-set, **not atomic** — v2 has no transaction + primitive. The source is verified by CAS before the target is created, but + a concurrent writer between the two requests is not serialized. +- **Watch**: long-poll loop (`?wait=true&waitIndex=N`, recursive for prefix + scopes). Idle poll timeouts are retried transparently. There are no + progress notifications; the same batch/event/byte budget system as the v3 + agent applies. +- **Hidden directories**: v2 hides `_`-prefixed directory entries from + non-recursive listings; such keys are only reachable when addressed + explicitly, matching `etcdctl` behavior. +- **Auth**: users hold roles; roles hold read/write permissions over exact + keys and `/prefix/*` globs. Grants and revokes use the server's incremental + `grant`/`revoke` role PUT (`{"role":"r","grant":{"kv":{"read":["/p/*"]}}}`), + matching the etcd 2.3 client wire format. Resource mapping: `key` → bare + path, `prefix`/`all` → `/path/*` / `/*` globs. +- **Status**: reduced dashboard. `clusterId` comes from the + `X-Etcd-Cluster-Id` header, `revision` from `X-Etcd-Index`; Prometheus + metrics, alarms, dbSize, and raft details do not exist in v2 and stay null. + Raw `/v2/stats/store` counters are passed through as the `store` object. + +## Connect probing + +`connect` fetches `/version`, then probes `GET /v2/members`: + +- `200` → ok; `403` → ok with `limited: true` (credentials work, root access + missing); `404` → `ETCD_V2_API_DISABLED`; `401` → `ETCD_UNAUTHENTICATED`. + +## Testing + +```bash +go test ./... # unit tests +DBX_ETCD2_LIVE=1 go test -run Live ./... # live flow (default endpoint below) +``` + +Live test environment variables: `DBX_ETCD2_ENDPOINTS`, `DBX_ETCD2_USER`, +`DBX_ETCD2_PASSWORD`. The default endpoint points at the team test server's +etcd 2.3.8 instance; see `docs/testing-databases.md` in the workspace root. diff --git a/agents/drivers/etcd2-go/auth.go b/agents/drivers/etcd2-go/auth.go new file mode 100644 index 0000000000..277fd67335 --- /dev/null +++ b/agents/drivers/etcd2-go/auth.go @@ -0,0 +1,390 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// v2 auth wire format (etcd 2.3 etcdserver/etcdhttp/client_auth.go): +// a role PUT body without grant/revoke creates the role; a body carrying +// grant/revoke updates it incrementally and must not carry permissions. +// Permission entries are glob patterns: a trailing "*" grants a prefix, +// anything else matches a single key exactly. + +type v2RWPermission struct { + Read []string `json:"read"` + Write []string `json:"write"` +} + +type v2Permissions struct { + KV v2RWPermission `json:"kv"` +} + +type v2Role struct { + Role string `json:"role"` + Permissions *v2Permissions `json:"permissions"` + Grant *v2Permissions `json:"grant,omitempty"` + Revoke *v2Permissions `json:"revoke,omitempty"` +} + +// v2UserRecord is the GET shape of /v2/auth/users/: roles come back +// as full role objects, not plain names. +type v2UserRecord struct { + User string `json:"user"` + Roles []struct { + Role string `json:"role"` + } `json:"roles"` +} + +// v2UserDocument is the PUT shape: grant/revoke carry role names. +type v2UserDocument struct { + User string `json:"user"` + Password string `json:"password,omitempty"` + Roles []string `json:"roles,omitempty"` + Grant []string `json:"grant,omitempty"` + Revoke []string `json:"revoke,omitempty"` +} + +func (s *etcd2Session) authUserList(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodGet, "/v2/auth/users", "", nil) + if err != nil { + return nil, err + } + var parsed struct { + Users []v2UserRecord `json:"users"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + users := make([]string, 0, len(parsed.Users)) + for _, user := range parsed.Users { + users = append(users, user.User) + } + return map[string]any{"users": users}, nil +} + +func (s *etcd2Session) authUserGet(params map[string]json.RawMessage) (any, error) { + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodGet, "/v2/auth/users/"+escapePathSegment(user), "", nil) + if err != nil { + return nil, err + } + var parsed v2UserRecord + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + roles := make([]string, 0, len(parsed.Roles)) + for _, role := range parsed.Roles { + roles = append(roles, role.Role) + } + return map[string]any{"user": parsed.User, "roles": roles}, nil +} + +// authUserAdd relies on the create-or-update PUT: HTTP 201 means created, +// HTTP 200 means the user existed and only the password would change. +func (s *etcd2Session) authUserAdd(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + password, err := requiredString(params, "password") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + payload := v2UserDocument{User: user, Password: password} + _, response, err := client.doJSON(ctx, http.MethodPut, "/v2/auth/users/"+escapePathSegment(user), payload) + if err != nil { + return nil, err + } + if response != nil && response.StatusCode == http.StatusOK { + return nil, fmt.Errorf("auth: User %s already exists.", user) + } + return map[string]bool{"created": true}, nil +} + +func (s *etcd2Session) authUserDelete(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, _, err := client.do(ctx, http.MethodDelete, "/v2/auth/users/"+escapePathSegment(user), "", nil); err != nil { + return nil, err + } + return map[string]bool{"deleted": true}, nil +} + +// authUserChangePassword reuses the create-or-update PUT: the server +// replaces the stored password hash when the user exists. +func (s *etcd2Session) authUserChangePassword(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + password, err := requiredString(params, "password") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + payload := v2UserDocument{User: user, Password: password} + if _, _, err := client.doJSON(ctx, http.MethodPut, "/v2/auth/users/"+escapePathSegment(user), payload); err != nil { + return nil, err + } + return map[string]bool{"changed": true}, nil +} + +func (s *etcd2Session) authUserGrantRevokeRole(params map[string]json.RawMessage, grant bool) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + user, err := requiredString(params, "user") + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + payload := v2UserDocument{User: user, Grant: []string{role}} + if !grant { + payload = v2UserDocument{User: user, Revoke: []string{role}} + } + if _, _, err := client.doJSON(ctx, http.MethodPut, "/v2/auth/users/"+escapePathSegment(user), payload); err != nil { + return nil, err + } + return map[string]bool{"updated": true}, nil +} + +func (s *etcd2Session) authRoleList(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodGet, "/v2/auth/roles", "", nil) + if err != nil { + return nil, err + } + var parsed struct { + Roles []v2Role `json:"roles"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + roles := make([]string, 0, len(parsed.Roles)) + for _, role := range parsed.Roles { + roles = append(roles, role.Role) + } + return map[string]any{"roles": roles}, nil +} + +func (s *etcd2Session) authRoleGet(params map[string]json.RawMessage) (any, error) { + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodGet, "/v2/auth/roles/"+escapePathSegment(role), "", nil) + if err != nil { + return nil, err + } + var parsed v2Role + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + permissions := []map[string]any{} + if parsed.Permissions != nil { + permissions = append(permissions, v2PermissionRows("read", parsed.Permissions.KV.Read)...) + permissions = append(permissions, v2PermissionRows("write", parsed.Permissions.KV.Write)...) + } + return map[string]any{"role": parsed.Role, "permissions": permissions}, nil +} + +// v2PermissionRows converts stored v2 patterns into the +// {access, key, rangeEnd, resource} rows the v3 agent emits: a trailing +// "*" displays as a prefix grant, anything else as an exact key. +func v2PermissionRows(access string, entries []string) []map[string]any { + rows := []map[string]any{} + for _, entry := range entries { + if strings.HasSuffix(entry, "*") { + base := strings.TrimSuffix(entry, "*") + rows = append(rows, map[string]any{ + "access": access, + "key": bytesObject([]byte(base)), + "rangeEnd": bytesObject([]byte(prefixEnd(base))), + "resource": "prefix", + }) + } else { + rows = append(rows, map[string]any{ + "access": access, + "key": bytesObject([]byte(entry)), + "rangeEnd": bytesObject(nil), + "resource": "key", + }) + } + } + return rows +} + +func (s *etcd2Session) authRoleAdd(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + payload := v2Role{Role: role} + if _, _, err := client.doJSON(ctx, http.MethodPut, "/v2/auth/roles/"+escapePathSegment(role), payload); err != nil { + return nil, err + } + return map[string]bool{"created": true}, nil +} + +func (s *etcd2Session) authRoleDelete(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + if _, _, err := client.do(ctx, http.MethodDelete, "/v2/auth/roles/"+escapePathSegment(role), "", nil); err != nil { + return nil, err + } + return map[string]bool{"deleted": true}, nil +} + +// v2PermissionPattern maps the protocol's resource model onto the v2 glob +// convention: exact keys stay bare, prefixes and "all" become "/path/*". +func v2PermissionPattern(resource, key string) string { + switch resource { + case "all": + return "/*" + case "prefix": + return strings.TrimSuffix(key, "/") + "/*" + default: + return key + } +} + +// authRolePermission maps grants/revokes onto the server's incremental +// grant/revoke role update (one PUT per access direction). +func (s *etcd2Session) authRolePermission(params map[string]json.RawMessage, grant bool) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + role, err := requiredString(params, "role") + if err != nil { + return nil, err + } + resource := stringOrDefault(params, "resource", "key") + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + if !strings.HasPrefix(key, "/") { + key = "/" + key + } + pattern := v2PermissionPattern(resource, key) + + var accesses []string + if grant { + access := strings.ToUpper(stringOrDefault(params, "access", "")) + switch access { + case "READ": + accesses = []string{"read"} + case "WRITE": + accesses = []string{"write"} + case "READWRITE": + accesses = []string{"read", "write"} + default: + return nil, fmt.Errorf("ETCD_INVALID_ACCESS: access must be READ, WRITE, or READWRITE, got %s", access) + } + } else { + accesses = []string{"read", "write"} + } + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + for _, access := range accesses { + document := v2Role{Role: role} + direction := v2Permissions{KV: v2RWPermission{}} + if access == "read" { + direction.KV.Read = []string{pattern} + } else { + direction.KV.Write = []string{pattern} + } + if grant { + document.Grant = &direction + } else { + document.Revoke = &direction + } + if _, _, err := client.doJSON(ctx, http.MethodPut, "/v2/auth/roles/"+escapePathSegment(role), document); err != nil { + return nil, err + } + } + return map[string]bool{"updated": true}, nil +} + +func (c *authenticatedClient) doJSON(ctx context.Context, method, path string, payload any) ([]byte, *http.Response, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + return c.do(ctx, method, path, string(encoded), map[string]string{"Content-Type": "application/json"}) +} + +func escapePathSegment(segment string) string { + return strings.ReplaceAll(segment, "/", "%2F") +} diff --git a/agents/drivers/etcd2-go/client.go b/agents/drivers/etcd2-go/client.go new file mode 100644 index 0000000000..5ba273c519 --- /dev/null +++ b/agents/drivers/etcd2-go/client.go @@ -0,0 +1,457 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const ( + defaultPort = 2379 + operationTimeout = 30 * time.Second +) + +// connectionParams mirrors the host-side etcd connection configuration. +// gRPC-specific options are accepted and ignored: the v2 API is plain HTTP. +type connectionParams struct { + EtcdEndpoints string `json:"etcd_endpoints"` + Endpoints string `json:"endpoints"` + ConnectionString string `json:"connection_string"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + 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"` + ConnectTimeoutSecs int `json:"connect_timeout_secs"` + GrpcMaxInboundMessageSize int `json:"grpc_max_inbound_message_size"` + URLParams string `json:"url_params"` +} + +// authenticatedClient issues v2 API requests against one etcd endpoint with +// optional basic auth and TLS. +type authenticatedClient struct { + endpoint string + http *http.Client + username string + password string + serverVersion string +} + +func connectionObject(params map[string]json.RawMessage) (connectionParams, error) { + var connection connectionParams + if raw, ok := params["connection"]; ok && raw != nil { + if err := json.Unmarshal(raw, &connection); err != nil { + return connection, err + } + return connection, nil + } + if err := decodeParams(params, &connection); err != nil { + return connection, err + } + return connection, nil +} + +func (s *etcd2Session) connect(params map[string]json.RawMessage) (any, error) { + connection, err := connectionObject(params) + if err != nil { + return nil, err + } + endpointList := connectionEndpoints(connection) + var lastErr error + for _, endpoint := range endpointList { + client, _, err := probeClient(endpoint, connection) + if err != nil { + lastErr = err + continue + } + s.close() + s.clientMu.Lock() + s.httpClient = client + s.connectedEndpoints = endpointList + s.serverVersion = client.serverVersion + s.clientMu.Unlock() + return map[string]bool{"ok": true}, nil + } + if lastErr == nil { + lastErr = errors.New("No etcd endpoint configured") + } + return nil, lastErr +} + +func (s *etcd2Session) activeClient() (*authenticatedClient, error) { + s.clientMu.Lock() + client := s.httpClient + s.clientMu.Unlock() + if client == nil { + return nil, errors.New("Not connected") + } + return client, nil +} + +func (s *etcd2Session) connectedEndpointList() []string { + s.clientMu.Lock() + defer s.clientMu.Unlock() + return append([]string(nil), s.connectedEndpoints...) +} + +func (s *etcd2Session) validateConnection() (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), operationTimeout) + defer cancel() + probe, err := client.probeV2(ctx) + if err != nil { + return nil, err + } + return probe, nil +} + +func (s *etcd2Session) close() error { + s.watchesMu.Lock() + watches := s.watches + s.watches = map[string]*watchState{} + s.watchBufferedBytes = 0 + s.watchesMu.Unlock() + for _, watch := range watches { + watch.close() + } + s.clientMu.Lock() + s.httpClient = nil + s.connectedEndpoints = nil + s.serverVersion = "" + s.clientMu.Unlock() + return nil +} + +func buildHTTPClient(connection connectionParams) (*http.Client, error) { + transport := &http.Transport{ + MaxIdleConns: 4, + MaxIdleConnsPerHost: 4, + } + if connection.SSL { + tlsConfig, err := tlsConfigFor(connection) + if err != nil { + return nil, err + } + transport.TLSClientConfig = tlsConfig + } + return &http.Client{ + Transport: transport, + // The request context governs per-call timeouts; the client-level + // timeout stays high so long-poll watches are not cut short. + Timeout: 10 * time.Minute, + }, nil +} + +func connectTimeoutSeconds(connection connectionParams) time.Duration { + seconds := connection.ConnectTimeoutSecs + if seconds == 0 { + seconds = 30 + } + if seconds < 1 { + seconds = 1 + } + if seconds > 300 { + seconds = 300 + } + return time.Duration(seconds) * time.Second +} + +func tlsConfigFor(connection connectionParams) (*tls.Config, error) { + tlsConfig := &tls.Config{} + if ca := strings.TrimSpace(connection.CACertPath); ca != "" { + authorityPEM, err := os.ReadFile(ca) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(authorityPEM) { + return nil, fmt.Errorf("failed to parse CA certificate at %s", ca) + } + tlsConfig.RootCAs = pool + } + certPath := firstNonBlank(connection.ClientCertPath, connection.CertPath) + keyPath := firstNonBlank(connection.ClientKeyPath, connection.KeyPath) + if (certPath == "") != (keyPath == "") { + return nil, errors.New("Client certificate and key must be provided together") + } + if certPath != "" { + pair, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{pair} + } + return tlsConfig, nil +} + +// probeClient verifies the endpoint speaks the v2 API. It returns the +// connected client and a probe result shaped like the v3 agent's: +// {ok, endpoint, limited?}. +func probeClient(endpoint string, connection connectionParams) (*authenticatedClient, map[string]any, error) { + httpClient, err := buildHTTPClient(connection) + if err != nil { + return nil, nil, err + } + client := &authenticatedClient{ + endpoint: strings.TrimSuffix(endpoint, "/"), + http: httpClient, + username: connection.Username, + password: connection.Password, + } + dialCtx, dialCancel := context.WithTimeout(context.Background(), connectTimeoutSeconds(connection)) + defer dialCancel() + version, err := client.fetchVersion(dialCtx) + if err != nil { + return nil, nil, err + } + client.serverVersion = version.etcdserver + probe, err := client.probeV2(dialCtx) + if err != nil { + return nil, nil, err + } + return client, probe, nil +} + +type etcdVersion struct { + etcdserver string + etcdcluster string +} + +func (c *authenticatedClient) fetchVersion(ctx context.Context) (etcdVersion, error) { + body, _, err := c.do(ctx, http.MethodGet, "/version", "", nil) + if err != nil { + return etcdVersion{}, err + } + var parsed struct { + Etcdserver string `json:"etcdserver"` + Etcdcluster string `json:"etcdcluster"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return etcdVersion{}, fmt.Errorf("unrecognized etcd /version response from %s: %w", c.endpoint, err) + } + return etcdVersion{etcdserver: parsed.Etcdserver, etcdcluster: parsed.Etcdcluster}, nil +} + +// probeV2 checks that the v2 keys API is actually served. A 403 proves the +// channel and credentials reached etcd, mirroring the v3 agent's +// PERMISSION_DENIED handling for restricted users. +func (c *authenticatedClient) probeV2(ctx context.Context) (map[string]any, error) { + response, err := c.request(ctx, http.MethodGet, "/v2/members", "", nil) + if err != nil { + return nil, err + } + defer drainClose(response.Body) + switch response.StatusCode { + case http.StatusOK: + return map[string]any{"ok": true, "endpoint": c.endpoint}, nil + case http.StatusForbidden: + return map[string]any{"ok": true, "endpoint": c.endpoint, "limited": true}, nil + case http.StatusNotFound: + return nil, fmt.Errorf("ETCD_V2_API_DISABLED: %s does not expose the etcd v2 API (removed in etcd 3.6+)", c.endpoint) + case http.StatusUnauthorized: + return nil, fmt.Errorf("ETCD_UNAUTHENTICATED: authentication failed against %s", c.endpoint) + default: + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return nil, fmt.Errorf("etcd v2 probe against %s failed: HTTP %d %s", c.endpoint, response.StatusCode, strings.TrimSpace(string(body))) + } +} + +// do performs a v2 API request and returns the body. Non-2xx responses are +// converted into etcdError values carrying the server's errorCode/message. +func (c *authenticatedClient) do(ctx context.Context, method, path, body string, header map[string]string) ([]byte, *http.Response, error) { + response, err := c.request(ctx, method, path, body, header) + if err != nil { + return nil, response, err + } + payload, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, response, errorFromResponse(response.StatusCode, payload) + } + if readErr != nil { + return nil, response, readErr + } + return payload, response, nil +} + +func (c *authenticatedClient) request(ctx context.Context, method, path, body string, header map[string]string) (*http.Response, error) { + return c.requestAt(ctx, method, c.endpoint, path, body, header) +} + +// requestAt targets a specific endpoint, used for per-member status fan-out. +func (c *authenticatedClient) requestAt(ctx context.Context, method, endpoint, path, body string, header map[string]string) (*http.Response, error) { + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(endpoint, "/")+path, reader) + if err != nil { + return nil, err + } + if c.username != "" { + req.SetBasicAuth(c.username, c.password) + } + for key, value := range header { + req.Header.Set(key, value) + } + if body != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + response, err := c.http.Do(req) + if err != nil { + // gRPC-style sentinel so the host-side transient-error handling keeps working. + return nil, fmt.Errorf("etcd connection error: %w", err) + } + return response, nil +} + +// doAt performs a v2 API request against a specific endpoint. +func (c *authenticatedClient) doAt(ctx context.Context, method, endpoint, path, body string) ([]byte, *http.Response, error) { + response, err := c.requestAt(ctx, method, endpoint, path, body, nil) + if err != nil { + return nil, nil, err + } + payload, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, response, errorFromResponse(response.StatusCode, payload) + } + if readErr != nil { + return nil, response, readErr + } + return payload, response, nil +} + +// etcdError carries the v2 API error payload (errorCode + message + index). +type etcdError struct { + statusCode int + errorCode int64 + message string + cause string + index int64 +} + +func (e *etcdError) Error() string { + if e.message == "" { + return fmt.Sprintf("etcd v2 error (HTTP %d)", e.statusCode) + } + if e.cause != "" { + return fmt.Sprintf("%s (%s)", e.message, e.cause) + } + return e.message +} + +func errorFromResponse(statusCode int, body []byte) *etcdError { + parsed := &etcdError{statusCode: statusCode} + if len(body) > 0 { + var payload struct { + ErrorCode int64 `json:"errorCode"` + Message string `json:"message"` + Cause string `json:"cause"` + Index int64 `json:"index"` + } + if json.Unmarshal(body, &payload) == nil && payload.Message != "" { + parsed.errorCode = payload.ErrorCode + parsed.message = payload.Message + parsed.cause = payload.Cause + parsed.index = payload.Index + return parsed + } + } + parsed.message = strings.TrimSpace(string(body)) + return parsed +} + +func isEtcdErrorCode(err error, code int64) bool { + var etcdErr *etcdError + if errors.As(err, &etcdErr) { + return etcdErr.errorCode == code + } + return false +} + +func isNotFound(err error) bool { + return isEtcdErrorCode(err, 100) +} + +func isCompareFailed(err error) bool { + return isEtcdErrorCode(err, 101) +} + +func drainClose(body io.ReadCloser) { + _, _ = io.Copy(io.Discard, io.LimitReader(body, 4096)) + _ = body.Close() +} + +func connectionEndpoints(connection connectionParams) []string { + configured := firstNonBlank(connection.EtcdEndpoints, connection.Endpoints, connection.ConnectionString) + var result []string + if configured != "" { + for _, endpoint := range strings.FieldsFunc(configured, func(r rune) bool { return r == ',' || r == '\n' }) { + normalized := normalizeEndpoint(strings.TrimSpace(endpoint), connection.SSL) + if normalized != "" { + result = append(result, normalized) + } + } + } + if len(result) == 0 { + host := connection.Host + if host == "" { + host = "127.0.0.1" + } + port := connection.Port + if port == 0 { + port = defaultPort + } + result = append(result, normalizeEndpoint(fmt.Sprintf("%s:%d", host, port), connection.SSL)) + } + return result +} + +func normalizeEndpoint(endpoint string, tlsEnabled bool) string { + if strings.TrimSpace(endpoint) == "" { + return "" + } + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + return strings.TrimSuffix(endpoint, "/") + } + scheme := "http" + if tlsEnabled { + scheme = "https" + } + return scheme + "://" + endpoint +} + +func endpointHost(rawEndpoint string) string { + parsed, err := url.Parse(rawEndpoint) + if err != nil { + return "" + } + return parsed.Hostname() +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/agents/drivers/etcd2-go/go.mod b/agents/drivers/etcd2-go/go.mod new file mode 100644 index 0000000000..36e909ff53 --- /dev/null +++ b/agents/drivers/etcd2-go/go.mod @@ -0,0 +1,3 @@ +module github.com/t8y2/dbx/agents/drivers/etcd2-go + +go 1.24 diff --git a/agents/drivers/etcd2-go/integration_test.go b/agents/drivers/etcd2-go/integration_test.go new file mode 100644 index 0000000000..e5162f88d2 --- /dev/null +++ b/agents/drivers/etcd2-go/integration_test.go @@ -0,0 +1,412 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// TestLiveEtcd2Agent exercises the v2 agent surface against a real etcd 2.x +// server. Enable with DBX_ETCD2_LIVE=1; configure via DBX_ETCD2_ENDPOINTS, +// DBX_ETCD2_USER, and DBX_ETCD2_PASSWORD (defaults match the server +// deployment: root/123456). +func TestLiveEtcd2Agent(t *testing.T) { + if os.Getenv("DBX_ETCD2_LIVE") != "1" { + t.Skip("set DBX_ETCD2_LIVE=1 to run the live etcd v2 agent test") + } + endpoints := envOrDefault("DBX_ETCD2_ENDPOINTS", "http://172.26.129.83:20041") + user := envOrDefault("DBX_ETCD2_USER", "root") + password := envOrDefault("DBX_ETCD2_PASSWORD", "123456") + + state := newEtcd2Session() + connectParams := map[string]json.RawMessage{ + "etcd_endpoints": json.RawMessage(`"` + endpoints + `"`), + "username": json.RawMessage(`"` + user + `"`), + "password": json.RawMessage(`"` + password + `"`), + } + if _, err := state.connect(connectParams); err != nil { + t.Fatalf("connect failed: %v", err) + } + defer state.close() + + if result, err := state.validateConnection(); err != nil { + t.Fatalf("validate_connection failed: %v", err) + } else if probe := result.(map[string]any); probe["ok"] != true { + t.Fatalf("unexpected probe result: %#v", probe) + } + + prefix := fmt.Sprintf("/dbx/live/%d/", time.Now().UnixNano()) + + // --- KV basics ----------------------------------------------------- + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("world"), + })); err != nil { + t.Fatalf("put failed: %v", err) + } + fetched, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "hello")})) + if err != nil { + t.Fatalf("get failed: %v", err) + } + row := fetched.(map[string]any) + if row["found"] != true || row["key"] != prefix+"hello" { + t.Fatalf("unexpected get result: %#v", row) + } + value := row["value"].(map[string]any) + if value["encoding"] != "utf8" || value["data"] != "world" { + t.Fatalf("unexpected value: %#v", value) + } + metadata := row["metadata"].(map[string]any) + if metadata["createRevision"] != metadata["modRevision"] { + t.Fatalf("fresh key indexes should match: %#v", metadata) + } + + missing, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "missing")})) + if err != nil { + t.Fatalf("missing get failed: %v", err) + } + if missing.(map[string]any)["found"] != false { + t.Fatalf("expected found=false: %#v", missing) + } + + // CAS conflict on stale index. + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("v2"), + "expectedModRevision": json.RawMessage(`1`), + })); err == nil || err.Error() != "ETCD_CAS_CONFLICT: key changed after it was loaded" { + t.Fatalf("expected CAS conflict, got %v", err) + } + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "value": jsonValue("v3"), + "expectedModRevision": json.RawMessage(metadata["modRevision"].(string)), + })); err != nil { + t.Fatalf("CAS put failed: %v", err) + } + + // TTL put and metadata. + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "leased"), + "value": jsonValue("expiring"), + "ttl": json.RawMessage(`300`), + })); err != nil { + t.Fatalf("ttl put failed: %v", err) + } + leased, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "leased")})) + if err != nil { + t.Fatalf("leased get failed: %v", err) + } + if _, hasTTL := leased.(map[string]any)["metadata"].(map[string]any)["ttl"]; !hasTTL { + t.Fatalf("leased key must expose ttl metadata: %#v", leased) + } + + // Lease objects are a v3 concept and must be rejected clearly. + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "nolease"), + "value": jsonValue("v"), + "lease": json.RawMessage(`5`), + })); err == nil || !strings.HasPrefix(err.Error(), "ETCD_V2_LEASE_UNSUPPORTED") { + t.Fatalf("expected lease rejection, got %v", err) + } + + // Rename including conflict and missing-source paths. + if _, err := state.rename(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "old"), + "newKey": jsonString(prefix + "new"), + })); err == nil || err.Error() != "ETCD_NOT_FOUND: source key does not exist" { + t.Fatalf("expected rename not-found, got %v", err) + } + if _, err := state.rename(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "hello"), + "newKey": jsonString(prefix + "renamed"), + })); err != nil { + t.Fatalf("rename failed: %v", err) + } + renamed, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "renamed")})) + if err != nil || renamed.(map[string]any)["found"] != true { + t.Fatalf("rename target missing: %#v %v", renamed, err) + } + if renamed.(map[string]any)["value"].(map[string]any)["data"] != "v3" { + t.Fatalf("rename must carry the value: %#v", renamed) + } + source, err := state.get(paramsWith(map[string]json.RawMessage{"key": jsonString(prefix + "hello")})) + if err != nil || source.(map[string]any)["found"] != false { + t.Fatalf("rename must remove the source: %#v %v", source, err) + } + if _, err := state.rename(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "leased"), + "newKey": jsonString(prefix + "renamed"), + })); err == nil || err.Error() != "ETCD_CAS_CONFLICT: source changed or target already exists" { + t.Fatalf("expected rename conflict, got %v", err) + } + + // list_prefix with continuation. + for i := 0; i < 3; i++ { + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(fmt.Sprintf("%sbulk%d", prefix, i)), + "value": jsonValue(fmt.Sprintf("v%d", i)), + })); err != nil { + t.Fatalf("bulk put %d failed: %v", i, err) + } + } + listed, err := state.listPrefix(paramsWith(map[string]json.RawMessage{ + "prefix": jsonString(prefix), + "limit": json.RawMessage(`2`), + "includeValues": json.RawMessage(`true`), + })) + if err != nil { + t.Fatalf("list_prefix failed: %v", err) + } + list := listed.(map[string]any) + if len(list["keys"].([]any)) != 2 { + t.Fatalf("expected limited list, got %#v", list) + } + continuation, ok := list["continuation"].(string) + if !ok || continuation == "" { + t.Fatalf("expected continuation, got %#v", list) + } + nextPage, err := state.listPrefix(paramsWith(map[string]json.RawMessage{ + "prefix": jsonString(prefix), + "limit": json.RawMessage(`100`), + "includeValues": json.RawMessage(`true`), + "continuation": json.RawMessage(`"` + continuation + `"`), + })) + if err != nil { + t.Fatalf("continuation list failed: %v", err) + } + nextKeys := nextPage.(map[string]any)["keys"].([]any) + if len(nextKeys) == 0 { + t.Fatalf("continuation must resume after the first page") + } + if nextKeys[0].(map[string]any)["key"].(string) == list["keys"].([]any)[1].(map[string]any)["key"].(string) { + t.Fatalf("continuation overlapped the first page") + } + + // --- watch (long poll) -------------------------------------------- + watchStarted, err := state.watchStart(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "watched"), + "includePrevKv": json.RawMessage(`true`), + })) + if err != nil { + t.Fatalf("watch_start failed: %v", err) + } + watchID := watchStarted.(map[string]any)["watchId"].(string) + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "watched"), + "value": jsonValue("first"), + })); err != nil { + t.Fatalf("watched put failed: %v", err) + } + if _, err := state.put(paramsWith(map[string]json.RawMessage{ + "key": jsonString(prefix + "watched"), + "value": jsonValue("second"), + })); err != nil { + t.Fatalf("watched put 2 failed: %v", err) + } + deadline := time.Now().Add(20 * time.Second) + var watchBatches []any + for time.Now().Before(deadline) { + polled, err := state.watchPoll(paramsWith(map[string]json.RawMessage{"watchId": json.RawMessage(`"` + watchID + `"`)})) + if err != nil { + t.Fatalf("watch_poll failed: %v", err) + } + batches := polled.(map[string]any)["batches"].([]any) + watchBatches = append(watchBatches, batches...) + if len(watchBatches) >= 2 { + break + } + time.Sleep(150 * time.Millisecond) + } + if len(watchBatches) < 2 { + t.Fatalf("watch did not deliver both events: %#v", watchBatches) + } + lastBatch := watchBatches[len(watchBatches)-1].(map[string]any) + lastEvent := lastBatch["events"].([]map[string]any)[0] + if lastEvent["value"].(map[string]any)["data"] != "second" { + t.Fatalf("unexpected last watch event: %#v", lastEvent) + } + if lastEvent["previousValue"].(map[string]any)["data"] != "first" { + t.Fatalf("prevKv missing on watch event: %#v", lastEvent) + } + stopped, err := state.watchStop(paramsWith(map[string]json.RawMessage{"watchId": json.RawMessage(`"` + watchID + `"`)})) + if err != nil || stopped.(map[string]bool)["stopped"] != true { + t.Fatalf("watch_stop failed: %#v %v", stopped, err) + } + + // --- auth ---------------------------------------------------------- + userName := fmt.Sprintf("dbxlive%d", time.Now().UnixNano()%100000) + // Idempotent pre-cleanup: an interrupted earlier run may have left the + // fixed-named role behind. + _, _ = state.authRoleDelete(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})) + if _, err := state.authUserAdd(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "password": jsonString("secret123"), + })); err != nil { + t.Fatalf("user add failed: %v", err) + } + if _, err := state.authRoleAdd(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})); err != nil { + t.Fatalf("role add failed: %v", err) + } + if _, err := state.authRolePermission(paramsWith(map[string]json.RawMessage{ + "role": jsonString("dbx_live_role"), + "resource": jsonString("prefix"), + "key": jsonString(prefix), + "access": jsonString("READWRITE"), + }), true); err != nil { + t.Fatalf("role grant permission failed: %v", err) + } + roleDetail, err := state.authRoleGet(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})) + if err != nil { + t.Fatalf("role get failed: %v", err) + } + permissions := roleDetail.(map[string]any)["permissions"].([]map[string]any) + if len(permissions) == 0 { + t.Fatalf("expected permission rows after grant: %#v", roleDetail) + } + if _, err := state.authUserGrantRevokeRole(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "role": jsonString("dbx_live_role"), + }), true); err != nil { + t.Fatalf("grant role failed: %v", err) + } + userDetail, err := state.authUserGet(paramsWith(map[string]json.RawMessage{"user": jsonString(userName)})) + if err != nil { + t.Fatalf("user get failed: %v", err) + } + if len(userDetail.(map[string]any)["roles"].([]string)) == 0 { + t.Fatalf("expected granted role: %#v", userDetail) + } + users, err := state.authUserList(paramsWith(map[string]json.RawMessage{})) + if err != nil { + t.Fatalf("user list failed: %v", err) + } + if !containsAny(users.(map[string]any)["users"].([]string), userName) { + t.Fatalf("user list missing created user: %#v", users) + } + roles, err := state.authRoleList(paramsWith(map[string]json.RawMessage{})) + if err != nil || !containsAny(roles.(map[string]any)["roles"].([]string), "dbx_live_role") { + t.Fatalf("role list missing role: %#v %v", roles, err) + } + if _, err := state.authRolePermission(paramsWith(map[string]json.RawMessage{ + "role": jsonString("dbx_live_role"), + "resource": jsonString("prefix"), + "key": jsonString(prefix), + }), false); err != nil { + t.Fatalf("role revoke permission failed: %v", err) + } + afterRevoke, err := state.authRoleGet(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})) + if err != nil { + t.Fatalf("role get after revoke failed: %v", err) + } + if len(afterRevoke.(map[string]any)["permissions"].([]map[string]any)) != 0 { + t.Fatalf("permissions must be empty after revoke: %#v", afterRevoke) + } + if _, err := state.authUserGrantRevokeRole(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "role": jsonString("dbx_live_role"), + }), false); err != nil { + t.Fatalf("revoke role failed: %v", err) + } + if _, err := state.authUserChangePassword(paramsWith(map[string]json.RawMessage{ + "user": jsonString(userName), + "password": jsonString("rotated456"), + })); err != nil { + t.Fatalf("change password failed: %v", err) + } + if _, err := state.authUserDelete(paramsWith(map[string]json.RawMessage{"user": jsonString(userName)})); err != nil { + t.Fatalf("user delete failed: %v", err) + } + if _, err := state.authRoleDelete(paramsWith(map[string]json.RawMessage{"role": jsonString("dbx_live_role")})); err != nil { + t.Fatalf("role delete failed: %v", err) + } + + // --- status -------------------------------------------------------- + status, err := state.status(paramsWith(map[string]json.RawMessage{})) + if err != nil { + t.Fatalf("kv_status failed: %v", err) + } + statusRow := status.(map[string]any) + if statusRow["clusterId"] == nil || statusRow["leaderId"] == nil { + t.Fatalf("status missing cluster identity: %#v", statusRow) + } + members := statusRow["members"].([]map[string]any) + if len(members) == 0 || members[0]["reachable"] != true { + t.Fatalf("unexpected member rows: %#v", members) + } + + // --- delete + cleanup ---------------------------------------------- + for _, key := range []string{prefix + "leased", prefix + "renamed", prefix + "watched", prefix + "bulk0", prefix + "bulk1", prefix + "bulk2"} { + if _, err := state.delete(paramsWith(map[string]json.RawMessage{"key": jsonString(key)})); err != nil { + t.Fatalf("delete %s failed: %v", key, err) + } + } +} + +func TestLiveEtcd2ProtocolFlow(t *testing.T) { + if os.Getenv("DBX_ETCD2_LIVE") != "1" { + t.Skip("set DBX_ETCD2_LIVE=1 to run the live etcd v2 agent test") + } + endpoints := envOrDefault("DBX_ETCD2_ENDPOINTS", "http://172.26.129.83:20041") + user := envOrDefault("DBX_ETCD2_USER", "root") + password := envOrDefault("DBX_ETCD2_PASSWORD", "123456") + + server := newRuntimeServer() + connection := fmt.Sprintf(`{"etcd_endpoints":%q,"username":%q,"password":%q}`, endpoints, user, password) + + handshake, _ := server.handleLine(`{"id":1,"method":"handshake","params":{}}`) + if handshake.Error != nil { + t.Fatalf("handshake failed: %#v", handshake) + } + openLine := fmt.Sprintf(`{"id":2,"method":"open_session","params":{"agentSessionId":"s1","connection":%s}}`, connection) + opened, _ := server.handleLine(openLine) + if opened.Error != nil { + t.Fatalf("open_session failed: %#v", opened) + } + putLine := fmt.Sprintf(`{"id":3,"method":"kv_put","params":{"agentSessionId":"s1","key":"/dbx:proto","value":{"encoding":"utf8","data":"flow"}}}`) + put, _ := server.handleLine(putLine) + if put.Error != nil { + t.Fatalf("kv_put failed: %#v", put) + } + v3Only, _ := server.handleLine(`{"id":4,"method":"kv_history","params":{"agentSessionId":"s1","key":"/dbx:proto"}}`) + if v3Only.Error == nil || !strings.HasPrefix(v3Only.Error.Message, "ETCD_V2_UNSUPPORTED") { + t.Fatalf("expected ETCD_V2_UNSUPPORTED, got %#v", v3Only) + } + closed, _ := server.handleLine(`{"id":5,"method":"close_session","params":{"agentSessionId":"s1"}}`) + if closed.Error != nil { + t.Fatalf("close_session failed: %#v", closed) + } +} + +func envOrDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func paramsWith(params map[string]json.RawMessage) map[string]json.RawMessage { + return params +} + +func jsonString(value string) json.RawMessage { + encoded, _ := json.Marshal(value) + return json.RawMessage(encoded) +} + +func jsonValue(value string) json.RawMessage { + encoded, _ := json.Marshal(value) + return json.RawMessage(`{"encoding":"utf8","data":` + string(encoded) + `}`) +} + +func containsAny(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/agents/drivers/etcd2-go/kv.go b/agents/drivers/etcd2-go/kv.go new file mode 100644 index 0000000000..39650badbe --- /dev/null +++ b/agents/drivers/etcd2-go/kv.go @@ -0,0 +1,524 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +const defaultListLimit = 100 + +// v2Node is one node of the etcd v2 keys API tree. +type v2Node struct { + Key string `json:"key"` + Value string `json:"value"` + Dir bool `json:"dir"` + Expiration *string `json:"expiration"` + TTL *int64 `json:"ttl"` + CreatedIndex int64 `json:"createdIndex"` + ModifiedIndex int64 `json:"modifiedIndex"` + Nodes []v2Node `json:"nodes"` +} + +type v2KeysResponse struct { + Action string `json:"action"` + Node *v2Node `json:"node"` + PrevNode *v2Node `json:"prevNode"` +} + +// v2KeyPath normalizes a protocol key into a v2 keys API path. v2 keys are +// slash paths; an empty prefix maps to the root directory. +func v2KeyPath(key string) string { + if key == "" { + return "/v2/keys/" + } + if !strings.HasPrefix(key, "/") { + key = "/" + key + } + return "/v2/keys" + key +} + +func (s *etcd2Session) listPrefix(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + prefix := stringOrDefault(params, "prefix", "") + limit := intOrDefault(params, "limit", defaultListLimit) + if limit < 1 { + limit = 1 + } + includeValues := boolOrDefault(params, "includeValues", false) + continuation := stringOrNull(params, "continuation") + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + // v2 has no revisioned reads; the revision parameter is accepted and ignored. + query := url.Values{} + query.Set("recursive", "true") + query.Set("sorted", "true") + body, _, err := client.do(ctx, http.MethodGet, v2KeyPath(prefix)+"?"+query.Encode(), "", nil) + if err != nil { + if isNotFound(err) { + return map[string]any{"keys": []any{}, "continuation": nil, "revision": nil}, nil + } + return nil, err + } + var parsed v2KeysResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + + startAfter := "" + if continuation != nil && *continuation != "" { + startAfter = *continuation + } + rows := []map[string]any{} + flattenNodes(parsed.Node, includeValues, func(row map[string]any) { + key := row["key"].(string) + if startAfter != "" && key <= startAfter { + return + } + if len(rows) < limit { + rows = append(rows, row) + } + }) + + result := map[string]any{"keys": toAnySlice(rows)} + if len(rows) == limit { + result["continuation"] = rows[len(rows)-1]["key"] + } else { + result["continuation"] = nil + } + // etcd v2 exposes the current index on the listing's node headers; the + // modifiedIndex of the returned root is the closest analog of a revision. + var revision any + if parsed.Node != nil { + revision = longString(parsed.Node.ModifiedIndex) + } + result["revision"] = revision + return result, nil +} + +func flattenNodes(node *v2Node, includeValues bool, emit func(map[string]any)) { + if node == nil { + return + } + if !node.Dir { + row := nodeMetadata(node) + row["key"] = node.Key + if includeValues { + row["value"] = valueObject([]byte(node.Value)) + } + emit(row) + return + } + children := append([]v2Node(nil), node.Nodes...) + sort.SliceStable(children, func(i, j int) bool { return children[i].Key < children[j].Key }) + for index := range children { + flattenNodes(&children[index], includeValues, emit) + } +} + +func toAnySlice(rows []map[string]any) []any { + result := make([]any, len(rows)) + for index, row := range rows { + result[index] = row + } + return result +} + +func (s *etcd2Session) get(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodGet, v2KeyPath(key), "", nil) + if err != nil { + if isNotFound(err) { + return map[string]any{"found": false, "key": nil, "value": nil, "metadata": nil}, nil + } + return nil, err + } + var parsed v2KeysResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + if parsed.Node == nil || parsed.Node.Dir { + return map[string]any{"found": false, "key": nil, "value": nil, "metadata": nil}, nil + } + node := parsed.Node + result := map[string]any{ + "found": true, + "key": node.Key, + "keyBytes": bytesObject([]byte(node.Key)), + } + if !boolOrDefault(params, "metadataOnly", false) { + result["value"] = valueObject([]byte(node.Value)) + } else { + result["value"] = nil + } + result["metadata"] = nodeMetadata(node) + return result, nil +} + +func (s *etcd2Session) put(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + value, err := parseValueObject(rawObject(params, "value")) + if err != nil { + return nil, err + } + expectedModRevision := longOrNull(params, "expectedModRevision") + expectedCreateRevision := longOrNull(params, "expectedCreateRevision") + ttlValue := longOrNull(params, "ttl") + preserveLease := boolOrDefault(params, "preserveLease", false) + + leaseValue := longOrNull(params, "lease") + hasLease := leaseValue != nil + hasTtl := ttlValue != nil + if (hasLease && hasTtl) || (preserveLease && (hasLease || hasTtl)) { + return nil, errors.New("lease, ttl, and preserveLease cannot be specified together") + } + if hasLease { + return nil, errors.New("ETCD_V2_LEASE_UNSUPPORTED: the etcd v2 API has no lease objects; use ttl instead") + } + if preserveLease { + return nil, errors.New("ETCD_V2_LEASE_UNSUPPORTED: the etcd v2 API has no lease objects; use ttl instead") + } + if expectedCreateRevision != nil && *expectedCreateRevision != 0 { + return nil, errors.New("ETCD_V2_CAS_UNSUPPORTED: the etcd v2 API cannot compare createdIndex; use expectedModRevision") + } + if hasTtl && *ttlValue <= 0 { + return nil, errors.New("ttl must be a positive integer") + } + + form := url.Values{} + form.Set("value", value) + if hasTtl { + form.Set("ttl", strconv.FormatInt(*ttlValue, 10)) + } + if expectedModRevision != nil { + form.Set("prevIndex", strconv.FormatInt(*expectedModRevision, 10)) + } + if expectedCreateRevision != nil && *expectedCreateRevision == 0 { + form.Set("prevExist", "false") + } + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodPut, v2KeyPath(key), form.Encode(), nil) + if err != nil { + if isCompareFailed(err) { + return nil, errors.New("ETCD_CAS_CONFLICT: key changed after it was loaded") + } + return nil, err + } + var parsed v2KeysResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + var revision int64 + if parsed.Node != nil { + revision = parsed.Node.ModifiedIndex + } + return map[string]any{"revision": longString(revision)}, nil +} + +func (s *etcd2Session) delete(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + expectedModRevision := longOrNull(params, "expectedModRevision") + path := v2KeyPath(key) + if expectedModRevision != nil { + query := url.Values{} + query.Set("prevIndex", strconv.FormatInt(*expectedModRevision, 10)) + path += "?" + query.Encode() + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodDelete, path, "", nil) + if err != nil { + if isCompareFailed(err) { + return nil, errors.New("ETCD_CAS_CONFLICT: key changed after it was loaded") + } + return nil, err + } + var parsed v2KeysResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + var revision int64 + if parsed.Node != nil { + revision = parsed.Node.ModifiedIndex + } + return map[string]any{"deleted": int64(1), "revision": longString(revision)}, nil +} + +// rename is a non-atomic check-then-set: the v2 API has no transactions, so a +// concurrent writer between the target check and the delete can be lost. The +// protocol exposes this honestly instead of pretending atomicity. +func (s *etcd2Session) rename(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + sourceKey, err := keyBytesParam(params) + if err != nil { + return nil, err + } + newKey := stringOrNull(params, "newKey") + if newKey == nil || *newKey == "" { + return nil, errors.New("ETCD_NEWKEY_REQUIRED") + } + targetKey := *newKey + if sourceKey == targetKey { + return map[string]any{"renamed": true, "revision": nil}, nil + } + + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + body, _, err := client.do(ctx, http.MethodGet, v2KeyPath(sourceKey), "", nil) + if err != nil { + if isNotFound(err) { + return nil, errors.New("ETCD_NOT_FOUND: source key does not exist") + } + return nil, err + } + var source v2KeysResponse + if err := json.Unmarshal(body, &source); err != nil { + return nil, err + } + if source.Node == nil || source.Node.Dir { + return nil, errors.New("ETCD_NOT_FOUND: source key does not exist") + } + if _, _, err := client.do(ctx, http.MethodGet, v2KeyPath(targetKey), "", nil); err == nil { + return nil, errors.New("ETCD_CAS_CONFLICT: source changed or target already exists") + } else if !isNotFound(err) { + return nil, err + } + + expected := longOrNull(params, "expectedModRevision") + expectedRevision := source.Node.ModifiedIndex + if expected != nil { + expectedRevision = *expected + } + form := url.Values{} + form.Set("value", source.Node.Value) + if source.Node.TTL != nil { + form.Set("ttl", strconv.FormatInt(*source.Node.TTL, 10)) + } + form.Set("prevExist", "false") + if _, _, err := client.do(ctx, http.MethodPut, v2KeyPath(targetKey), form.Encode(), nil); err != nil { + if isCompareFailed(err) { + return nil, errors.New("ETCD_CAS_CONFLICT: source changed or target already exists") + } + return nil, err + } + deleteQuery := url.Values{} + deleteQuery.Set("prevIndex", strconv.FormatInt(expectedRevision, 10)) + if _, _, err := client.do(ctx, http.MethodDelete, v2KeyPath(sourceKey)+"?"+deleteQuery.Encode(), "", nil); err != nil { + if isCompareFailed(err) { + return nil, errors.New("ETCD_CAS_CONFLICT: source changed or target already exists") + } + return nil, err + } + return map[string]any{"renamed": true, "revision": nil}, nil +} + +func nodeMetadata(node *v2Node) map[string]any { + metadata := map[string]any{ + "createRevision": longString(node.CreatedIndex), + "modRevision": longString(node.ModifiedIndex), + "version": longString(int64(1)), + "lease": longString(0), + "valueSize": len(node.Value), + } + if node.TTL != nil { + metadata["ttl"] = *node.TTL + } + return metadata +} + +func longString(value int64) string { + return strconv.FormatInt(value, 10) +} + +func unsignedLongString(value int64) string { + return strconv.FormatUint(uint64(value), 10) +} + +// prefixEnd mirrors the v3 agent's byte-increment range end; for v2 rows it +// only feeds UI metadata, where the explicit resource field carries the truth. +func prefixEnd(prefix string) string { + if prefix == "" { + return "\x00" + } + end := []byte(prefix) + for i := len(end) - 1; i >= 0; i-- { + if end[i] < 0xff { + end[i]++ + return string(end[:i+1]) + } + } + return "\x00" +} + +func keyBytesParam(params map[string]json.RawMessage) (string, error) { + if encoded := rawObject(params, "keyBytes"); encoded != nil { + value, err := parseValueObject(encoded) + if err != nil { + return "", err + } + return value, nil + } + if raw, ok := params["key"]; ok && raw != nil && string(raw) != "null" { + var key string + if err := json.Unmarshal(raw, &key); err != nil { + return "", err + } + return key, nil + } + return "", errors.New("Key is required") +} + +func rawObject(params map[string]json.RawMessage, key string) map[string]json.RawMessage { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil || object == nil { + return nil + } + return object +} + +func bytesObject(bytes []byte) map[string]any { + return map[string]any{ + "encoding": "base64", + "data": base64.StdEncoding.EncodeToString(bytes), + } +} + +func valueObject(value []byte) map[string]any { + if utf8.Valid(value) { + return map[string]any{"encoding": "utf8", "data": string(value)} + } + return bytesObject(value) +} + +func displayBytes(bytes []byte) string { + if utf8.Valid(bytes) { + return string(bytes) + } + return base64.StdEncoding.EncodeToString(bytes) +} + +func parseValueObject(value map[string]json.RawMessage) (string, error) { + if value == nil { + value = map[string]json.RawMessage{} + } + encoding := stringOrDefault(value, "encoding", "utf8") + data := stringOrDefault(value, "data", "") + if encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + return string(decoded), nil + } + if encoding != "utf8" { + return "", fmt.Errorf("Unsupported value encoding: %s", encoding) + } + return data, nil +} + +func stringOrNull(params map[string]json.RawMessage, key string) *string { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return nil + } + return &value +} + +func stringOrDefault(params map[string]json.RawMessage, key string, fallback string) string { + value := stringOrNull(params, key) + if value == nil { + return fallback + } + return *value +} + +func intOrDefault(params map[string]json.RawMessage, key string, fallback int) int { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return fallback + } + var value int + if err := json.Unmarshal(raw, &value); err != nil { + return fallback + } + return value +} + +func boolOrDefault(params map[string]json.RawMessage, key string, fallback bool) bool { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return fallback + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + return fallback + } + return value +} + +func longOrNull(params map[string]json.RawMessage, key string) *int64 { + raw := params[key] + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var value int64 + if err := json.Unmarshal(raw, &value); err != nil { + return nil + } + return &value +} + +func requiredString(params map[string]json.RawMessage, field string) (string, error) { + value := stringOrNull(params, field) + if value == nil || *value == "" { + return "", fmt.Errorf("ETCD_%s_REQUIRED", strings.ToUpper(field)) + } + return *value, nil +} diff --git a/agents/drivers/etcd2-go/main.go b/agents/drivers/etcd2-go/main.go new file mode 100644 index 0000000000..dd31702c1e --- /dev/null +++ b/agents/drivers/etcd2-go/main.go @@ -0,0 +1,422 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "sync" +) + +const ( + protocolVersion = 2 + legacyAgentSessionID = "__legacy__" + maxAgentSessions = 256 +) + +// Capabilities advertise the v2 API surface: no history, no lease objects, +// no compaction, and no defrag because etcd v2 semantics do not have them. +var capabilities = []string{ + "connect", "test_connection", "kv", "kv_ttl", "kv_cas", "kv_list_values", + "kv_status", "etcd_watch", "etcd_auth", "multi_session", +} + +type request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params map[string]json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +// etcd2Session carries the per-connection HTTP client and derived state. +// Requests for one session are serialized by agentSession.mu; watch pump +// goroutines reach the maps through watchesMu instead. +type etcd2Session struct { + clientMu sync.Mutex + httpClient *authenticatedClient + connectedEndpoints []string + serverVersion string + + watchesMu sync.Mutex + watches map[string]*watchState + watchBufferedBytes int64 + + activeCancelMu sync.Mutex + activeCancel context.CancelFunc +} + +func (s *etcd2Session) reserveWatchBuffer(bytes int64) bool { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + if bytes < 0 || s.watchBufferedBytes+bytes > maxSessionWatchBufferBytes { + return false + } + s.watchBufferedBytes += bytes + return true +} + +func (s *etcd2Session) releaseWatchBuffer(bytes int64) { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + s.watchBufferedBytes -= bytes + if s.watchBufferedBytes < 0 { + s.watchBufferedBytes = 0 + } +} + +type agentSession struct { + state *etcd2Session + mu sync.Mutex +} + +func (a *agentSession) cancelActive() { + a.state.activeCancelMu.Lock() + cancel := a.state.activeCancel + a.state.activeCancelMu.Unlock() + if cancel != nil { + cancel() + } +} + +type runtimeServer struct { + mu sync.RWMutex + sessions map[string]*agentSession +} + +func main() { + configureRuntimeParallelism() + server := newRuntimeServer() + encoder := json.NewEncoder(os.Stdout) + var encoderMu sync.Mutex + var requests sync.WaitGroup + fmt.Fprintln(os.Stdout, `{"ready":true}`) + + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 512*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var envelope request + if json.Unmarshal([]byte(line), &envelope) == nil && envelope.Method == "shutdown" { + requests.Wait() + result, _ := server.handleLine(line) + encoderMu.Lock() + _ = encoder.Encode(result) + encoderMu.Unlock() + server.closeAll() + return + } + requests.Add(1) + go func(line string) { + defer requests.Done() + result, _ := server.handleLine(line) + encoderMu.Lock() + defer encoderMu.Unlock() + if err := encoder.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "failed to write response: %v\n", err) + } + }(line) + } + requests.Wait() + server.closeAll() +} + +func configureRuntimeParallelism() { + if raw := strings.TrimSpace(os.Getenv("DBX_AGENT_ETCD2_GOMAXPROCS")); raw != "" { + if configured, err := strconv.Atoi(raw); err == nil && configured > 0 { + runtime.GOMAXPROCS(configured) + return + } + } + if strings.TrimSpace(os.Getenv("GOMAXPROCS")) != "" { + return + } + runtime.GOMAXPROCS(min(runtime.NumCPU(), 4)) +} + +func newRuntimeServer() *runtimeServer { + return &runtimeServer{sessions: map[string]*agentSession{}} +} + +func (r *runtimeServer) handleLine(line string) (response, bool) { + var req request + if err := json.Unmarshal([]byte(line), &req); err != nil { + return errorResponse(nil, "", "", err), false + } + if len(req.ID) == 0 { + req.ID = json.RawMessage("1") + } + result, shutdown, err := r.dispatch(req.Method, req.Params) + if err != nil { + return errorResponse(req.ID, req.Method, stringParam(req.Params, "agentSessionId"), err), false + } + return response{JSONRPC: "2.0", ID: req.ID, Result: result}, shutdown +} + +func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessage) (any, bool, error) { + switch method { + case "handshake": + return handshakeResult(), false, nil + case "open_session": + id := requiredSessionID(params) + if id == "" { + return nil, false, errors.New("agentSessionId is required") + } + return r.openSession(id, params) + case "close_session": + return r.closeSession(stringParam(params, "agentSessionId")), false, nil + case "validate_session": + session, err := r.session(requiredSessionID(params)) + if err != nil { + return nil, false, err + } + session.mu.Lock() + defer session.mu.Unlock() + result, err := session.state.validateConnection() + return result, false, err + case "cancel_session": + session, err := r.session(requiredSessionID(params)) + if err != nil { + return nil, false, err + } + session.cancelActive() + return map[string]bool{"ok": true}, false, nil + case "test_connection": + return r.testConnection(params) + case "connect": + _ = r.closeSession(legacyAgentSessionID) + return r.openSession(legacyAgentSessionID, params) + case "disconnect": + return r.closeSession(legacyAgentSessionID), false, nil + case "shutdown": + return map[string]bool{"ok": true}, true, r.closeAll() + default: + id := stringParam(params, "agentSessionId") + if id == "" { + id = legacyAgentSessionID + } + session, err := r.session(id) + if err != nil { + return nil, false, err + } + session.mu.Lock() + defer session.mu.Unlock() + result, err := session.state.handle(method, params) + return result, false, err + } +} + +func requiredSessionID(params map[string]json.RawMessage) string { + return strings.TrimSpace(stringParam(params, "agentSessionId")) +} + +func (r *runtimeServer) openSession(id string, params map[string]json.RawMessage) (any, bool, error) { + r.mu.Lock() + if _, exists := r.sessions[id]; exists { + r.mu.Unlock() + return nil, false, fmt.Errorf("Agent session already exists: %s", id) + } + if len(r.sessions) >= maxAgentSessions { + r.mu.Unlock() + return nil, false, fmt.Errorf("Agent session limit reached: %d", maxAgentSessions) + } + session := &agentSession{state: newEtcd2Session()} + r.sessions[id] = session + r.mu.Unlock() + + if _, err := session.state.connect(params); err != nil { + r.mu.Lock() + delete(r.sessions, id) + r.mu.Unlock() + session.state.close() + return nil, false, err + } + return map[string]bool{"ok": true}, false, nil +} + +func (r *runtimeServer) session(id string) (*agentSession, error) { + r.mu.RLock() + session := r.sessions[id] + r.mu.RUnlock() + if session == nil { + return nil, fmt.Errorf("Agent session not found: %s", id) + } + return session, nil +} + +func (r *runtimeServer) closeSession(id string) any { + r.mu.Lock() + session := r.sessions[id] + delete(r.sessions, id) + r.mu.Unlock() + if session != nil { + session.cancelActive() + session.mu.Lock() + session.state.close() + session.mu.Unlock() + } + return map[string]bool{"ok": true} +} + +func (r *runtimeServer) closeAll() error { + r.mu.Lock() + sessions := r.sessions + r.sessions = map[string]*agentSession{} + r.mu.Unlock() + var firstErr error + for _, session := range sessions { + session.cancelActive() + if err := session.state.close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (r *runtimeServer) testConnection(params map[string]json.RawMessage) (any, bool, error) { + state := newEtcd2Session() + defer state.close() + if _, err := state.connect(params); err != nil { + return nil, false, err + } + return map[string]bool{"ok": true}, false, nil +} + +func newEtcd2Session() *etcd2Session { + return &etcd2Session{watches: map[string]*watchState{}} +} + +func handshakeResult() map[string]any { + return map[string]any{ + "protocolVersion": protocolVersion, + "agentProtocolVersion": protocolVersion, + "capabilities": capabilities, + } +} + +func (s *etcd2Session) beginOperation() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), operationTimeout) + s.activeCancelMu.Lock() + s.activeCancel = cancel + s.activeCancelMu.Unlock() + return ctx, cancel +} + +func (s *etcd2Session) endOperation(cancel context.CancelFunc) { + cancel() + s.activeCancelMu.Lock() + s.activeCancel = nil + s.activeCancelMu.Unlock() +} + +func (s *etcd2Session) handle(method string, params map[string]json.RawMessage) (any, error) { + switch method { + case "handshake": + return handshakeResult(), nil + case "connect", "test_connection": + if _, err := s.connect(params); err != nil { + return nil, err + } + return map[string]bool{"ok": true}, nil + case "validate_connection": + return s.validateConnection() + case "kv_list_prefix": + return s.listPrefix(params) + case "kv_get": + return s.get(params) + case "kv_put": + return s.put(params) + case "kv_delete": + return s.delete(params) + case "kv_rename": + return s.rename(params) + case "kv_status": + return s.status(params) + case "etcd_watch_start": + return s.watchStart(params) + case "etcd_watch_poll": + return s.watchPoll(params) + case "etcd_watch_stop": + return s.watchStop(params) + case "etcd_auth_user_list": + return s.authUserList(params) + case "etcd_auth_user_get": + return s.authUserGet(params) + case "etcd_auth_user_add": + return s.authUserAdd(params) + case "etcd_auth_user_delete": + return s.authUserDelete(params) + case "etcd_auth_user_change_password": + return s.authUserChangePassword(params) + case "etcd_auth_user_grant_role": + return s.authUserGrantRevokeRole(params, true) + case "etcd_auth_user_revoke_role": + return s.authUserGrantRevokeRole(params, false) + case "etcd_auth_role_list": + return s.authRoleList(params) + case "etcd_auth_role_get": + return s.authRoleGet(params) + case "etcd_auth_role_add": + return s.authRoleAdd(params) + case "etcd_auth_role_delete": + return s.authRoleDelete(params) + case "etcd_auth_role_grant_permission": + return s.authRolePermission(params, true) + case "etcd_auth_role_revoke_permission": + return s.authRolePermission(params, false) + case "disconnect": + s.close() + return map[string]bool{"ok": true}, nil + case "shutdown": + s.close() + return map[string]bool{"ok": true}, nil + default: + if isEtcd3OnlyMethod(method) { + return nil, fmt.Errorf("ETCD_V2_UNSUPPORTED: %s is not available on the etcd v2 API", method) + } + return nil, fmt.Errorf("unknown method: %s", method) + } +} + +func isEtcd3OnlyMethod(method string) bool { + switch method { + case "kv_history", "etcd_compact", "etcd_defrag", + "etcd_lease_list", "etcd_lease_get", "etcd_lease_grant", + "etcd_lease_keepalive_once", "etcd_lease_revoke": + return true + } + return false +} + +func decodeParams(params map[string]json.RawMessage, target any) error { + if params == nil { + params = map[string]json.RawMessage{} + } + data, err := json.Marshal(params) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func stringParam(params map[string]json.RawMessage, key string) string { + if params == nil { + return "" + } + var result string + _ = json.Unmarshal(params[key], &result) + return result +} diff --git a/agents/drivers/etcd2-go/main_test.go b/agents/drivers/etcd2-go/main_test.go new file mode 100644 index 0000000000..89314a87d6 --- /dev/null +++ b/agents/drivers/etcd2-go/main_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestHandshakeAdvertisesV2Capabilities(t *testing.T) { + server := newRuntimeServer() + result, _, err := server.dispatch("handshake", nil) + if err != nil { + t.Fatalf("handshake failed: %v", err) + } + handshake := result.(map[string]any) + want := []string{ + "connect", "test_connection", "kv", "kv_ttl", "kv_cas", "kv_list_values", + "kv_status", "etcd_watch", "etcd_auth", "multi_session", + } + got := handshake["capabilities"].([]string) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("capability mismatch:\n got %v\nwant %v", got, want) + } + for _, forbidden := range []string{"kv_history", "etcd_lease", "etcd_compaction", "etcd_defrag", "structured_error_v1"} { + for _, capability := range got { + if capability == forbidden { + t.Fatalf("%s must not be advertised on the v2 agent", forbidden) + } + } + } +} + +func TestHandleLineReportsSessionNotFound(t *testing.T) { + server := newRuntimeServer() + response, _ := server.handleLine(`{"id":7,"method":"kv_get","params":{"key":"a"}}`) + if response.Error == nil || response.Error.Code != -1 { + t.Fatalf("expected code -1 error, got %#v", response) + } + if response.Error.Message != "Agent session not found: __legacy__" { + t.Fatalf("unexpected message: %q", response.Error.Message) + } +} + +func TestUnconnectedSessionErrors(t *testing.T) { + state := newEtcd2Session() + if _, err := state.get(map[string]json.RawMessage{"key": json.RawMessage(`"k"`)}); err == nil || err.Error() != "Not connected" { + t.Fatalf("kv_get before connect: %v", err) + } + if _, err := state.validateConnection(); err == nil || err.Error() != "Not connected" { + t.Fatalf("validate_connection before connect: %v", err) + } +} + +func TestEtcd3OnlyMethodsRejected(t *testing.T) { + state := newEtcd2Session() + for _, method := range []string{"kv_history", "etcd_lease_list", "etcd_lease_grant", "etcd_compact", "etcd_defrag"} { + _, err := state.handle(method, map[string]json.RawMessage{}) + if err == nil || !strings.HasPrefix(err.Error(), "ETCD_V2_UNSUPPORTED") { + t.Fatalf("%s should report ETCD_V2_UNSUPPORTED, got %v", method, err) + } + } + _, err := state.handle("etcd_nonsense", map[string]json.RawMessage{}) + if err == nil || !strings.Contains(err.Error(), "unknown method") { + t.Fatalf("expected unknown method error, got %v", err) + } +} + +func TestConnectionEndpoints(t *testing.T) { + cases := []struct { + name string + connection connectionParams + want []string + }{ + {"host port fallback", connectionParams{Port: 2379}, []string{"http://127.0.0.1:2379"}}, + {"ssl scheme", connectionParams{Host: "db", Port: 1, SSL: true}, []string{"https://db:1"}}, + {"comma list", connectionParams{EtcdEndpoints: "a:1,b:2"}, []string{"http://a:1", "http://b:2"}}, + {"existing scheme trimmed", connectionParams{EtcdEndpoints: "https://a:1/"}, []string{"https://a:1"}}, + } + for _, testCase := range cases { + got := connectionEndpoints(testCase.connection) + if strings.Join(got, "|") != strings.Join(testCase.want, "|") { + t.Fatalf("%s: got %v, want %v", testCase.name, got, testCase.want) + } + } +} + +func TestTLSConfigRequiresCertAndKeyTogether(t *testing.T) { + _, err := tlsConfigFor(connectionParams{SSL: true, ClientCertPath: "/tmp/cert.pem"}) + if err == nil || err.Error() != "Client certificate and key must be provided together" { + t.Fatalf("expected paired cert/key error, got %v", err) + } +} + +func TestV2KeyPath(t *testing.T) { + cases := []struct{ key, want string }{ + {"", "/v2/keys/"}, + {"foo", "/v2/keys/foo"}, + {"/foo/bar", "/v2/keys/foo/bar"}, + } + for _, testCase := range cases { + if got := v2KeyPath(testCase.key); got != testCase.want { + t.Fatalf("v2KeyPath(%q) = %q, want %q", testCase.key, got, testCase.want) + } + } +} + +func TestErrorFromResponse(t *testing.T) { + err := errorFromResponse(404, []byte(`{"errorCode":100,"message":"Key not found","cause":"/foo","index":37}`)) + if !isNotFound(err) { + t.Fatalf("expected key-not-found classification: %#v", err) + } + if isCompareFailed(err) { + t.Fatalf("errorCode 100 is not compare failure") + } + if err.Error() != "Key not found (/foo)" { + t.Fatalf("unexpected message: %q", err.Error()) + } + compare := errorFromResponse(412, []byte(`{"errorCode":101,"message":"Compare failed","cause":"[1 != 2]"}`)) + if !isCompareFailed(compare) { + t.Fatalf("expected compare failure classification: %#v", compare) + } + plain := errorFromResponse(500, []byte("boom")) + if plain.Error() != "boom" { + t.Fatalf("plain body passthrough broken: %q", plain.Error()) + } +} + +func TestV2PermissionRows(t *testing.T) { + rows := v2PermissionRows("read", []string{"/a", "/p/*"}) + if len(rows) != 2 { + t.Fatalf("expected 2 permission rows, got %d", len(rows)) + } + first := rows[0] + if first["access"] != "read" || first["resource"] != "key" { + t.Fatalf("unexpected exact-key row: %#v", first) + } + second := rows[1] + if second["access"] != "read" || second["resource"] != "prefix" { + t.Fatalf("unexpected prefix row: %#v", second) + } + end := second["rangeEnd"].(map[string]any) + if end["data"] != "L3Aw" { // prefixEnd("/p/") == "/p0" + t.Fatalf("prefix rangeEnd wrong: %#v", end) + } +} + +func TestV2PermissionPattern(t *testing.T) { + if got := v2PermissionPattern("key", "/foo"); got != "/foo" { + t.Fatalf("key pattern must stay bare: %q", got) + } + if got := v2PermissionPattern("prefix", "/foo/"); got != "/foo/*" { + t.Fatalf("prefix pattern must become glob: %q", got) + } + if got := v2PermissionPattern("all", "/ignored"); got != "/*" { + t.Fatalf("all pattern must be root glob: %q", got) + } +} + +func TestV2RoleGrantDocument(t *testing.T) { + encoded, err := json.Marshal(v2Role{ + Role: "r1", + Grant: &v2Permissions{KV: v2RWPermission{Read: []string{"/p/*"}}}, + }) + if err != nil { + t.Fatal(err) + } + var round v2Role + if err := json.Unmarshal(encoded, &round); err != nil { + t.Fatal(err) + } + if round.Role != "r1" || len(round.Grant.KV.Read) != 1 || round.Grant.KV.Read[0] != "/p/*" { + t.Fatalf("grant document round-trip broken: %s", encoded) + } + if round.Permissions != nil { + t.Fatalf("grant documents must not carry permissions: %s", encoded) + } +} + +func TestMemberHexToUnsigned(t *testing.T) { + if got := memberHexToUnsigned("272e204152"); got != "168277590354" { + t.Fatalf("unexpected conversion: %v", got) + } + if got := memberHexToUnsigned(""); got != nil { + t.Fatalf("empty id must map to nil, got %v", got) + } + if got := memberHexToUnsigned("zzz"); got != nil { + t.Fatalf("invalid hex must map to nil, got %v", got) + } +} + +func TestWatchBudgetState(t *testing.T) { + session := newEtcd2Session() + state := &watchState{watchID: "w", session: session} + state.append(10, []map[string]any{{"eventType": "put"}}, 128) + state.append(11, []map[string]any{{"eventType": "put"}}, maxWatchBufferBytes+1) + polled := state.poll() + if len(polled["batches"].([]any)) != 1 { + t.Fatalf("overflow must keep buffered batch: %#v", polled) + } + terminal, ok := polled["terminal"].(map[string]any) + if !ok || terminal["reason"] != "overflow" { + t.Fatalf("expected overflow terminal: %#v", polled) + } + if session.watchBufferedBytesSnapshot() != 0 { + t.Fatalf("budget not released: %d", session.watchBufferedBytesSnapshot()) + } +} + +func TestWatchLimits(t *testing.T) { + session := newEtcd2Session() + for i := 0; i < maxWatches; i++ { + session.registerWatch(string(rune('a'+i)), &watchState{watchID: string(rune('a' + i)), session: session}) + } + _, err := session.watchStart(map[string]json.RawMessage{"key": json.RawMessage(`"k"`)}) + if err == nil || !strings.HasPrefix(err.Error(), "ETCD_WATCH_LIMIT") { + t.Fatalf("expected watch limit error, got %v", err) + } + fresh := newEtcd2Session() + _, err = fresh.watchStart(map[string]json.RawMessage{"key": json.RawMessage(`"k"`), "scope": json.RawMessage(`"glob"`)}) + if err == nil || err.Error() != "ETCD_WATCH_SCOPE_INVALID: scope must be key or prefix" { + t.Fatalf("expected scope error, got %v", err) + } + _, err = fresh.watchPoll(map[string]json.RawMessage{"watchId": json.RawMessage(`"none"`)}) + if err == nil || err.Error() != "ETCD_WATCH_NOT_FOUND: watch does not exist" { + t.Fatalf("expected watch-not-found, got %v", err) + } +} + +func TestPutRejectsLeaseOptions(t *testing.T) { + state := newEtcd2Session() + // Client check comes first (Java parity); without a connection the error + // stays "Not connected". + params := map[string]json.RawMessage{ + "key": json.RawMessage(`"k"`), + "value": json.RawMessage(`{"encoding":"utf8","data":"v"}`), + "ttl": json.RawMessage(`5`), + "lease": json.RawMessage(`7`), + } + if _, err := state.put(params); err == nil || err.Error() != "Not connected" { + t.Fatalf("expected Not connected before option validation, got %v", err) + } +} + +func TestPrefixEnd(t *testing.T) { + if prefixEnd("") != "\x00" || prefixEnd("ab") != "ac" || prefixEnd("a\xff") != "b" { + t.Fatalf("prefixEnd algorithm broken") + } +} + +func TestValueEncodings(t *testing.T) { + utf8Value := valueObject([]byte("hello")) + if utf8Value["encoding"] != "utf8" || utf8Value["data"] != "hello" { + t.Fatalf("unexpected utf8 value: %#v", utf8Value) + } + if valueObject([]byte{0xff})["encoding"] != "base64" { + t.Fatalf("binary must be base64") + } + if displayBytes([]byte{0xff}) == "\xff" { + t.Fatalf("displayBytes must fall back to base64") + } +} + +func (s *etcd2Session) watchBufferedBytesSnapshot() int64 { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + return s.watchBufferedBytes +} diff --git a/agents/drivers/etcd2-go/protocol_error.go b/agents/drivers/etcd2-go/protocol_error.go new file mode 100644 index 0000000000..b0926e1c75 --- /dev/null +++ b/agents/drivers/etcd2-go/protocol_error.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "strings" +) + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data *rpcErrorData `json:"data,omitempty"` +} + +type rpcErrorData struct { + Category string `json:"category"` + Retryable bool `json:"retryable"` + SessionDisposition string `json:"sessionDisposition"` + Stage string `json:"stage"` + ContractVersion int `json:"contractVersion"` + OperationOutcome string `json:"operationOutcome"` + ExceptionClass string `json:"exceptionClass,omitempty"` + AgentSessionID string `json:"agentSessionId,omitempty"` +} + +func errorResponse(id json.RawMessage, method, agentSessionID string, err error) response { + return response{JSONRPC: "2.0", ID: id, Error: classifyRPCError(method, agentSessionID, err)} +} + +func classifyRPCError(method, agentSessionID string, err error) *rpcError { + stage := rpcErrorStage(method) + data := &rpcErrorData{ + Category: "protocol", + Retryable: false, + SessionDisposition: "keep", + Stage: stage, + ContractVersion: 1, + OperationOutcome: rpcOperationOutcome(stage), + ExceptionClass: safeRPCDiagnostic(errTypeName(err), 160), + AgentSessionID: strings.TrimSpace(agentSessionID), + } + + switch { + case errors.Is(err, context.Canceled): + data.Category = "canceled" + data.SessionDisposition = "quarantine" + case errors.Is(err, context.DeadlineExceeded) || isTimeoutError(err): + data.Category = "timeout" + data.SessionDisposition = "quarantine" + case isConnectionError(err): + data.Category = "connection" + data.Retryable = stage == "connect" || stage == "validate" + if stage != "connect" { + data.SessionDisposition = "quarantine" + } + } + + return &rpcError{Code: -1, Message: err.Error(), Data: data} +} + +func isConnectionError(err error) bool { + if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) { + return true + } + var networkError *net.OpError + if errors.As(err, &networkError) { + return true + } + lower := strings.ToLower(err.Error()) + for _, marker := range []string{ + "connection refused", "connection reset", "broken pipe", "connection closed", + "connection lost", "unexpected eof", "no route to host", "etcd connection error", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func errTypeName(err error) string { + if err == nil { + return "" + } + return strings.ReplaceAll(strings.TrimSpace(err.Error()), "\n", " ") +} + +func rpcErrorStage(method string) string { + switch method { + case "connect", "open_session", "test_connection": + return "connect" + case "validate_connection", "validate_session": + return "validate" + case "cancel_session": + return "cancel" + case "close_session", "disconnect", "shutdown": + return "close" + case "etcd_watch_poll": + return "fetch" + case "handshake", "": + return "request" + default: + return "execute" + } +} + +func rpcOperationOutcome(stage string) string { + switch stage { + case "request", "connect", "validate": + return "not_started" + default: + return "unknown" + } +} + +func isTimeoutError(err error) bool { + var timeout interface{ Timeout() bool } + return errors.As(err, &timeout) && timeout.Timeout() +} + +func safeRPCDiagnostic(value string, maxLength int) string { + var result strings.Builder + for _, char := range value { + if result.Len() >= maxLength { + break + } + if char >= 0x21 && char <= 0x7e { + result.WriteRune(char) + } + } + return result.String() +} diff --git a/agents/drivers/etcd2-go/status.go b/agents/drivers/etcd2-go/status.go new file mode 100644 index 0000000000..0c93f941f7 --- /dev/null +++ b/agents/drivers/etcd2-go/status.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +// status assembles a reduced kv_status document from the v2 endpoints: +// /v2/members, /v2/stats/self, /v2/stats/leader, and /v2/stats/store. Fields +// without a v2 analog (alarms, dbSize, raft detail) stay null. +func (s *etcd2Session) status(params map[string]json.RawMessage) (any, error) { + client, err := s.activeClient() + if err != nil { + return nil, err + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + + type member struct { + ID string `json:"id"` + Name string `json:"name"` + PeerURLs []string `json:"peerURLs"` + ClientURLs []string `json:"clientURLs"` + } + membersByID := map[string]*member{} + var memberList []member + var clusterID *string + if body, response, err := client.do(ctx, http.MethodGet, "/v2/members", "", nil); err == nil { + var parsed struct { + Members []member `json:"members"` + } + if json.Unmarshal(body, &parsed) == nil { + memberList = parsed.Members + for index := range memberList { + membersByID[memberList[index].ID] = &memberList[index] + } + } + if header := response.Header.Get("X-Etcd-Cluster-Id"); header != "" { + clusterID = &header + } + } + // The v2 API exposes the current index only as a response header on the + // keys endpoints, not in any stats document. + var revision *string + if _, response, err := client.do(ctx, http.MethodGet, "/v2/keys/", "", nil); err == nil { + if header := response.Header.Get("X-Etcd-Index"); header != "" { + revision = &header + } + } + + type selfStats struct { + Name string `json:"name"` + ID string `json:"id"` + State string `json:"state"` + LeaderInfo struct { + Leader string `json:"leader"` + } `json:"leaderInfo"` + RecvAppendRequestCnt int64 `json:"recvAppendRequestCnt"` + SendAppendRequestCnt int64 `json:"sendAppendRequestCnt"` + } + var leaderID *string + selfByEndpoint := map[string]*selfStats{} + endpoints := s.connectedEndpointList() + for _, endpoint := range endpoints { + self := &selfStats{} + body, _, err := client.doAt(ctx, http.MethodGet, endpoint, "/v2/stats/self", "") + if err != nil { + continue + } + if json.Unmarshal(body, self) != nil { + continue + } + selfByEndpoint[endpoint] = self + if self.LeaderInfo.Leader != "" { + value := self.LeaderInfo.Leader + leaderID = &value + } + } + + storeStats := map[string]any{} + if body, _, err := client.do(ctx, http.MethodGet, "/v2/stats/store", "", nil); err == nil { + var parsed map[string]any + if json.Unmarshal(body, &parsed) == nil { + storeStats = parsed + } + } + + statusMembers := []map[string]any{} + observedIDs := map[string]struct{}{} + for _, endpoint := range endpoints { + row := map[string]any{"endpoint": endpoint} + started := time.Now() + self := selfByEndpoint[endpoint] + if self == nil { + row["memberId"] = nil + row["name"] = nil + row["version"] = nil + row["leaderId"] = nil + row["revision"] = nil + row["raftTerm"] = nil + row["raftIndex"] = nil + row["raftAppliedIndex"] = nil + row["dbSize"] = nil + row["dbSizeInUse"] = nil + row["learner"] = false + row["reachable"] = false + row["latencyMs"] = nil + row["errors"] = []string{"stats request failed"} + statusMembers = append(statusMembers, row) + continue + } + member := membersByID[self.ID] + row["memberId"] = memberHexToUnsigned(self.ID) + if member == nil { + row["name"] = self.Name + } else { + row["name"] = member.Name + } + row["version"] = client.serverVersion + row["leaderId"] = memberHexToUnsigned(self.LeaderInfo.Leader) + row["revision"] = nil + row["raftTerm"] = nil + row["raftIndex"] = nil + row["raftAppliedIndex"] = nil + row["dbSize"] = nil + row["dbSizeInUse"] = nil + row["learner"] = false + row["reachable"] = true + row["latencyMs"] = time.Since(started).Milliseconds() + row["errors"] = []string{} + row["state"] = strings.TrimPrefix(self.State, "State") + if _, seen := observedIDs[self.ID]; seen { + continue + } + observedIDs[self.ID] = struct{}{} + statusMembers = append(statusMembers, row) + } + + var clusterIDValue any + if clusterID != nil { + clusterIDValue = memberHexToUnsigned(*clusterID) + } + var leaderIDValue any + if leaderID != nil { + leaderIDValue = memberHexToUnsigned(*leaderID) + } + var revisionValue any + if revision != nil { + revisionValue = *revision + } + // The v2 API has no key-count equivalent of the v3 count-only range; + // the raw store counters are exposed separately below. + var keyCountValue any + return map[string]any{ + "clusterId": clusterIDValue, + "revision": revisionValue, + "leaderId": leaderIDValue, + "keyCount": keyCountValue, + "alarms": []string{}, + "members": statusMembers, + "store": storeStats, + }, nil +} + +// memberHexToUnsigned converts v2 hex member/cluster IDs (e.g. "272e204152") +// to the unsigned decimal strings the v3 agent emits. +func memberHexToUnsigned(hex string) any { + if hex == "" { + return nil + } + parsed, err := strconv.ParseUint(strings.TrimPrefix(hex, "0x"), 16, 64) + if err != nil { + return nil + } + return unsignedLongString(int64(parsed)) +} diff --git a/agents/drivers/etcd2-go/watch.go b/agents/drivers/etcd2-go/watch.go new file mode 100644 index 0000000000..4b827e37f3 --- /dev/null +++ b/agents/drivers/etcd2-go/watch.go @@ -0,0 +1,395 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "sync" + "time" +) + +const ( + maxWatches = 4 + maxWatchBatches = 256 + maxWatchEvents = 10000 + maxWatchBufferBytes = int64(8 * 1024 * 1024) + maxSessionWatchBufferBytes = int64(16 * 1024 * 1024) + // v2 long polls hold until an event arrives; re-issue the request when a + // poll is cut off so the pump survives idle periods. + watchPollTimeout = 5 * time.Minute +) + +type bufferedWatchBatch struct { + payload map[string]any + bufferedBytes int64 +} + +// watchState buffers watch events for the polling host, mirroring the v3 +// agent's budget system so host-side expectations stay identical. +type watchState struct { + watchID string + session *etcd2Session + + mu sync.Mutex + batches []bufferedWatchBatch + eventCount int + bufferedBytes int64 + terminalReason string + terminalMessage string + pumpCancel context.CancelFunc + closed bool +} + +func (w *watchState) append(revision int64, events []map[string]any, batchBytes int64) { + w.mu.Lock() + defer w.mu.Unlock() + if w.terminalReason != "" { + return + } + if int64(len(w.batches)) >= maxWatchBatches || + int64(w.eventCount+len(events)) > maxWatchEvents || + batchBytes > maxWatchBufferBytes || + w.bufferedBytes+batchBytes > maxWatchBufferBytes || + !w.session.reserveWatchBuffer(batchBytes) { + w.overflowLocked() + return + } + w.batches = append(w.batches, bufferedWatchBatch{ + payload: map[string]any{"revision": longString(revision), "events": events}, + bufferedBytes: batchBytes, + }) + w.eventCount += len(events) + w.bufferedBytes += batchBytes +} + +func (w *watchState) overflowLocked() { + if w.terminalReason != "" { + return + } + w.terminalReason = "overflow" + w.terminalMessage = "ETCD_WATCH_OVERFLOW: the event buffer reached its byte or event limit" + w.stopPumpLocked() +} + +func (w *watchState) fail(reason, message string) { + w.mu.Lock() + defer w.mu.Unlock() + if w.terminalReason == "" { + w.terminalReason = reason + w.terminalMessage = message + } +} + +func (w *watchState) poll() map[string]any { + w.mu.Lock() + defer w.mu.Unlock() + page := make([]any, 0, 64) + for len(w.batches) > 0 && len(page) < 64 { + batch := w.batches[0] + w.batches = w.batches[1:] + events := batch.payload["events"].([]map[string]any) + w.eventCount -= len(events) + w.bufferedBytes -= batch.bufferedBytes + w.session.releaseWatchBuffer(batch.bufferedBytes) + page = append(page, batch.payload) + } + result := map[string]any{"watchId": w.watchID, "batches": page} + if w.terminalReason != "" && len(w.batches) == 0 { + result["terminal"] = map[string]any{ + "reason": w.terminalReason, + "message": w.terminalMessage, + } + } + return result +} + +func (w *watchState) close() { + w.mu.Lock() + w.failLocked("stopped", "watch stopped") + w.clearBufferedLocked() + w.closed = true + cancel := w.pumpCancel + w.pumpCancel = nil + w.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (w *watchState) failLocked(reason, message string) { + if w.terminalReason == "" { + w.terminalReason = reason + w.terminalMessage = message + } +} + +func (w *watchState) clearBufferedLocked() { + if w.bufferedBytes > 0 { + w.session.releaseWatchBuffer(w.bufferedBytes) + } + w.batches = nil + w.eventCount = 0 + w.bufferedBytes = 0 +} + +func (w *watchState) attachPump(cancel context.CancelFunc) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed || w.terminalReason != "" { + cancel() + return + } + w.pumpCancel = cancel +} + +func (w *watchState) stopPumpLocked() { + cancel := w.pumpCancel + w.pumpCancel = nil + if cancel != nil { + go cancel() + } +} + +func watchEventBufferBytes(key, value, previousValue string) int64 { + bytes := int64(512 + 4*len(key)) + bytes += estimatedBufferedBytes(len(value)) + if previousValue != "" { + bytes += estimatedBufferedBytes(len(previousValue)) + } + return bytes +} + +func estimatedBufferedBytes(sourceBytes int) int64 { + if sourceBytes < 0 { + return 0 + } + return int64(sourceBytes) * 4 +} + +func newWatchID() string { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return base64.StdEncoding.EncodeToString(bytes[:]) + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + hexed := hex.EncodeToString(bytes[:]) + return fmt.Sprintf("%s-%s-%s-%s-%s", hexed[0:8], hexed[8:12], hexed[12:16], hexed[16:20], hexed[20:32]) +} + +func (s *etcd2Session) watchCount() int { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + return len(s.watches) +} + +func (s *etcd2Session) registerWatch(id string, state *watchState) bool { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + if _, exists := s.watches[id]; exists { + return false + } + s.watches[id] = state + return true +} + +func (s *etcd2Session) removeWatch(id string) *watchState { + s.watchesMu.Lock() + defer s.watchesMu.Unlock() + state := s.watches[id] + delete(s.watches, id) + return state +} + +func (s *etcd2Session) watchStart(params map[string]json.RawMessage) (any, error) { + if s.watchCount() >= maxWatches { + return nil, fmt.Errorf("ETCD_WATCH_LIMIT: at most %d watches are allowed per connection", maxWatches) + } + key, err := keyBytesParam(params) + if err != nil { + return nil, err + } + scope := stringOrDefault(params, "scope", "key") + if scope != "key" && scope != "prefix" { + return nil, errors.New("ETCD_WATCH_SCOPE_INVALID: scope must be key or prefix") + } + client, err := s.activeClient() + if err != nil { + return nil, err + } + + // startRevision maps to the v2 waitIndex: default to the current index+1. + requestedRevision := longOrNull(params, "startRevision") + var waitIndex int64 + if requestedRevision != nil && *requestedRevision > 0 { + waitIndex = *requestedRevision + } else { + current, err := client.currentEtcdIndex() + if err != nil { + return nil, err + } + waitIndex = current + 1 + } + + watchID := newWatchID() + state := &watchState{watchID: watchID, session: s} + pumpCtx, pumpCancel := context.WithCancel(context.Background()) + go pumpLongPollWatch(pumpCtx, client, key, scope, waitIndex, boolOrDefault(params, "includePrevKv", false), state) + state.attachPump(pumpCancel) + s.registerWatch(watchID, state) + return map[string]any{"watchId": watchID, "startedRevision": longString(waitIndex)}, nil +} + +// currentEtcdIndex reads the X-Etcd-Index header from a lightweight GET. +func (c *authenticatedClient) currentEtcdIndex() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), operationTimeout) + defer cancel() + response, err := c.request(ctx, http.MethodGet, "/v2/keys/", "", nil) + if err != nil { + return 0, err + } + defer drainClose(response.Body) + if response.StatusCode < 200 || response.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return 0, errorFromResponse(response.StatusCode, body) + } + index, err := strconv.ParseInt(response.Header.Get("X-Etcd-Index"), 10, 64) + if err != nil { + return 0, fmt.Errorf("etcd v2 server did not report X-Etcd-Index") + } + return index, nil +} + +// pumpLongPollWatch drives the v2 wait loop: each response becomes one batch; +// timeouts and connection blips re-issue the same waitIndex. +func pumpLongPollWatch(ctx context.Context, client *authenticatedClient, key, scope string, waitIndex int64, includePrevKv bool, state *watchState) { + for { + if ctx.Err() != nil || state.hasTerminal() { + return + } + query := url.Values{} + query.Set("wait", "true") + query.Set("waitIndex", strconv.FormatInt(waitIndex, 10)) + if scope == "prefix" { + query.Set("recursive", "true") + } + pollCtx, pollCancel := context.WithTimeout(ctx, watchPollTimeout) + body, _, err := client.do(pollCtx, http.MethodGet, v2KeyPath(key)+"?"+query.Encode(), "", nil) + pollCancel() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + // Idle long poll cut off: keep waiting from the same index. + continue + } + if ctx.Err() != nil { + return + } + var etcdErr *etcdError + if errors.As(err, &etcdErr) && etcdErr.errorCode == 401 { + state.fail("error", "ETCD_WATCH_EXPIRED: the watched index was cleared from the etcd v2 event history") + return + } + state.fail("error", "watch failed: "+err.Error()) + return + } + var parsed v2KeysResponse + if jsonErr := json.Unmarshal(body, &parsed); jsonErr != nil { + state.fail("error", "watch response was not valid etcd v2 JSON: "+jsonErr.Error()) + return + } + if parsed.Node == nil { + waitIndex++ + continue + } + events := []map[string]any{} + bufferedBytes := int64(128) + node := parsed.Node + previous := parsed.PrevNode + deleted := parsed.Action == "delete" || parsed.Action == "expire" + previousValue := "" + if previous != nil { + previousValue = previous.Value + } + bufferedBytes += watchEventBufferBytes(node.Key, node.Value, previousValue) + if bufferedBytes > maxWatchBufferBytes { + state.mu.Lock() + state.overflowLocked() + state.mu.Unlock() + return + } + row := map[string]any{ + "eventType": eventTypeForAction(parsed.Action), + "revision": longString(node.ModifiedIndex), + "key": node.Key, + "keyBytes": bytesObject([]byte(node.Key)), + } + if deleted { + row["value"] = nil + } else { + row["value"] = valueObject([]byte(node.Value)) + } + if includePrevKv && previous != nil && previous.Value != "" { + row["previousValue"] = valueObject([]byte(previous.Value)) + } else { + row["previousValue"] = nil + } + if deleted && previous != nil { + row["metadata"] = nodeMetadata(previous) + } else { + row["metadata"] = nodeMetadata(node) + } + events = append(events, row) + state.append(node.ModifiedIndex, events, bufferedBytes) + nextIndex := node.ModifiedIndex + 1 + if nextIndex > waitIndex { + waitIndex = nextIndex + } else { + waitIndex++ + } + } +} + +func eventTypeForAction(action string) string { + if action == "delete" || action == "expire" || action == "compareAndDelete" { + return "delete" + } + return "put" +} + +func (w *watchState) hasTerminal() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.terminalReason != "" +} + +func (s *etcd2Session) watchPoll(params map[string]json.RawMessage) (any, error) { + watchID := stringOrDefault(params, "watchId", "") + s.watchesMu.Lock() + state := s.watches[watchID] + s.watchesMu.Unlock() + if state == nil { + return nil, errors.New("ETCD_WATCH_NOT_FOUND: watch does not exist") + } + result := state.poll() + if _, terminal := result["terminal"]; terminal { + if removed := s.removeWatch(watchID); removed != nil { + removed.close() + } + } + return result, nil +} + +func (s *etcd2Session) watchStop(params map[string]json.RawMessage) (any, error) { + if state := s.removeWatch(stringOrDefault(params, "watchId", "")); state != nil { + state.close() + } + return map[string]bool{"stopped": true}, nil +} diff --git a/agents/go-common/go-semver/go.mod b/agents/go-common/go-semver/go.mod new file mode 100644 index 0000000000..1fd60f6a6a --- /dev/null +++ b/agents/go-common/go-semver/go.mod @@ -0,0 +1,3 @@ +module github.com/coreos/go-semver/semver + +go 1.24 diff --git a/agents/go-common/go-semver/semver.go b/agents/go-common/go-semver/semver.go new file mode 100644 index 0000000000..eb9fb7ff2d --- /dev/null +++ b/agents/go-common/go-semver/semver.go @@ -0,0 +1,296 @@ +// Copyright 2013-2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Semantic Versions http://semver.org +package semver + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strconv" + "strings" +) + +type Version struct { + Major int64 + Minor int64 + Patch int64 + PreRelease PreRelease + Metadata string +} + +type PreRelease string + +func splitOff(input *string, delim string) (val string) { + parts := strings.SplitN(*input, delim, 2) + + if len(parts) == 2 { + *input = parts[0] + val = parts[1] + } + + return val +} + +func New(version string) *Version { + return Must(NewVersion(version)) +} + +func NewVersion(version string) (*Version, error) { + v := Version{} + + if err := v.Set(version); err != nil { + return nil, err + } + + return &v, nil +} + +// Must is a helper for wrapping NewVersion and will panic if err is not nil. +func Must(v *Version, err error) *Version { + if err != nil { + panic(err) + } + return v +} + +// Set parses and updates v from the given version string. Implements flag.Value +func (v *Version) Set(version string) error { + metadata := splitOff(&version, "+") + preRelease := PreRelease(splitOff(&version, "-")) + dotParts := strings.SplitN(version, ".", 3) + + if len(dotParts) != 3 { + return fmt.Errorf("%s is not in dotted-tri format", version) + } + + if err := validateIdentifier(string(preRelease)); err != nil { + return fmt.Errorf("failed to validate pre-release: %v", err) + } + + if err := validateIdentifier(metadata); err != nil { + return fmt.Errorf("failed to validate metadata: %v", err) + } + + parsed := make([]int64, 3) + + for i, v := range dotParts[:3] { + val, err := strconv.ParseInt(v, 10, 64) + parsed[i] = val + if err != nil { + return err + } + } + + v.Metadata = metadata + v.PreRelease = preRelease + v.Major = parsed[0] + v.Minor = parsed[1] + v.Patch = parsed[2] + return nil +} + +func (v Version) String() string { + var buffer bytes.Buffer + + fmt.Fprintf(&buffer, "%d.%d.%d", v.Major, v.Minor, v.Patch) + + if v.PreRelease != "" { + fmt.Fprintf(&buffer, "-%s", v.PreRelease) + } + + if v.Metadata != "" { + fmt.Fprintf(&buffer, "+%s", v.Metadata) + } + + return buffer.String() +} + +func (v *Version) UnmarshalYAML(unmarshal func(interface{}) error) error { + var data string + if err := unmarshal(&data); err != nil { + return err + } + return v.Set(data) +} + +func (v Version) MarshalJSON() ([]byte, error) { + return []byte(`"` + v.String() + `"`), nil +} + +func (v *Version) UnmarshalJSON(data []byte) error { + l := len(data) + if l == 0 || string(data) == `""` { + return nil + } + if l < 2 || data[0] != '"' || data[l-1] != '"' { + return errors.New("invalid semver string") + } + return v.Set(string(data[1 : l-1])) +} + +// Compare tests if v is less than, equal to, or greater than versionB, +// returning -1, 0, or +1 respectively. +func (v Version) Compare(versionB Version) int { + if cmp := recursiveCompare(v.Slice(), versionB.Slice()); cmp != 0 { + return cmp + } + return preReleaseCompare(v, versionB) +} + +// Equal tests if v is equal to versionB. +func (v Version) Equal(versionB Version) bool { + return v.Compare(versionB) == 0 +} + +// LessThan tests if v is less than versionB. +func (v Version) LessThan(versionB Version) bool { + return v.Compare(versionB) < 0 +} + +// Slice converts the comparable parts of the semver into a slice of integers. +func (v Version) Slice() []int64 { + return []int64{v.Major, v.Minor, v.Patch} +} + +func (p PreRelease) Slice() []string { + preRelease := string(p) + return strings.Split(preRelease, ".") +} + +func preReleaseCompare(versionA Version, versionB Version) int { + a := versionA.PreRelease + b := versionB.PreRelease + + /* Handle the case where if two versions are otherwise equal it is the + * one without a PreRelease that is greater */ + if len(a) == 0 && (len(b) > 0) { + return 1 + } else if len(b) == 0 && (len(a) > 0) { + return -1 + } + + // If there is a prerelease, check and compare each part. + return recursivePreReleaseCompare(a.Slice(), b.Slice()) +} + +func recursiveCompare(versionA []int64, versionB []int64) int { + if len(versionA) == 0 { + return 0 + } + + a := versionA[0] + b := versionB[0] + + if a > b { + return 1 + } else if a < b { + return -1 + } + + return recursiveCompare(versionA[1:], versionB[1:]) +} + +func recursivePreReleaseCompare(versionA []string, versionB []string) int { + // A larger set of pre-release fields has a higher precedence than a smaller set, + // if all of the preceding identifiers are equal. + if len(versionA) == 0 { + if len(versionB) > 0 { + return -1 + } + return 0 + } else if len(versionB) == 0 { + // We're longer than versionB so return 1. + return 1 + } + + a := versionA[0] + b := versionB[0] + + aInt := false + bInt := false + + aI, err := strconv.Atoi(versionA[0]) + if err == nil { + aInt = true + } + + bI, err := strconv.Atoi(versionB[0]) + if err == nil { + bInt = true + } + + // Numeric identifiers always have lower precedence than non-numeric identifiers. + if aInt && !bInt { + return -1 + } else if !aInt && bInt { + return 1 + } + + // Handle Integer Comparison + if aInt && bInt { + if aI > bI { + return 1 + } else if aI < bI { + return -1 + } + } + + // Handle String Comparison + if a > b { + return 1 + } else if a < b { + return -1 + } + + return recursivePreReleaseCompare(versionA[1:], versionB[1:]) +} + +// BumpMajor increments the Major field by 1 and resets all other fields to their default values +func (v *Version) BumpMajor() { + v.Major += 1 + v.Minor = 0 + v.Patch = 0 + v.PreRelease = PreRelease("") + v.Metadata = "" +} + +// BumpMinor increments the Minor field by 1 and resets all other fields to their default values +func (v *Version) BumpMinor() { + v.Minor += 1 + v.Patch = 0 + v.PreRelease = PreRelease("") + v.Metadata = "" +} + +// BumpPatch increments the Patch field by 1 and resets all other fields to their default values +func (v *Version) BumpPatch() { + v.Patch += 1 + v.PreRelease = PreRelease("") + v.Metadata = "" +} + +// validateIdentifier makes sure the provided identifier satisfies semver spec +func validateIdentifier(id string) error { + if id != "" && !reIdentifier.MatchString(id) { + return fmt.Errorf("%s is not a valid semver identifier", id) + } + return nil +} + +// reIdentifier is a regular expression used to check that pre-release and metadata +// identifiers satisfy the spec requirements +var reIdentifier = regexp.MustCompile(`^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$`) diff --git a/agents/go-common/go-semver/sort.go b/agents/go-common/go-semver/sort.go new file mode 100644 index 0000000000..e256b41a5d --- /dev/null +++ b/agents/go-common/go-semver/sort.go @@ -0,0 +1,38 @@ +// Copyright 2013-2015 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package semver + +import ( + "sort" +) + +type Versions []*Version + +func (s Versions) Len() int { + return len(s) +} + +func (s Versions) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s Versions) Less(i, j int) bool { + return s[i].LessThan(*s[j]) +} + +// Sort sorts the given slice of Version +func Sort(versions []*Version) { + sort.Sort(Versions(versions)) +} diff --git a/agents/scripts/driver_release_packages_test.py b/agents/scripts/driver_release_packages_test.py index 3bf76e3c1c..3323e280a9 100644 --- a/agents/scripts/driver_release_packages_test.py +++ b/agents/scripts/driver_release_packages_test.py @@ -30,6 +30,10 @@ class DriverReleasePackagesTest(unittest.TestCase): cassandra_source.write_bytes(b"\x7fELFtest-cassandra-agent") tdengine_source = release_dir / "dbx-agent-tdengine-windows-aarch64.exe" tdengine_source.write_bytes(b"MZtest-tdengine-agent") + etcd_source = release_dir / "dbx-agent-etcd-linux-x64" + etcd_source.write_bytes(b"\x7fELFtest-etcd-agent") + etcd2_source = release_dir / "dbx-agent-etcd2-macos-aarch64" + etcd2_source.write_bytes(b"test-etcd2-agent") java_source = release_dir / "dbx-agent-h2.jar" java_source.write_bytes(b"test-jar") versions = { @@ -47,6 +51,8 @@ class DriverReleasePackagesTest(unittest.TestCase): "cassandra": "0.1.37", "hive": "0.1.43", "tdengine": "0.1.0", + "etcd": "0.1.40", + "etcd2": "0.1.0", "sqlite-worker": "0.1.0", } @@ -59,6 +65,8 @@ class DriverReleasePackagesTest(unittest.TestCase): versioned_rocketmq = release_dir / "dbx-agent-rocketmq-0.1.0-windows-x64.exe" versioned_cassandra = release_dir / "dbx-agent-cassandra-0.1.37-linux-x64" versioned_tdengine = release_dir / "dbx-agent-tdengine-0.1.0-windows-aarch64.exe" + versioned_etcd = release_dir / "dbx-agent-etcd-0.1.40-linux-x64" + versioned_etcd2 = release_dir / "dbx-agent-etcd2-0.1.0-macos-aarch64" self.assertEqual( renamed, [ @@ -70,6 +78,8 @@ class DriverReleasePackagesTest(unittest.TestCase): versioned_rabbitmq, versioned_rocketmq, versioned_tdengine, + versioned_etcd, + versioned_etcd2, ], ) @@ -246,6 +256,8 @@ class DriverReleasePackagesTest(unittest.TestCase): [ versioned_cassandra, versioned_duckdb, + versioned_etcd, + versioned_etcd2, versioned_java, versioned_native, versioned_rabbitmq, diff --git a/agents/scripts/validate_agents.py b/agents/scripts/validate_agents.py index 81ebe6685e..cff62f80fe 100644 --- a/agents/scripts/validate_agents.py +++ b/agents/scripts/validate_agents.py @@ -11,7 +11,7 @@ SOURCE_GLOBS = ("*/src/main/**/*.java", "drivers/*/src/main/**/*.java") KOTLIN_FILE_SUFFIXES = (".kt", ".kts") KOTLIN_SCAN_EXCLUDED_PARTS = {".git", ".gradle", "build"} DEFAULT_AGENT_JRE_KEY = "21" -NON_JDBC_AGENT_MODULES = {"mongodb", "etcd", "zookeeper", "kafka", "rocketmq", "rabbitmq"} +NON_JDBC_AGENT_MODULES = {"mongodb", "zookeeper", "kafka", "rocketmq", "rabbitmq"} NATIVE_ONLY_AGENT_MODULES = { "cassandra": "drivers/cassandra-go", "duckdb": "drivers/duckdb", @@ -26,6 +26,8 @@ NATIVE_ONLY_AGENT_MODULES = { "rabbitmq": "drivers/rabbitmq", "rocketmq": "drivers/rocketmq", "zookeeper": "drivers/zookeeper", + "etcd": "drivers/etcd-go", + "etcd2": "drivers/etcd2-go", } CRATE_NATIVE_AGENT_MODULES = { "sqlite-worker": Path("..") / "crates" / "dbx-sqlite-worker", diff --git a/agents/scripts/version_agent_artifacts.py b/agents/scripts/version_agent_artifacts.py index 2732142298..1a53a13721 100644 --- a/agents/scripts/version_agent_artifacts.py +++ b/agents/scripts/version_agent_artifacts.py @@ -4,7 +4,7 @@ import json from pathlib import Path -NATIVE_DRIVERS = ("cassandra", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "duckdb", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "sqlite-worker") +NATIVE_DRIVERS = ("cassandra", "hive", "oracle", "xugu", "kingbase", "iotdb", "neo4j", "vastbase", "duckdb", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker") PLATFORMS = ( "macos-aarch64", "macos-x64", diff --git a/agents/settings.gradle b/agents/settings.gradle index 4923eaee1c..724b68acc5 100644 --- a/agents/settings.gradle +++ b/agents/settings.gradle @@ -6,7 +6,7 @@ def driverModules = [ 'teradata', 'vertica', 'firebird', 'exasol', 'oceanbase-oracle', 'gbase8a', 'gbase8s', 'bigquery', 'kylin', 'ignite', 'ignite3', 'spanner', 'sundb', 'h2', 'h2-legacy', 'snowflake', 'trino', 'spark', 'db2', 'informix', 'mongodb', 'highgo', 'uxdb', 'yashandb', 'oscar', - 'iris', 'etcd', 'kafka', 'sqlserver-legacy' + 'iris', 'kafka', 'sqlserver-legacy' ] include(*(infrastructureModules + driverModules)) diff --git a/agents/versions.json b/agents/versions.json index 51e3f2193f..8e50dfe418 100644 --- a/agents/versions.json +++ b/agents/versions.json @@ -43,6 +43,7 @@ "iris": "0.1.43", "iotdb": "0.1.38", "etcd": "0.1.39", + "etcd2": "0.1.0", "zookeeper": "0.1.27", "kafka": "0.1.14", "rocketmq": "0.1.10", diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index d31679b450..30d0636fbc 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -2786,6 +2786,11 @@ function switchH2ConnectionMode(mode: H2ConnectionMode) { resetTestState(); } +function switchEtcdApiVersion(profile: "etcd" | "etcd-v2") { + form.value.driver_profile = profile; + resetTestState(); +} + function switchH2DriverProfile(profile: "h2" | "h2-v1" | "h2-v2" | "h2-v3" | "h2-custom") { form.value.driver_profile = profile; if (profile === "h2-custom") { @@ -6872,6 +6877,18 @@ function openExternalUrl(url: string) {