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"]) } }