diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..8f46a0d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,67 @@ +name: e2e +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + binary: + name: binary (${{ matrix.mode }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + mode: [mysql, postgres, both] + env: + MILVUS_TEST_ADDR: localhost:19530 + E2E_MODE: ${{ matrix.mode }} + E2E_PROXY_BINARY: ${{ github.workspace }}/.e2e/milvus-sql-proxy + E2E_ARTIFACT_DIR: ${{ github.workspace }}/.e2e/artifacts + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build actual proxy executable with race detection + run: | + mkdir -p "$E2E_ARTIFACT_DIR" + go build -race -o "$E2E_PROXY_BINARY" ./cmd + - name: Start disposable Milvus + shell: bash + run: | + docker run -d --name sqlproxy-e2e-milvus --security-opt seccomp:unconfined \ + -e ETCD_USE_EMBED=true -e ETCD_DATA_DIR=/var/lib/milvus/etcd \ + -e ETCD_CONFIG_PATH=/milvus/configs/embedEtcd.yaml \ + -e COMMON_STORAGETYPE=local -e DEPLOY_MODE=STANDALONE \ + -v "$PWD/testdata/embedEtcd.yaml:/milvus/configs/embedEtcd.yaml:ro" \ + -p 127.0.0.1:19530:19530 -p 127.0.0.1:9091:9091 \ + milvusdb/milvus:v2.6.2 milvus run standalone + for ((attempt=1; attempt<=120; attempt++)); do + if curl --max-time 2 -fsS http://127.0.0.1:9091/healthz; then exit 0; fi + sleep 2 + done + exit 1 + - name: Exercise CLI, configuration, SQL lifecycle and shutdown + shell: bash + run: | + go test -race ./pkg -run '^TestBinaryE2E$' -count=1 -timeout=5m -v 2>&1 | tee "$E2E_ARTIFACT_DIR/test.log" + - name: Collect Milvus diagnostics + if: always() + shell: bash + run: | + mkdir -p "$E2E_ARTIFACT_DIR" + docker logs sqlproxy-e2e-milvus > "$E2E_ARTIFACT_DIR/milvus.log" 2>&1 || true + docker inspect sqlproxy-e2e-milvus > "$E2E_ARTIFACT_DIR/milvus-inspect.json" 2>&1 || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-${{ matrix.mode }} + path: ${{ env.E2E_ARTIFACT_DIR }} + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dec0b43..61d6434 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,8 @@ jobs: go test -race ./... -coverprofile=unit-coverage.out go tool cover -func=unit-coverage.out | awk '/^total:/ { print; gsub("%", "", $3); if ($3 < 90) exit 1 }' - run: go vet ./... + - name: Validate workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 .github/workflows/*.yml milvus: runs-on: ubuntu-latest timeout-minutes: 20 @@ -36,7 +38,7 @@ jobs: -v "$PWD/testdata/embedEtcd.yaml:/milvus/configs/embedEtcd.yaml:ro" \ -p 127.0.0.1:19530:19530 -p 127.0.0.1:9091:9091 \ milvusdb/milvus:v2.6.2 milvus run standalone - for attempt in $(seq 1 120); do + for ((attempt=1; attempt<=120; attempt++)); do if curl -fsS http://127.0.0.1:9091/healthz; then exit 0; fi sleep 2 done diff --git a/Readme.md b/Readme.md index ff8e88a..bd6a92e 100644 --- a/Readme.md +++ b/Readme.md @@ -82,7 +82,7 @@ DELETE FROM documents WHERE id=2; RELEASE TABLE documents; DROP INDEX embedding_idx ON documents; DROP TABLE documents; -USE default; +USE `default`; -- PostgreSQL: USE "default"; DROP DATABASE demo; ``` @@ -130,3 +130,24 @@ from [xwb1989/sqlparser](https://github.com/xwb1989/sqlparser). MySQL protocol: [go-mysql](https://github.com/go-mysql-org/go-mysql). PostgreSQL protocol: [pgx](https://github.com/jackc/pgx). Original result helpers derive from [kingshard](https://github.com/flike/kingshard). + +### Executable E2E workflow + +The separate `e2e` workflow runs on pull requests, pushes to `main`, and manual +dispatch. Its `mysql`, `postgres`, and `both` matrix builds the real proxy with +race detection, starts Milvus 2.6.2, and launches the proxy through a generated +configuration file. Each mode exercises the complete SQL lifecycle through +network clients, rejects bad credentials, checks inactive listeners, and verifies +clean SIGTERM shutdown and released ports. Proxy/Milvus logs and test output are +uploaded as `e2e-` artifacts, including on failure. + +Run one mode locally against a **disposable** Milvus: + +```sh +go build -race -o /tmp/milvus-sql-proxy ./cmd +MILVUS_TEST_ADDR=localhost:19530 E2E_PROXY_BINARY=/tmp/milvus-sql-proxy \ + E2E_MODE=both go test -race ./pkg -run '^TestBinaryE2E$' -count=1 -timeout=5m -v +``` + +Set `E2E_ARTIFACT_DIR` to retain local diagnostics; otherwise tests use their +temporary directory. Tests only remove the uniquely named databases they create. diff --git a/pkg/e2e_test.go b/pkg/e2e_test.go new file mode 100644 index 0000000..4c1dce4 --- /dev/null +++ b/pkg/e2e_test.go @@ -0,0 +1,177 @@ +package pkg + +import ( + "context" + "database/sql" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// TestBinaryE2E crosses the actual CLI/config/process boundary. Its binary must +// be built separately, and the upstream must be a disposable Milvus instance. +func TestBinaryE2E(t *testing.T) { + binary := os.Getenv("E2E_PROXY_BINARY") + if binary == "" { + t.Skip("set E2E_PROXY_BINARY to enable executable E2E") + } + addr := os.Getenv("MILVUS_TEST_ADDR") + if addr == "" { + t.Fatal("MILVUS_TEST_ADDR is required for binary E2E") + } + mode := os.Getenv("E2E_MODE") + if mode != "mysql" && mode != "postgres" && mode != "both" { + t.Fatal("E2E_MODE must be mysql, postgres, or both") + } + binary, err := filepath.Abs(binary) + if err != nil { + t.Fatal(err) + } + artifacts := os.Getenv("E2E_ARTIFACT_DIR") + if artifacts == "" { + artifacts = t.TempDir() + } + if err = os.MkdirAll(artifacts, 0700); err != nil { + t.Fatal(err) + } + mysqlAddr, postgresAddr := reserveE2EPorts(t) + config := filepath.Join(artifacts, "proxy-"+mode+".yaml") + content := fmt.Sprintf("mode: %s\naddr: %q\npostgresAddr: %q\nuser: root\npassword: integration\nqueryTimeoutSeconds: 90\nmilvus:\n addr: %q\n", mode, mysqlAddr, postgresAddr, addr) + if err = os.WriteFile(config, []byte(content), 0600); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(artifacts, "proxy-"+mode+".log") + log, err := os.Create(logPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + log.Close() + if t.Failed() { + if data, err := os.ReadFile(logPath); err == nil { + t.Logf("proxy log:\n%s", data) + } + } + }) + cmd := exec.Command(binary, "-config", config) + cmd.Stdout = log + cmd.Stderr = log + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + var exitErr error + go func() { exitErr = cmd.Wait(); close(done) }() + var stopOnce sync.Once + stop := func() { + stopOnce.Do(func() { + select { + case <-done: + t.Errorf("proxy exited before SIGTERM: %v", exitErr) + return + default: + } + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Error(err) + } + select { + case <-done: + if exitErr != nil { + t.Errorf("proxy SIGTERM exit: %v", exitErr) + } + case <-time.After(10 * time.Second): + cmd.Process.Kill() + <-done + t.Error("proxy did not exit within 10s of SIGTERM") + } + }) + } + t.Cleanup(stop) + modes := []string{mode} + if mode == "both" { + modes = []string{"mysql", "postgres"} + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, protocol := range modes { + endpoint := mysqlAddr + if protocol == "postgres" { + endpoint = postgresAddr + } + for { + err = probeE2E(ctx, protocol, endpoint) + if err == nil { + break + } + select { + case <-done: + t.Fatalf("proxy exited during readiness: %v", exitErr) + case <-ctx.Done(): + t.Fatalf("%s readiness: %v", protocol, err) + case <-time.After(100 * time.Millisecond): + } + } + } + if mode == "mysql" { + assertPortClosed(t, postgresAddr) + } + if mode == "postgres" { + assertPortClosed(t, mysqlAddr) + } + testMilvusLifecycle(t, mysqlAddr, postgresAddr, modes) + // Successful process exit and released listener ports are part of the contract. + stop() + assertPortClosed(t, mysqlAddr) + assertPortClosed(t, postgresAddr) +} + +func reserveE2EPorts(t *testing.T) (string, string) { + t.Helper() + a, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer b.Close() + return a.Addr().String(), b.Addr().String() +} +func assertPortClosed(t *testing.T, addr string) { + t.Helper() + co, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + co.Close() + t.Errorf("unexpected listener at %s", addr) + } +} +func probeE2E(ctx context.Context, protocol, addr string) error { + probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if protocol == "mysql" { + db, err := sql.Open("mysql", fmt.Sprintf("root:integration@tcp(%s)/default?timeout=2s", addr)) + if err != nil { + return err + } + defer db.Close() + var n int + return db.QueryRowContext(probeCtx, "SELECT 1").Scan(&n) + } + pg, err := pgx.Connect(probeCtx, fmt.Sprintf("postgres://root:integration@%s/default?sslmode=disable", addr)) + if err != nil { + return err + } + defer pg.Close(context.Background()) + var n int + return pg.QueryRow(probeCtx, "SELECT 1").Scan(&n) +} diff --git a/pkg/integration_test.go b/pkg/integration_test.go index 0e2053b..284aca5 100644 --- a/pkg/integration_test.go +++ b/pkg/integration_test.go @@ -42,15 +42,21 @@ func TestMilvusIntegration(t *testing.T) { t.Skip("set MILVUS_TEST_ADDR for disposable Milvus integration") } s := startTestServer(t, &Config{Mode: "both", Addr: "127.0.0.1:0", PostgresAddr: "127.0.0.1:0", User: "root", Password: "integration", QueryTimeoutSeconds: 90, Milvus: MilvusConfig{Address: addr}}) + testMilvusLifecycle(t, s.listeners[0].Addr().String(), s.listeners[1].Addr().String(), []string{"mysql", "postgres"}) +} + +// Shared assertions are exercised both against the in-process server and the +// independently built executable. No server internals are used below. +func testMilvusLifecycle(t *testing.T, mysqlAddr, postgresAddr string, modes []string) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - for _, mode := range []string{"mysql", "postgres"} { + for _, mode := range modes { t.Run(mode, func(t *testing.T) { dbname := fmt.Sprintf("sqlproxy_%s_%d", mode, time.Now().UnixNano()) var exec func(string, ...interface{}) error var query func(string, ...interface{}) ([][]interface{}, error) if mode == "mysql" { - db, e := sql.Open("mysql", fmt.Sprintf("root:integration@tcp(%s)/default", s.listeners[0].Addr())) + db, e := sql.Open("mysql", fmt.Sprintf("root:integration@tcp(%s)/default", mysqlAddr)) if e != nil { t.Fatal(e) } @@ -87,7 +93,7 @@ func TestMilvusIntegration(t *testing.T) { return out, rs.Err() } } else { - pg, e := pgx.Connect(ctx, fmt.Sprintf("postgres://root:integration@%s/default?sslmode=disable", s.listeners[1].Addr())) + pg, e := pgx.Connect(ctx, fmt.Sprintf("postgres://root:integration@%s/default?sslmode=disable", postgresAddr)) if e != nil { t.Fatal(e) } @@ -125,16 +131,7 @@ func TestMilvusIntegration(t *testing.T) { return v } mustExec("CREATE DATABASE " + dbname) - defer func() { - exec("USE " + dbname) - exec("RELEASE TABLE items") - exec("DROP TABLE multi") - exec("DROP TABLE items") - exec("USE default") - if e := exec("DROP DATABASE " + dbname); e != nil { - t.Error(e) - } - }() + defer cleanupMilvusDatabase(t, mode, mysqlAddr, postgresAddr, dbname) mustExec("USE " + dbname) mustExec("CREATE TABLE items (id bigint PRIMARY KEY, name varchar(100), enabled bool, score double, meta json, embedding vector(3))") mustExec("CREATE INDEX embedding_idx ON items (embedding) USING HNSW WITH (metric_type='L2', M=16, efConstruction=100)") @@ -221,9 +218,9 @@ func TestMilvusIntegration(t *testing.T) { }) } // Incorrect credentials must fail both protocol handshakes. - for i, mode := range []string{"mysql", "postgres"} { + for _, mode := range modes { if mode == "mysql" { - db, e := sql.Open("mysql", fmt.Sprintf("root:wrong@tcp(%s)/default", s.listeners[i].Addr())) + db, e := sql.Open("mysql", fmt.Sprintf("root:wrong@tcp(%s)/default", mysqlAddr)) if e != nil { t.Fatal(e) } @@ -232,7 +229,7 @@ func TestMilvusIntegration(t *testing.T) { } db.Close() } else { - pg, e := pgx.Connect(ctx, fmt.Sprintf("postgres://root:wrong@%s/default?sslmode=disable", s.listeners[i].Addr())) + pg, e := pgx.Connect(ctx, fmt.Sprintf("postgres://root:wrong@%s/default?sslmode=disable", postgresAddr)) if e == nil { pg.Close(ctx) t.Fatal("PG accepted wrong password") @@ -243,3 +240,44 @@ func TestMilvusIntegration(t *testing.T) { } } } + +// Cleanup must work even after the lifecycle context expires or its connection +// is discarded by a driver. Only this test's uniquely named database is touched. +func cleanupMilvusDatabase(t *testing.T, mode, mysqlAddr, postgresAddr, name string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var execute func(string) error + if mode == "mysql" { + db, err := sql.Open("mysql", fmt.Sprintf("root:integration@tcp(%s)/%s?timeout=5s", mysqlAddr, name)) + if err != nil { + t.Error(err) + return + } + defer db.Close() + db.SetMaxOpenConns(1) + execute = func(q string) error { _, err := db.ExecContext(ctx, q); return err } + } else { + pg, err := pgx.Connect(ctx, fmt.Sprintf("postgres://root:integration@%s/%s?sslmode=disable", postgresAddr, name)) + if err != nil { + t.Error(err) + return + } + defer pg.Close(context.Background()) + execute = func(q string) error { _, err := pg.Exec(ctx, q); return err } + } + // Collections may already have been dropped by successful lifecycle steps. + execute("DROP TABLE multi") + execute("DROP TABLE items") + defaultSQL := "USE `default`" + if mode == "postgres" { + defaultSQL = "USE \"default\"" + } + if err := execute(defaultSQL); err != nil { + t.Error(err) + return + } + if err := execute("DROP DATABASE " + name); err != nil { + t.Errorf("cleanup database %s: %v", name, err) + } +}