Files
opsxdb/apps/server/internal/db/agent_e2e_test.go
T
logan a9510235bb
CI / Go server (push) Canceled after 0s
CI / Web + core packages (push) Canceled after 0s
CI / Desktop (Tauri) cargo check (push) Canceled after 0s
feat: 18 引擎驱动全家桶 + 驱动管理/离线包 + 设置中心 + 工作台交互打磨
驱动接入(全部本机真实例验收):
- Elasticsearch 原生 SQL API、InfluxDB 原生 HTTP+Flux(官方 JDBC 付费许可,实测弃用)
- ZooKeeper/MongoDB JVM agent(连接时自动下载官方驱动 jar,sha256 钉版本到 ~/.opsxdb/drivers/)
- 达梦 JDBC agent 桥就绪(待厂商 jar+实例);TiDB 真实例验收
- 协议族代码审查放行 PolarDB/TDSQL/OceanBase/Kingbase/GaussDB/Oracle
- 驱动能力模型:按驱动开关对象树分组/右键菜单/行编辑,NoSQL 树拍平(对象直挂库节点)

驱动管理:
- 驱动管理弹框(状态/预装/离线包导入/JDK 检测)+ /api/drivers/* 三接口
- opsxdb-driver 驱动商店仓库(清单/fetch.sh/pack.sh 离线包/import.sh)
- 连接时懒加载:DMG 零 Java 产物,首连自动下载(实测 ZK 九件套 1.35s),离线包自动回退

UI/交互:
- 设置中心(⚙):常规/执行/结果/驱动/快捷键/关于,左导航+右面板(对标 opsxshell)
- 实例级展开箭头(行首 SVG chevron,替代字体回退失真的 Unicode 三角)
- 树内嵌套渲染修复:对象树挂在激活连接下;工具栏窄窗口不断行(按钮 nowrap+整颗换行)
- 侧栏连接行精简(图标+连接名)、SQLite SSH 守卫、Oracle 死参数清理

质量:
- Go 协议层/离线回退单测、E2E 门控测试(ES/ZK/Influx/Mongo/TiDB/JDBC 桥)
- 驱动注册表单一事实来源 connection-types.json,README/BACKLOG/AGENTS 同步
2026-09-22 01:07:46 +08:00

380 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
// agent_e2e_test.go —— JDBC agent 桥端到端验证(真实 JVM + 真实 JDBC 驱动)。
// 前置:本机 JDK、agent jar(OPSXDB_AGENT_JAR 或默认 ~/.opsxdb/agents/jdbc/)。
// 触发:OPSXDB_E2E_JDBC=1 才运行(CI 无 JVM 时自动跳过)。
// 覆盖:StartAgent 全链路(gson 校验/JVM 启动/握手)→ open → DDL/DML → query → 元数据。
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestE2EAgentSQLiteBridge(t *testing.T) {
if os.Getenv("OPSXDB_E2E_JDBC") != "1" {
t.Skip("置 OPSXDB_E2E_JDBC=1 并备好 JDK 与 agent jar 后运行")
}
home, _ := os.UserHomeDir()
jars := filepath.Join(home, ".opsxdb", "agents", "jdbc", "drivers", "sqlite-e2e")
if _, err := os.Stat(filepath.Join(jars, "sqlite-jdbc-3.46.1.3.jar")); err != nil {
t.Skipf("缺 sqlite-jdbc jar: %s", jars)
}
t.Setenv("OPSXDB_E2E_JARS", jars)
spec := AgentSpec{
DriverKey: "e2e-sqlite",
DriverClass: "org.sqlite.JDBC",
URLTemplate: "jdbc:sqlite:{{.Database}}",
JarsDirEnv: "OPSXDB_E2E_JARS",
}
agentDriverSpecs[spec.DriverKey] = spec // 注册进表,AgentConfigured 才能按 JarsDirEnv 解析
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
cli, err := StartAgent(spec)
if err != nil {
t.Fatalf("StartAgent: %v", err)
}
t.Cleanup(func() { cli.Shutdown(context.Background()) })
dbPath := filepath.Join(t.TempDir(), "e2e.db")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
handle := ""
{
var res struct {
Handle string `json:"handle"`
}
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:sqlite:" + dbPath, "user": "", "password": ""}, &res); err != nil {
t.Fatalf("open: %v (stderr=%s)", err, cli.stderrTail.String())
}
handle = res.Handle
}
mustExec := func(sql string) {
t.Helper()
var r struct {
AffectedRows int64 `json:"affectedRows"`
}
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": sql, "timeoutSec": 30}, &r); err != nil {
t.Fatalf("exec %q: %v (stderr=%s)", sql, err, cli.stderrTail.String())
}
}
mustExec("CREATE TABLE demo(id INTEGER PRIMARY KEY, name TEXT)")
mustExec("INSERT INTO demo(name) VALUES ('opsx-e2e')")
{
var res struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
}
if err := cli.call(ctx, "query", map[string]any{"handle": handle, "sql": "SELECT id, name FROM demo", "maxRows": 100, "timeoutSec": 30}, &res); err != nil {
t.Fatalf("query: %v", err)
}
if len(res.Rows) != 1 || res.Rows[0]["name"] != "opsx-e2e" {
t.Fatalf("rows = %+v", res.Rows)
}
}
{
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaTables", map[string]any{"handle": handle, "schema": ""}, &res); err != nil {
t.Fatalf("metaTables: %v", err)
}
found := false
for _, n := range res.Names {
if n == "demo" {
found = true
}
}
if !found {
t.Fatalf("metaTables 未包含 demo: %v", res.Names)
}
}
{
var res struct {
Columns []ColumnInfo `json:"columns"`
}
if err := cli.call(ctx, "metaColumns", map[string]any{"handle": handle, "schema": "", "table": "demo"}, &res); err != nil {
t.Fatalf("metaColumns: %v", err)
}
if len(res.Columns) != 2 {
t.Fatalf("columns = %+v", res.Columns)
}
}
}
// TestE2EElasticsearch —— 真实 ES 8(docker,安全关闭)+ 官方 x-pack-sql-jdbc 走 agent 桥全链路。
// 触发:OPSXDB_E2E_ES=1;前置 ES :9200 可连、~/.opsxdb/agents/jdbc/drivers/elasticsearch/ 有官方 JDBC jar。
func TestE2EElasticsearch(t *testing.T) {
if os.Getenv("OPSXDB_E2E_ES") != "1" {
t.Skip("置 OPSXDB_E2E_ES=1 并启动 ES :9200 后运行")
}
// 种子文档(让 SHOW TABLES / SELECT 有真实对象)
req, _ := http.NewRequest(http.MethodPut, "http://127.0.0.1:9200/e2e_smoke/_doc/1?refresh=true", strings.NewReader(`{"name":"opsx","n":42}`))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Skipf("ES :9200 不可达: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 400 {
t.Fatalf("种子文档写入失败: HTTP %d", resp.StatusCode)
}
c := ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200}
if err := TestConnection(c); err != nil {
t.Fatalf("TestConnection: %v", err)
}
dbs, err := ListDatabases(context.Background(), c)
if err != nil || len(dbs) != 1 || dbs[0] != "default" {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
cc := c
cc.Database = "default"
tables, err := ListTables(context.Background(), cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
found := false
for _, n := range tables {
if n == "e2e_smoke" {
found = true
}
}
if !found {
t.Fatalf("tables 未含 e2e_smoke: %v", tables)
}
r := ExecSQL(context.Background(), ConnReq{Driver: "elasticsearch", Host: "127.0.0.1", Port: 9200, Database: "default", SQL: "SELECT name, n FROM e2e_smoke", Limit: 100})
if r.Error != "" {
t.Fatalf("ExecSQL: %s (stderr 见测试日志)", r.Error)
}
if r.RowCount < 1 {
t.Fatalf("rows = %+v", r.Rows)
}
}
// TestE2EZooKeeper —— 真实 ZK(docker)+ OpsxZkAgent 全链路:连接/浏览/读写/删除。
// 触发:OPSXDB_E2E_ZK=1;前置 ZK :2181、~/.opsxdb/agents/jdbc/drivers/zookeeper/ 有官方 jar。
func TestE2EZooKeeper(t *testing.T) {
if os.Getenv("OPSXDB_E2E_ZK") != "1" {
t.Skip("置 OPSXDB_E2E_ZK=1 并启动 ZK :2181 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181}
if err := TestConnection(c); err != nil {
t.Skipf("ZK :2181 不可达: %v", err)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
hasZk := false
for _, n := range dbs {
if n == "zookeeper" {
hasZk = true
}
}
if !hasZk {
t.Fatalf("根下应含 zookeeper: %v", dbs)
}
cc := c
cc.Database = "zookeeper"
tables, err := ListTables(ctx, cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
hasConfig := false
for _, n := range tables {
if n == "config" {
hasConfig = true
}
}
if !hasConfig {
t.Fatalf("/zookeeper 下应含 config: %v", tables)
}
// 读:GET
r := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, Database: "zookeeper", SQL: "GET /zookeeper/config", Limit: 100})
if r.Error != "" {
t.Fatalf("GET: %s", r.Error)
}
if r.RowCount != 1 || r.Rows[0]["path"] != "/zookeeper/config" {
t.Fatalf("GET rows = %+v", r.Rows)
}
// 写:CREATE → GET → SET → DELETE(DELETE 无 WHERE 属危险语句,走确认通道)
w := func(sql string) {
t.Helper()
res := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: sql, ConfirmDanger: true})
if res.Error != "" {
t.Fatalf("%s → %s", sql, res.Error)
}
}
w("CREATE /opsxe2e hello-opsx")
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
if r.Rows[0]["data"] != "hello-opsx" {
t.Fatalf("CREATE 后 data = %v", r.Rows[0]["data"])
}
w("SET /opsxe2e updated")
r = ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "GET /opsxe2e", Limit: 100})
if r.Rows[0]["data"] != "updated" {
t.Fatalf("SET 后 data = %v", r.Rows[0]["data"])
}
// 危险语句未确认时必须先返回 NeedConfirm
guard := ExecSQL(ctx, ConnReq{Driver: "zookeeper", Host: "127.0.0.1", Port: 2181, SQL: "DELETE /opsxe2e"})
if !guard.NeedConfirm {
t.Fatalf("DELETE 未确认应返回 NeedConfirm,got %+v", guard)
}
w("DELETE /opsxe2e")
}
// TestE2EInfluxDB —— 真实 InfluxDB 2.7(docker)原生 HTTP 全链路:bucket/measurement/Flux 查询。
// 触发:OPSXDB_E2E_INFLUX=1;前置 InfluxDB :8086(org opsx / token opsxdb-demo-token / bucket demo)。
func TestE2EInfluxDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_INFLUX") != "1" {
t.Skip("置 OPSXDB_E2E_INFLUX=1 并启动 InfluxDB :8086 后运行")
}
// 种子点(行协议写入)
wreq, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:8086/api/v2/write?org=opsx&bucket=demo&precision=s",
strings.NewReader("e2e_m,site=local v=42"))
wreq.Header.Set("Authorization", "Token opsxdb-demo-token")
wreq.Header.Set("Content-Type", "text/plain; charset=utf-8")
wresp, err := http.DefaultClient.Do(wreq)
if err != nil {
t.Skipf("InfluxDB :8086 不可达: %v", err)
}
_, _ = io.Copy(io.Discard, wresp.Body)
wresp.Body.Close()
if wresp.StatusCode >= 400 {
t.Fatalf("种子写入失败: HTTP %d", wresp.StatusCode)
}
time.Sleep(500 * time.Millisecond) // 存储引擎落盘
c := ConnReq{Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token"}
if err := TestConnection(c); err != nil {
t.Fatalf("TestConnection: %v", err)
}
dbs, err := ListDatabases(context.Background(), c)
if err != nil {
t.Fatalf("ListDatabases: %v", err)
}
hasDemo := false
for _, n := range dbs {
if n == "demo" {
hasDemo = true
}
}
if !hasDemo {
t.Fatalf("buckets 未含 demo: %v", dbs)
}
cc := c
cc.Database = "demo"
tables, err := ListTables(context.Background(), cc)
if err != nil {
t.Fatalf("ListTables: %v", err)
}
hasM := false
for _, n := range tables {
if n == "e2e_m" {
hasM = true
}
}
if !hasM {
t.Fatalf("measurements 未含 e2e_m: %v", tables)
}
r := ExecSQL(context.Background(), ConnReq{
Driver: "influxdb", Host: "127.0.0.1", Port: 8086, User: "opsx", Password: "opsxdb-demo-token",
SQL: `from(bucket: "demo") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "e2e_m")`,
Limit: 100,
})
if r.Error != "" {
t.Fatalf("Flux 查询: %s", r.Error)
}
if r.RowCount < 1 {
t.Fatalf("rows = %+v", r.Rows)
}
}
// TestE2EMongoDB —— 真实 MongoDB 7 + OpsxMongoAgent 全链路:库/集合浏览、DSL 查改增删、危险确认。
// 触发:OPSXDB_E2E_MONGO=1;前置 MongoDB :27017、~/.opsxdb/agents/jdbc/drivers/mongodb/ 有官方 jar。
func TestE2EMongoDB(t *testing.T) {
if os.Getenv("OPSXDB_E2E_MONGO") != "1" {
t.Skip("置 OPSXDB_E2E_MONGO=1 并启动 MongoDB :27017 后运行")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
c := ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017}
if err := TestConnection(c); err != nil {
t.Skipf("MongoDB :27017 不可达: %v", err)
}
// 种子:插入文档(确认通道)
seed := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.insertOne({"name":"opsx","n":7})`, ConfirmDanger: false})
if seed.Error != "" {
t.Fatalf("insertOne: %s", seed.Error)
}
dbs, err := ListDatabases(ctx, c)
if err != nil || len(dbs) == 0 {
t.Fatalf("ListDatabases = %v, %v", dbs, err)
}
hasDb := false
for _, n := range dbs {
if n == "opsxe2e" {
hasDb = true
}
}
if !hasDb {
t.Fatalf("库列表未含 opsxe2e: %v", dbs)
}
cc := c
cc.Database = "opsxe2e"
tables, err := ListTables(ctx, cc)
if err != nil || len(tables) == 0 {
t.Fatalf("ListTables = %v, %v", tables, err)
}
found := false
for _, n := range tables {
if n == "c1" {
found = true
}
}
if !found {
t.Fatalf("集合列表未含 c1: %v", tables)
}
cols, err := ListColumns(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", Table: "c1"})
if err != nil || len(cols) == 0 {
t.Fatalf("ListColumns = %v, %v", cols, err)
}
// 查询
q := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.find({"name":"opsx"})`, Limit: 100})
if q.Error != "" || q.RowCount < 1 {
t.Fatalf("find: err=%s rows=%+v", q.Error, q.Rows)
}
// 危险确认:deleteMany 未确认必须先返回 NeedConfirm
guard := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`})
if !guard.NeedConfirm {
t.Fatalf("deleteMany 未确认应 NeedConfirm,got %+v", guard)
}
// 更新 + 删除(确认通道)
u := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.updateOne({"name":"opsx"}, {"$set":{"n":8}})`, ConfirmDanger: true})
if u.Error != "" {
t.Fatalf("updateOne: %s", u.Error)
}
d := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.deleteMany({"name":"opsx"})`, ConfirmDanger: true})
if d.Error != "" {
t.Fatalf("deleteMany: %s", d.Error)
}
cnt := ExecSQL(ctx, ConnReq{Driver: "mongodb", Host: "127.0.0.1", Port: 27017, Database: "opsxe2e", SQL: `db.c1.count({"name":"opsx"})`})
if fmt.Sprintf("%v", cnt.Rows[0]["n"]) != "0" {
t.Fatalf("count after delete = %v", cnt.Rows[0]["n"])
}
}