Files
opsxdb/apps/server/internal/db/agent_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

395 lines
12 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_test.go —— JDBC agent 桥的协议层测试。
// 用 helper process 扮演 JVM agent(TestMain 拦截 -agent-stub),
// 无需本机安装 Java 即可验证:握手、请求/响应路由、超时、进程退出、并发串行写。
import (
"archive/zip"
"bufio"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// TestMain 拦截 stub 模式:测试二进制被当成 agent 子进程启动时直接进入协议循环。
func TestMain(m *testing.M) {
for _, a := range os.Args {
if a == "-agent-stub" {
runAgentStub()
return
}
}
os.Exit(m.Run())
}
// runAgentStub 扮演 OpsxJdbcAgent 的最小协议面(与 agents/jdbc/OpsxJdbcAgent.java 对应)。
func runAgentStub() {
out := bufio.NewWriter(os.Stdout)
announce := agentResponse{ID: 0, OK: true}
announce.Result = json.RawMessage(`{"agent":"opsx-agent-jdbc","version":"0.1.0","protocol":1}`)
write := func(r agentResponse) {
b, _ := json.Marshal(r)
_, _ = out.WriteString(string(b) + "\n")
out.Flush()
}
write(announce)
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 1024*1024), 1024*1024)
for in.Scan() {
var req agentRequest
if json.Unmarshal(in.Bytes(), &req) != nil {
continue
}
args := req.Args
fail := func(msg string) agentResponse { return agentResponse{ID: req.ID, OK: false, Error: msg} }
switch req.Op {
case "hello":
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"agent":"opsx-agent-jdbc","version":"0.1.0","protocol":1}`)
write(r)
case "open":
if args["url"] == "" || args["url"] == nil {
write(fail("missing url"))
break
}
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"handle":"h1"}`)
write(r)
case "ping":
write(agentResponse{ID: req.ID, OK: true})
case "query":
if strings.Contains(fmt.Sprintf("%v", args["sql"]), "SLOW") {
time.Sleep(2 * time.Second)
}
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"columns":["id","name"],"rows":[{"id":"1","name":"demo"}],"truncated":false}`)
write(r)
case "exec":
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"affectedRows":3}`)
write(r)
case "metaDatabases":
r := agentResponse{ID: req.ID, OK: true}
r.Result = json.RawMessage(`{"names":["MAIN","SYS"]}`)
write(r)
case "close":
write(agentResponse{ID: req.ID, OK: true})
case "shutdown":
write(agentResponse{ID: req.ID, OK: true})
os.Exit(0)
default:
write(fail("unknown op: " + req.Op))
}
}
os.Exit(0)
}
// TestAgentStubHelper 占位:stub 子进程以 -test.run=TestAgentStubHelper 启动时
// 不会走到这里(TestMain 已拦截),但名字必须存在以匹配 -test.run。
func TestAgentStubHelper(t *testing.T) {}
func startStubAgent(t *testing.T) *AgentClient {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=TestAgentStubHelper", "--", "-agent-stub")
cli, err := StartAgentFromCmd(cmd, agentDriverSpecs["dameng"])
if err != nil {
t.Fatalf("StartAgentFromCmd: %v", err)
}
t.Cleanup(func() { cli.Shutdown(context.Background()) })
return cli
}
func TestAgentClientRoundTrip(t *testing.T) {
cli := startStubAgent(t)
ctx := context.Background()
// open → query → exec → metaDatabases → ping
handle := ""
{
var res struct {
Handle string `json:"handle"`
}
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:dm://127.0.0.1:5236", "user": "SYSDBA"}, &res); err != nil {
t.Fatalf("open: %v", err)
}
if res.Handle != "h1" {
t.Fatalf("handle = %q, want h1", res.Handle)
}
handle = res.Handle
}
{
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 1", "maxRows": 100}, &res); err != nil {
t.Fatalf("query: %v", err)
}
if len(res.Columns) != 2 || res.Rows[0]["name"] != "demo" {
t.Fatalf("query result mismatch: %+v", res)
}
}
{
var res struct {
AffectedRows int64 `json:"affectedRows"`
}
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": "UPDATE t SET a=1"}, &res); err != nil {
t.Fatalf("exec: %v", err)
}
if res.AffectedRows != 3 {
t.Fatalf("affectedRows = %d, want 3", res.AffectedRows)
}
}
{
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaDatabases", map[string]any{"handle": handle}, &res); err != nil {
t.Fatalf("metaDatabases: %v", err)
}
if len(res.Names) != 2 || res.Names[0] != "MAIN" {
t.Fatalf("names = %v", res.Names)
}
}
if err := cli.call(ctx, "ping", map[string]any{"handle": handle}, nil); err != nil {
t.Fatalf("ping: %v", err)
}
}
func TestAgentCallTimeout(t *testing.T) {
cli := startStubAgent(t)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
var res map[string]any
err := cli.call(ctx, "query", map[string]any{"handle": "h1", "sql": "SELECT SLOW", "maxRows": 10}, &res)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want DeadlineExceeded", err)
}
}
func TestAgentProcessExit(t *testing.T) {
cli := startStubAgent(t)
cli.Kill() // 杀进程后再调用,必须报进程退出而非挂死
var res map[string]any
err := cli.call(context.Background(), "ping", map[string]any{"handle": "h1"}, &res)
if err == nil {
t.Fatal("期望进程退出错误,got nil")
}
}
func TestAgentConcurrentCalls(t *testing.T) {
cli := startStubAgent(t)
ctx := context.Background()
if err := cli.call(ctx, "open", map[string]any{"url": "jdbc:dm://h:5236"}, nil); err != nil {
t.Fatalf("open: %v", err)
}
const n = 24
var wg sync.WaitGroup
errs := make([]error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
var res struct {
Columns []string `json:"columns"`
}
errs[i] = cli.call(ctx, "query", map[string]any{"handle": "h1", "sql": "SELECT 1", "maxRows": 1}, &res)
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
}
func TestRenderAgentURL(t *testing.T) {
spec := agentDriverSpecs["dameng"]
url, err := renderAgentURL(spec, ConnReq{Host: "127.0.0.1", Port: 5236})
if err != nil {
t.Fatalf("render: %v", err)
}
if url != "jdbc:dm://127.0.0.1:5236" {
t.Fatalf("url = %q", url)
}
if _, err := renderAgentURL(spec, ConnReq{Host: "", Port: 5236}); err == nil {
t.Fatal("空主机必须报错")
}
if _, err := renderAgentURL(spec, ConnReq{Host: "127.0.0.1", Port: 0}); err == nil {
t.Fatal("零端口必须报错")
}
}
func TestDownloadPinnedChecksum(t *testing.T) {
payload := []byte("fake-gson-bytes")
good := sha1.Sum(payload)
cases := []struct {
name string
body []byte
want string
wantErr bool
}{
{"校验通过", payload, hex.EncodeToString(good[:]), false},
{"校验失败拒绝落盘", []byte("tampered"), hex.EncodeToString(good[:]), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(tc.body)
}))
defer srv.Close()
dest := t.TempDir() + "/gson.jar"
err := downloadPinned(srv.URL, dest, tc.want)
if tc.wantErr && err == nil {
t.Fatal("期望校验失败")
}
if !tc.wantErr {
if err != nil {
t.Fatalf("download: %v", err)
}
if _, statErr := os.Stat(dest); statErr != nil {
t.Fatalf("文件未落盘: %v", statErr)
}
} else {
if _, statErr := os.Stat(dest); statErr == nil {
t.Fatal("校验失败时文件不应存在")
}
}
})
}
}
func TestAgentConfigured(t *testing.T) {
dir := t.TempDir()
t.Setenv("OPSXDB_DAMENG_JARS", dir)
if ok, _ := AgentConfigured("dameng"); ok {
t.Fatal("空目录不应判为已配置")
}
if err := os.WriteFile(dir+"/DmJdbcDriver-8.1.3.140.jar", []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if ok, _ := AgentConfigured("dameng"); !ok {
t.Fatal("放置 jar 后应判为已配置")
}
if IsAgentDriver("mysql") {
t.Fatal("mysql 不应是 agent 驱动")
}
if !IsAgentDriver("dameng") {
t.Fatal("dameng 应是 agent 驱动")
}
}
// TestEnsureAgentRuntimeOfflinePack —— 网络不可用(镜像指向无效 + sha256 不匹配)时,
// ensureAgentRuntime 必须从离线驱动包导入 jar(pack.sh 产物)。
func TestEnsureAgentRuntimeOfflinePack(t *testing.T) {
home := t.TempDir()
t.Setenv("OPSXDB_HOME", home)
t.Setenv("OPSXDB_AGENT_JAR", filepath.Join(home, "agent", "opsx-agent-jdbc.jar"))
// 网络必失败:镜像指向本地空端口 + 错误的 sha256
t.Setenv("MAVEN_MIRROR", "http://127.0.0.1:1/none")
badSHA := sha1.Sum([]byte("bad"))
spec := AgentSpec{
DriverKey: "e2e-offline",
DriverClass: "x",
URLTemplate: "jdbc:x://{{.Host}}:{{.Port}}",
JarsDirEnv: "OPSXDB_OFFLINE_JARS",
}
agentDriverSpecs[spec.DriverKey] = spec
t.Cleanup(func() { delete(agentDriverSpecs, spec.DriverKey) })
agentDriverArtifacts[spec.DriverKey] = []agentArtifact{
{"demo.jar", "com.nope:demo:1.0", hex.EncodeToString(badSHA[:])},
}
// 构造离线包:manifest + 正确的 jar 内容(sha256 以包内 manifest 为准)
jarBytes := []byte("fake-jar-content")
files := map[string]string{"drivers/e2e-offline/demo.jar": string(jarBytes)}
manifestFiles := []map[string]string{{"path": "drivers/e2e-offline/demo.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(jarBytes))}}
zipPath := filepath.Join(home, "offline.zip")
if err := writeTestZip(zipPath, manifestFiles, files); err != nil {
t.Fatal(err)
}
t.Setenv("OPSXDB_OFFLINE_ZIP", zipPath)
// agent jar 也在包里(确保 ensureAgentJar 的离线路径不拦路)
if err := importFromOfflinePack(map[string]string{"pack/agent/opsx-agent-jdbc.jar": filepath.Join(home, "agent", "opsx-agent-jdbc.jar")}); err != nil {
// agent jar 不在此包属预期——单独构造一份最小 agent 条目
agentBytes := []byte("fake-agent")
mf2 := []map[string]string{{"path": "agent/opsx-agent-jdbc.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(agentBytes))}}
f2 := map[string]string{"agent/opsx-agent-jdbc.jar": string(agentBytes)}
if werr := writeTestZip(zipPath, append(manifestFiles, mf2...), mergeMaps(files, f2)); werr != nil {
t.Fatal(werr)
}
}
if err := ensureAgentRuntime(spec); err != nil {
t.Fatalf("ensureAgentRuntime 应走离线包成功: %v", err)
}
got, err := os.ReadFile(filepath.Join(home, "drivers", "e2e-offline", "demo.jar"))
if err != nil || string(got) != string(jarBytes) {
t.Fatalf("离线导入内容不符: %v", err)
}
// 负例:manifest hash 不符必须拒
jarBytes2 := []byte("tampered")
mf3 := []map[string]string{{"path": "drivers/e2e-offline/demo.jar", "sha256": fmt.Sprintf("%x", sha256.Sum256(jarBytes2))}}
if err := writeTestZip(zipPath, mf3, map[string]string{"drivers/e2e-offline/demo.jar": "not-the-same-content"}); err != nil {
t.Fatal(err)
}
os.Remove(filepath.Join(home, "drivers", "e2e-offline", "demo.jar"))
if err := ensureAgentRuntime(spec); err == nil {
t.Fatal("manifest 与实际内容不符时必须失败")
}
}
func writeTestZip(path string, manifestFiles []map[string]string, files map[string]string) error {
zf, err := os.Create(path)
if err != nil {
return err
}
defer zf.Close()
zw := zip.NewWriter(zf)
mf, err := zw.Create("pack/manifest.json")
if err != nil {
return err
}
b, _ := json.Marshal(map[string]any{"bundleVersion": 1, "files": manifestFiles})
if _, err := mf.Write(b); err != nil {
return err
}
for rel, content := range files {
w, err := zw.Create("pack/" + rel)
if err != nil {
return err
}
if _, err := w.Write([]byte(content)); err != nil {
return err
}
}
return zw.Close()
}
func mergeMaps(a, b map[string]string) map[string]string {
out := map[string]string{}
for k, v := range a {
out[k] = v
}
for k, v := range b {
out[k] = v
}
return out
}