Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 22 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
```

Expand Down Expand Up @@ -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-<mode>` 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.
177 changes: 177 additions & 0 deletions pkg/e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading