diff --git a/.gitignore b/.gitignore
index 22c9efc..e7e1bb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,12 +17,12 @@ GEMINI.md
build/
dist/
/altinity-mcp
+/jwe-token-generator
go-carpet*
.mcp.json
.gocache/
.gomodcache/
.tmp/
-resutls.xml
coverage.out
results.xml
push-action
diff --git a/cmd/altinity-mcp/main_test.go b/cmd/altinity-mcp/main_test.go
index 59b852a..af20787 100644
--- a/cmd/altinity-mcp/main_test.go
+++ b/cmd/altinity-mcp/main_test.go
@@ -22,14 +22,12 @@ import (
"testing"
"time"
+ "github.com/altinity/altinity-mcp/internal/testutil/embeddedch"
"github.com/altinity/altinity-mcp/pkg/clickhouse"
"github.com/altinity/altinity-mcp/pkg/config"
"github.com/altinity/altinity-mcp/pkg/jwe_auth"
altinitymcp "github.com/altinity/altinity-mcp/pkg/server"
- "github.com/moby/moby/api/types/container"
"github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
"github.com/urfave/cli/v3"
)
@@ -427,77 +425,23 @@ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7d7Qj8fKjKjKjKjKjKjK
})
}
-// setupClickHouseContainerMain is a local helper for this package's tests
+// setupClickHouseContainerMain boots a ClickHouse server as a host
+// subprocess via embedded-clickhouse and seeds the default.test table this
+// package's tests rely on.
func setupClickHouseContainerMain(t *testing.T) *config.ClickHouseConfig {
t.Helper()
- ctx := context.Background()
-
- totalStart := time.Now()
-
- req := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp", "9000/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- "CLICKHOUSE_DB": "default",
- "CLICKHOUSE_USER": "default",
- "CLICKHOUSE_PASSWORD": "",
- "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
- },
- WaitingFor: wait.ForHTTP("/").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(2 * time.Second),
- }
- containerStart := time.Now()
- chContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true})
- containerElapsed := time.Since(containerStart)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- cleanupStart := time.Now()
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = chContainer.Terminate(cleanupCtx)
- t.Logf("[container/%s] cleanup took %s", req.Image, time.Since(cleanupStart))
- })
-
- host, err := chContainer.Host(ctx)
- require.NoError(t, err)
- port, err := chContainer.MappedPort(ctx, "9000")
- require.NoError(t, err)
+ cfg := embeddedch.Setup(t, embeddedch.WithTCPProtocol())
+ cfg.Limit = 1000
- cfg := &config.ClickHouseConfig{
- Host: host,
- Port: int(port.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.TCPProtocol,
- ReadOnly: false,
- MaxExecutionTime: 60,
- Limit: 1000,
- }
-
- // create base table
- setupStart := time.Now()
+ ctx := context.Background()
client, err := clickhouse.NewClient(ctx, *cfg)
require.NoError(t, err)
defer func() { _ = client.Close() }()
_, _ = client.ExecuteQuery(ctx, "CREATE TABLE IF NOT EXISTS default.test (id UInt64, value String) ENGINE = Memory")
_, _ = client.ExecuteQuery(ctx, "INSERT INTO default.test VALUES (1, 'one') ON CLUSTER default")
- setupElapsed := time.Since(setupStart)
-
- t.Logf("[container/%s] start=%s setup=%s total=%s", req.Image, containerElapsed, setupElapsed, time.Since(totalStart))
return cfg
}
-// startContainerWithTiming wraps testcontainers.GenericContainer with timing logs.
-func startContainerWithTiming(t *testing.T, ctx context.Context, req testcontainers.GenericContainerRequest) (testcontainers.Container, error) {
- t.Helper()
- start := time.Now()
- container, err := testcontainers.GenericContainer(ctx, req)
- t.Logf("[container/%s] start took %s", req.Image, time.Since(start))
- return container, err
-}
-
// Health handler tests
func TestHealthHandler_Additions(t *testing.T) {
t.Parallel()
@@ -728,48 +672,10 @@ func TestHealthHandler(t *testing.T) {
t.Run("successful_clickhouse_connection_with_testcontainer", func(t *testing.T) {
t.Parallel()
- ctx := context.Background()
-
- // Start ClickHouse container
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- // Get the mapped port
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "8123")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
-
+ cfg := embeddedch.Setup(t)
app := &application{
config: config.Config{
- ClickHouse: config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.HTTPProtocol,
- },
+ ClickHouse: *cfg,
Server: config.ServerConfig{
JWE: config.JWEConfig{Enabled: false},
},
@@ -962,289 +868,75 @@ func TestTestConnection(t *testing.T) {
t.Run("successful_connection_with_testcontainer", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
-
- // Start ClickHouse container
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- // Get the mapped port
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "8123")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
-
- cfg := config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.HTTPProtocol,
- }
-
- // Test connection
- err = testConnection(ctx, cfg)
- require.NoError(t, err)
+ cfg := embeddedch.Setup(t)
+ require.NoError(t, testConnection(ctx, *cfg))
})
t.Run("connection_with_tcp_protocol", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
-
- // Start ClickHouse container
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"9000/tcp", "8123/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- // Get the mapped port for TCP
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "9000")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
-
- cfg := config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.TCPProtocol,
- }
-
- // Test connection
- err = testConnection(ctx, cfg)
- require.NoError(t, err)
+ cfg := embeddedch.Setup(t, embeddedch.WithTCPProtocol())
+ require.NoError(t, testConnection(ctx, *cfg))
})
t.Run("connection_with_tls", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
require.NoError(t, setupLogging("debug"))
- // Generate self-signed certificate
+
cert, key, err := generateSelfSignedCert()
require.NoError(t, err)
- // Create HTTPS port config with OpenSSL server section
- httpsConfig := `
+ // Materialize cert + key on disk so the embedded CH server can read
+ // them; then point the server config at those host paths via a
+ // config.d drop-in.
+ certDir := t.TempDir()
+ certPath := filepath.Join(certDir, "server.crt")
+ keyPath := filepath.Join(certDir, "server.key")
+ require.NoError(t, os.WriteFile(certPath, cert, 0644))
+ require.NoError(t, os.WriteFile(keyPath, key, 0600))
+
+ httpsConfig := fmt.Sprintf(`
8443
- /etc/clickhouse-server/server.crt
- /etc/clickhouse-server/server.key
+ %s
+ %s
none
-`
+`, certPath, keyPath)
// https://github.com/ClickHouse/clickhouse-go/issues/1630
nonEmptyDefaultUserPassword := "non_empty"
- // Materialize config to a TempDir and bind-mount read-only.
- // testcontainers' File-stream-via-archive path uses PUT /containers/{id}/archive
- // which is blocked in some sandboxes (e.g. our agent isolator).
- tmpDir := t.TempDir()
- writeTmp := func(name, content string) string {
- p := tmpDir + "/" + name
- require.NoError(t, os.WriteFile(p, []byte(content), 0644))
- return p
- }
- certFile := writeTmp("server.crt", string(cert))
- keyFile := writeTmp("server.key", string(key))
- httpsConfigFile := writeTmp("https_port.xml", httpsConfig)
- // https://github.com/ClickHouse/clickhouse-go/issues/1630
- nonEmptyPasswordFile := writeTmp("non_empty_password.xml", nonEmptyDefaultUserPassword)
-
- // Start ClickHouse container with TLS enabled
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp", "8443/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- HostConfigModifier: func(hc *container.HostConfig) {
- hc.Binds = append(hc.Binds,
- certFile+":/etc/clickhouse-server/server.crt:ro",
- keyFile+":/etc/clickhouse-server/server.key:ro",
- httpsConfigFile+":/etc/clickhouse-server/config.d/https_port.xml:ro",
- nonEmptyPasswordFile+":/etc/clickhouse-server/users.d/non_empty_password.xml:ro",
- )
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "8443")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
-
- cfg := config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- // https://github.com/ClickHouse/clickhouse-go/issues/1630
- Password: "non_empty",
- Protocol: config.HTTPProtocol,
- TLS: config.TLSConfig{
- Enabled: true,
- InsecureSkipVerify: true,
- },
- }
+ chCfg := embeddedch.Setup(t,
+ embeddedch.WithConfigDropIn(httpsConfig),
+ embeddedch.WithConfigDropIn(nonEmptyDefaultUserPassword),
+ )
+ chCfg.Port = 8443
+ chCfg.Username = "default"
+ chCfg.Password = "non_empty"
+ chCfg.Protocol = config.HTTPProtocol
+ chCfg.TLS = config.TLSConfig{Enabled: true, InsecureSkipVerify: true}
- // Test connection
- err = testConnection(ctx, cfg)
- require.NoError(t, err)
+ require.NoError(t, testConnection(ctx, *chCfg))
})
t.Run("connection_with_readonly_mode", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
-
- // Start ClickHouse container
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- // Get the mapped port
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "8123")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
-
- cfg := config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.HTTPProtocol,
- ReadOnly: true,
- }
-
- // Test connection
- err = testConnection(ctx, cfg)
- require.NoError(t, err)
+ cfg := embeddedch.Setup(t)
+ cfg.ReadOnly = true
+ require.NoError(t, testConnection(ctx, *cfg))
})
t.Run("connection_with_max_execution_time", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
-
- // Start ClickHouse container
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- // Get the mapped port
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "8123")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
-
- cfg := config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.HTTPProtocol,
- MaxExecutionTime: 300,
- }
-
- // Test connection
- err = testConnection(ctx, cfg)
- require.NoError(t, err)
+ cfg := embeddedch.Setup(t)
+ cfg.MaxExecutionTime = 300
+ require.NoError(t, testConnection(ctx, *cfg))
})
}
@@ -2760,46 +2452,10 @@ logging:
func TestNewApplicationWithTestContainer(t *testing.T) {
t.Parallel()
ctx := context.Background()
-
- // Start ClickHouse container
- containerReq := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- },
- WaitingFor: wait.ForHTTP("/ping").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(1 * time.Second),
- }
-
- clickhouseContainer, err := startContainerWithTiming(t, ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: containerReq,
- Started: true,
- })
- if err != nil {
- t.Fatal("Failed to start ClickHouse container, skipping test:", err)
- }
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = clickhouseContainer.Terminate(cleanupCtx)
- })
-
- // Get the mapped port
- mappedPort, err := clickhouseContainer.MappedPort(ctx, "8123")
- require.NoError(t, err)
-
- host, err := clickhouseContainer.Host(ctx)
- require.NoError(t, err)
+ chCfg := embeddedch.Setup(t)
cfg := config.Config{
- ClickHouse: config.ClickHouseConfig{
- Host: host,
- Port: int(mappedPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.HTTPProtocol,
- },
+ ClickHouse: *chCfg,
Server: config.ServerConfig{
JWE: config.JWEConfig{
Enabled: false,
diff --git a/cmd/altinity-mcp/oauth_server.go b/cmd/altinity-mcp/oauth_server.go
index 3800a5b..41fe30e 100644
--- a/cmd/altinity-mcp/oauth_server.go
+++ b/cmd/altinity-mcp/oauth_server.go
@@ -601,7 +601,11 @@ func (a *application) fetchUserInfo(accessToken string) (*altinitymcp.OAuthClaim
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Error().Err(closeErr).Msgf("can't close %s response body", userInfoURL)
+ }
+ }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxOAuthResponseBytes))
if err != nil {
return nil, err
@@ -903,7 +907,11 @@ func (a *application) handleOAuthCallback(w http.ResponseWriter, r *http.Request
http.Error(w, "Failed to exchange upstream auth code", http.StatusBadGateway)
return
}
- defer resp.Body.Close()
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Error().Err(closeErr).Msgf("can't close %s response body", tokenURL)
+ }
+ }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxOAuthResponseBytes))
if err != nil {
http.Error(w, "Failed to read upstream token response", http.StatusBadGateway)
@@ -1394,7 +1402,11 @@ func (a *application) handleOAuthTokenRefreshForward(w http.ResponseWriter, r *h
writeOAuthTokenError(w, http.StatusBadGateway, "server_error", "upstream refresh failed")
return
}
- defer resp.Body.Close()
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Error().Err(closeErr).Msgf("can't close %s response body", tokenURL)
+ }
+ }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxOAuthResponseBytes))
if err != nil {
writeOAuthTokenError(w, http.StatusBadGateway, "server_error", "failed to read upstream refresh response")
diff --git a/docs/development_and_testing.md b/docs/development_and_testing.md
index 20aee0e..44c8d7f 100644
--- a/docs/development_and_testing.md
+++ b/docs/development_and_testing.md
@@ -79,6 +79,109 @@ These tests run automatically as part of `go test ./...` (skipped with `-short`)
For configuration background and provider-specific setup, see [oauth_authorization.md](./oauth_authorization.md).
+## Embedded ClickHouse for Tests
+
+Tests under `internal/testutil/embeddedch` boot ClickHouse as a host subprocess instead of a container. Two flavors are supported:
+
+- **Stock** — upstream ClickHouse, downloaded automatically by `franchb/embedded-clickhouse`.
+- **Antalya** — the Altinity Antalya binary, expected at `~/.cache/embedded-clickhouse/clickhouse-`.
+
+On Linux the Antalya binary is extracted once from the Antalya Docker image (`altinity/clickhouse-server:26.1.6.20001.altinityantalya`) on first use.
+On macOS and other non-Linux hosts you must build it from source ahead of time — the Antalya Docker image only ships a Linux ELF, so it cannot run as a host subprocess on macOS.
+
+### Build the Antalya `clickhouse` binary on macOS
+
+The Antalya tree shares the upstream ClickHouse build system, so the standard macOS build flow applies. Steps below are tailored for `~/.cache/embedded-clickhouse/` placement expected by the tests in this repo.
+
+1. Install build prerequisites via Homebrew:
+
+ ```bash
+ brew update
+ brew install ccache cmake ninja libtool gettext llvm lld binutils grep findutils nasm bash rust rustup
+ ```
+
+2. Extract the Antalya image ref/tag straight from the Go source so the build stays in lockstep with what the tests expect. Run this from the root of the `altinity-mcp` checkout:
+
+ ```bash
+ # AntalyaImageRef looks like "altinity/clickhouse-server:26.1.6.20001.altinityantalya"
+ ANTALYA_IMAGE_REF=$(grep -E '^\s*const AntalyaImageRef' internal/testutil/embeddedch/embeddedch.go | sed -E 's/.*"([^"]+)".*/\1/')
+ # 26.1.6.20001.altinityantalya
+ ANTALYA_IMAGE_TAG="v${ANTALYA_IMAGE_REF##*:}"
+ echo "image=$ANTALYA_IMAGE_REF tag=$ANTALYA_IMAGE_TAG"
+ ```
+
+ Keep this shell session open — `ANTALYA_IMAGE_REF` and `ANTALYA_IMAGE_TAG` are reused in steps 3 and 5.
+
+3. Clone the Altinity ClickHouse fork (Antalya branches/tags live here) with submodules and check out the tag matching `ANTALYA_IMAGE_TAG`. The clone path **must not contain whitespace**:
+
+ ```bash
+ git clone https://github.com/Altinity/ClickHouse.git ~/src/github.com/altinity/ClickHouse
+ cd ~/src/github.com/altinity/ClickHouse
+ git fetch --tags
+ git checkout "$ANTALYA_IMAGE_TAG"
+ git submodule update --init --jobs 8
+ ```
+
+ If you skipped `--recurse-submodules` during clone, the explicit `git submodule update --init` step is required — submodules are not checked out by default.
+
+4. Install the Rust nightly toolchain ClickHouse pins. The version is hardcoded in `contrib/corrosion-cmake/CMakeLists.txt` (`Rust_TOOLCHAIN`); if you skip this step `cmake` fails with `Cannot find nightly-YYYY-MM-DD Rust toolchain`. Extract it from the source so you always install exactly what the build expects:
+
+ ```bash
+ cd ~/src/github.com/altinity/ClickHouse
+ # First time only: initialise rustup if you installed it via Homebrew.
+ # `brew install rustup` puts the binary on PATH but does not pick a default
+ # toolchain — running rustup-init -y is the supported one-shot setup.
+ command -v rustup-init >/dev/null && rustup-init -y --no-modify-path --default-toolchain stable
+
+ RUST_TOOLCHAIN=$(grep -E 'set\(Rust_TOOLCHAIN' contrib/corrosion-cmake/CMakeLists.txt | sed -E 's/.*"([^"]+)".*/\1/')
+ echo "required toolchain: $RUST_TOOLCHAIN"
+
+ rustup toolchain install "$RUST_TOOLCHAIN"
+ # Sanity check — should list the nightly alongside stable:
+ rustup toolchain list
+ ```
+
+ The build does not need this nightly to be the default toolchain; corrosion picks it up by name. If you ever bump the ClickHouse checkout to a newer Antalya tag and `cmake` complains about a different missing nightly, just rerun the snippet above — it always reads the value from source.
+
+5. Build with Homebrew's Clang/LLD (Apple's system Clang is **not** supported):
+
+ ```bash
+ cd ~/src/github.com/altinity/ClickHouse
+ mkdir -p build
+ export PATH="$(brew --prefix llvm)/bin:$PATH"
+ cmake -S . -B build -G Ninja \
+ -DCMAKE_BUILD_TYPE=RelWithDebInfo \
+ -DCMAKE_C_COMPILER="$(brew --prefix llvm)/bin/clang" \
+ -DCMAKE_CXX_COMPILER="$(brew --prefix llvm)/bin/clang++" \
+ -DCMAKE_AR="$(brew --prefix llvm)/bin/llvm-ar" \
+ -DCMAKE_RANLIB="$(brew --prefix llvm)/bin/llvm-ranlib"
+ cmake --build build --target clickhouse
+ # Resulting binary: build/programs/clickhouse
+ ```
+
+ If linking fails with `ld: archive member '/' not a mach-o file in ...`, double-check that `-DCMAKE_AR=$(brew --prefix llvm)/bin/llvm-ar` is set (Apple's `ar` does not support GNU thin archives).
+
+6. Copy the built binary into the cache directory using the exact filename the tests look for. The filename suffix is `safeFileName(AntalyaImageRef)` — every char outside `[A-Za-z0-9._-]` becomes `_` — which we reproduce here from `$ANTALYA_IMAGE_REF`:
+
+ ```bash
+ mkdir -p ~/.cache/embedded-clickhouse
+ ANTALYA_BIN_SUFFIX=$(printf '%s' "$ANTALYA_IMAGE_REF" | LC_ALL=C sed -E 's/[^A-Za-z0-9._-]/_/g')
+ DEST=~/.cache/embedded-clickhouse/clickhouse-${ANTALYA_BIN_SUFFIX}
+ cp ~/src/github.com/altinity/ClickHouse/build/programs/clickhouse "$DEST"
+ chmod +x "$DEST"
+ echo "installed: $DEST"
+ ```
+
+ If you're unsure what filename the loader expects, just run the Antalya tests once — the failure message prints the exact path it looked for.
+
+7. Re-run the embedded-CH tests:
+
+ ```bash
+ go test ./pkg/server/... -run Antalya -count=1 -v
+ ```
+
+If you want to refresh the macOS binary after `AntalyaImageRef` is bumped, re-run step 2 to refresh `ANTALYA_IMAGE_REF`/`ANTALYA_IMAGE_TAG`, then repeat steps 3 (`git pull && git checkout && git submodule update`), 4 (the pinned Rust nightly may have changed), 5, and 6. The cached binary is keyed by `AntalyaImageRef`, so bumping that constant invalidates the cache automatically.
+
## Suggested Contributor Workflow
For a typical code change:
diff --git a/go.mod b/go.mod
index ac42dcc..faa0377 100644
--- a/go.mod
+++ b/go.mod
@@ -5,77 +5,42 @@ go 1.26
require (
github.com/AfterShip/clickhouse-sql-parser v0.5.1
github.com/Altinity/clickhouse-go/v2 v2.45.1-0.20260424134931-fb5f38b1cac7
+ github.com/franchb/embedded-clickhouse v0.4.0
github.com/go-jose/go-jose/v4 v4.1.4
- github.com/moby/moby/api v1.54.1
- github.com/modelcontextprotocol/go-sdk v1.5.0
+ github.com/moby/moby/api v1.54.2
+ github.com/modelcontextprotocol/go-sdk v1.6.0
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
- github.com/testcontainers/testcontainers-go v0.42.0
github.com/urfave/cli/v3 v3.8.0
gopkg.in/yaml.v3 v3.0.1
)
require (
- dario.cat/mergo v1.0.2 // indirect
- github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
- github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/andybalholm/brotli v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/containerd/errdefs v1.0.0 // indirect
- github.com/containerd/errdefs/pkg v0.3.0 // indirect
- github.com/containerd/log v0.1.0 // indirect
- github.com/containerd/platforms v0.2.1 // indirect
- github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
- github.com/ebitengine/purego v0.10.0 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
- github.com/go-logr/logr v1.4.3 // indirect
- github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/klauspost/compress v1.18.5 // indirect
- github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
- github.com/magiconair/properties v1.8.10 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
- github.com/moby/go-archive v0.2.0 // indirect
- github.com/moby/moby/client v0.4.0 // indirect
- github.com/moby/patternmatcher v0.6.1 // indirect
- github.com/moby/sys/sequential v0.6.0 // indirect
- github.com/moby/sys/user v0.4.0 // indirect
- github.com/moby/sys/userns v0.1.0 // indirect
- github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
- github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
- github.com/sirupsen/logrus v1.9.4 // indirect
- github.com/tklauser/go-sysconf v0.3.16 // indirect
- github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
- github.com/yusufpapurcu/wmi v1.2.4 // indirect
- go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
- go.opentelemetry.io/otel v1.42.0 // indirect
- go.opentelemetry.io/otel/metric v1.42.0 // indirect
- go.opentelemetry.io/otel/trace v1.42.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.49.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
)
diff --git a/go.sum b/go.sum
index 0736b00..9290c89 100644
--- a/go.sum
+++ b/go.sum
@@ -1,47 +1,24 @@
-dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
-dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AfterShip/clickhouse-sql-parser v0.5.1 h1:Re4BZXx0v4zVKvtumqwFSVy6Xmaui6QT/OgYL6zM3aI=
github.com/AfterShip/clickhouse-sql-parser v0.5.1/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
github.com/Altinity/clickhouse-go/v2 v2.45.1-0.20260424134931-fb5f38b1cac7 h1:/vBQtlgdQ2URiFPjIxHwF0YUnAEkwk87B/edeu/G36Q=
github.com/Altinity/clickhouse-go/v2 v2.45.1-0.20260424134931-fb5f38b1cac7/go.mod h1:SqvoxopcNPeJWv3lSv3hpBYlwanQ24hWkorGjEWKmdg=
-github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
-github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
+github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
-github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
-github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
-github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
-github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
-github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
-github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
-github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
-github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
-github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
-github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
-github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
-github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/franchb/embedded-clickhouse v0.4.0 h1:oOvS8DWqBuKTwFclBefZR4oq3l2BPVJfjXRVIf80ZEk=
+github.com/franchb/embedded-clickhouse v0.4.0/go.mod h1:03D51zEeJ1QAOaReNR3SSlmb7hJQ91BK/KmOynOT288=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
@@ -53,65 +30,67 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
-github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
-github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
-github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
-github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
-github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
-github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
-github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
-github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
-github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
-github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
-github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
-github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
-github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
-github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
-github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
-github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
-github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
-github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
-github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
+github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
+github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
+github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
+github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
+github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
+github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
+github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
+github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
@@ -122,67 +101,93 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
-github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
-github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
-github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
-github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
-github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
-github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
-github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
-github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
-github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
-github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
-github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
+github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
-github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
-github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
-go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
-go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
-go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
-go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
-go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
-go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
-go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
-go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
-go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
-go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
-golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
-pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
diff --git a/internal/testutil/embeddedch/embeddedch.go b/internal/testutil/embeddedch/embeddedch.go
new file mode 100644
index 0000000..f301277
--- /dev/null
+++ b/internal/testutil/embeddedch/embeddedch.go
@@ -0,0 +1,323 @@
+// Package embeddedch boots a ClickHouse server as a host subprocess for tests
+// via franchb/embedded-clickhouse. It replaces the testcontainers-based
+// fixtures used across this repo, eliminating Docker/Ryuk/proxy plumbing for
+// any test that doesn't require Antalya-specific server features.
+//
+// Stock ClickHouse 26.3 is the default; pass WithFlavor(FlavorAntalya) to run
+// the Altinity Antalya binary. On Linux the binary is extracted once from the
+// production Docker image into ~/.cache/embedded-clickhouse/. On non-Linux
+// hosts (macOS, ...) the binary must be present at that location ahead of
+// time — see docs/development_and_testing.md for build/install steps.
+package embeddedch
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/altinity/altinity-mcp/pkg/config"
+ embeddedclickhouse "github.com/franchb/embedded-clickhouse"
+ "github.com/stretchr/testify/require"
+)
+
+// AntalyaImageRef is the Altinity Antalya image used as the source for the
+// extracted Antalya ClickHouse binary on Linux. Bump in lockstep with
+// production deployments.
+const AntalyaImageRef = "altinity/clickhouse-server:26.1.11.20001.altinityantalya"
+
+// Flavor selects which ClickHouse binary embedded-clickhouse runs.
+type Flavor int
+
+const (
+ // FlavorStock uses the upstream ClickHouse binary that
+ // embedded-clickhouse fetches from GitHub releases.
+ FlavorStock Flavor = iota
+ // FlavorAntalya runs the Altinity Antalya clickhouse binary cached at
+ // embeddedClickHouseCacheDir() (typically ~/.cache/embedded-clickhouse/).
+ // On Linux the binary is extracted once from AntalyaImageRef on first use.
+ // On non-Linux hosts the binary must be built locally and dropped into the
+ // cache dir — see docs/development_and_testing.md for instructions.
+ FlavorAntalya
+)
+
+// Options customizes the ClickHouse fixture spun up by Setup.
+type Options struct {
+ Flavor Flavor
+ ConfigDropIns []string
+ // UsersXML, if non-empty, is written to /users.xml beside the
+ // generated config.xml. Use it when a config.d drop-in references
+ // `users.xml` (Antalya's
+ // token_processors / user_directories does this) — the file would
+ // otherwise not exist and ClickHouse fails startup with CANNOT_LOAD_CONFIG.
+ UsersXML string
+ // Protocol controls which port (HTTP or TCP) is reflected in the
+ // returned ClickHouseConfig. Defaults to HTTP.
+ Protocol config.ClickHouseProtocol
+ StartTimeout time.Duration
+}
+
+// Option mutates Options.
+type Option func(*Options)
+
+// WithFlavor selects the ClickHouse flavor.
+func WithFlavor(f Flavor) Option { return func(o *Options) { o.Flavor = f } }
+
+// WithConfigDropIn adds an XML drop-in written to /config.d/N.xml
+// before Start. ClickHouse auto-merges these into its main config; this is
+// the no-Docker equivalent of testcontainers' Files: []ContainerFile.
+func WithConfigDropIn(xml string) Option {
+ return func(o *Options) { o.ConfigDropIns = append(o.ConfigDropIns, xml) }
+}
+
+// WithTCPProtocol returns the native protocol port instead of HTTP.
+func WithTCPProtocol() Option {
+ return func(o *Options) { o.Protocol = config.TCPProtocol }
+}
+
+// WithStartTimeout overrides the default 60s start timeout.
+func WithStartTimeout(d time.Duration) Option {
+ return func(o *Options) { o.StartTimeout = d }
+}
+
+// WithUsersXML writes the given XML to /users.xml beside config.xml.
+// Pair with config.d drop-ins that declare .
+func WithUsersXML(xml string) Option {
+ return func(o *Options) { o.UsersXML = xml }
+}
+
+// Setup boots a ClickHouse server as a host subprocess and returns a
+// ClickHouseConfig pointing at it. The server is shut down via t.Cleanup.
+//
+// In short test mode (-short) the test is skipped to avoid the binary
+// download cost on quick local iterations.
+func Setup(t *testing.T, opts ...Option) *config.ClickHouseConfig {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("skipping embedded ClickHouse in short mode")
+ }
+
+ o := Options{
+ Protocol: config.HTTPProtocol,
+ StartTimeout: 60 * time.Second,
+ }
+ for _, fn := range opts {
+ fn(&o)
+ }
+
+ cfgBuilder := embeddedclickhouse.DefaultConfig().
+ Version(embeddedclickhouse.V26_3).
+ StartTimeout(o.StartTimeout)
+
+ if o.Flavor == FlavorAntalya {
+ bin := EnsureAntalyaBinary(t)
+ cfgBuilder = cfgBuilder.BinaryPath(bin)
+ } else {
+ // embedded-clickhouse v0.4.0's cache layer locks within a process but
+ // not across processes. `go test ./...` runs each package as a
+ // separate binary, so multiple processes may race to download and
+ // extract the same archive into ~/.cache/embedded-clickhouse,
+ // corrupting the .tmp file with "write binary: unexpected EOF" or
+ // "rename temp file: no such file or directory". Hold an OS-level
+ // flock around the first start until the archive lands, then release.
+ release, err := acquireFileLock(t, embeddedClickHouseCacheLockPath())
+ require.NoError(t, err)
+ defer release()
+ }
+
+ if len(o.ConfigDropIns) > 0 || o.UsersXML != "" {
+ dataDir := t.TempDir()
+ if len(o.ConfigDropIns) > 0 {
+ configDDir := filepath.Join(dataDir, "config.d")
+ require.NoError(t, os.MkdirAll(configDDir, 0o755))
+ for i, xml := range o.ConfigDropIns {
+ path := filepath.Join(configDDir, "drop-in-"+strconv.Itoa(i)+".xml")
+ require.NoError(t, os.WriteFile(path, []byte(xml), 0o644))
+ }
+ }
+ if o.UsersXML != "" {
+ require.NoError(t, os.WriteFile(filepath.Join(dataDir, "users.xml"), []byte(o.UsersXML), 0o644))
+ }
+ cfgBuilder = cfgBuilder.DataPath(dataDir)
+ }
+
+ ch := embeddedclickhouse.NewServer(cfgBuilder)
+ require.NoError(t, ch.Start(), "embedded-clickhouse start failed")
+ t.Cleanup(func() { _ = ch.Stop() })
+
+ addr := ch.HTTPAddr()
+ if o.Protocol == config.TCPProtocol {
+ addr = ch.TCPAddr()
+ }
+ host, port, err := net.SplitHostPort(addr)
+ require.NoError(t, err)
+ portInt, err := strconv.Atoi(port)
+ require.NoError(t, err)
+
+ return &config.ClickHouseConfig{
+ Host: host,
+ Port: portInt,
+ Database: "default",
+ Username: "default",
+ Password: "",
+ Protocol: o.Protocol,
+ ReadOnly: false,
+ MaxExecutionTime: 60,
+ }
+}
+
+// antalyaBinaryCache memoizes the extracted binary path for the lifetime
+// of the test process so we only extract once per `go test` invocation.
+var (
+ antalyaBinaryOnce sync.Once
+ antalyaBinaryPath string
+ antalyaBinaryErr error
+)
+
+// EnsureAntalyaBinary returns the path to a cached Antalya clickhouse binary
+// at embeddedClickHouseCacheDir().
+//
+// On Linux the binary is extracted once from AntalyaImageRef via Docker.
+// On non-Linux hosts the binary must already be present at the expected path
+// (Antalya does not publish macOS/Windows Docker images, and a Linux ELF
+// cannot run as a host subprocess on macOS). When missing on non-Linux, this
+// fails with a message pointing at docs/development_and_testing.md, which
+// covers building clickhouse from source on macOS.
+func EnsureAntalyaBinary(t *testing.T) string {
+ t.Helper()
+ antalyaBinaryOnce.Do(func() {
+ antalyaBinaryPath, antalyaBinaryErr = ensureAntalyaBinary()
+ })
+ require.NoError(t, antalyaBinaryErr, "failed to provide Antalya clickhouse binary")
+ require.NotEmpty(t, antalyaBinaryPath, "Antalya binary path is empty")
+ return antalyaBinaryPath
+}
+
+// AntalyaBinaryPath is the on-disk location where EnsureAntalyaBinary looks
+// for (and on Linux, caches) the Antalya clickhouse binary.
+func AntalyaBinaryPath() string {
+ return filepath.Join(embeddedClickHouseCacheDir(), "clickhouse-"+safeFileName(AntalyaImageRef))
+}
+
+func ensureAntalyaBinary() (string, error) {
+ binPath := AntalyaBinaryPath()
+ if st, err := os.Stat(binPath); err == nil && st.Mode().IsRegular() && st.Size() > 0 {
+ return binPath, nil
+ }
+ if runtime.GOOS != "linux" {
+ return "", fmt.Errorf(
+ "Antalya clickhouse binary not found at %s.\n"+
+ "On %s/%s the binary cannot be extracted from the Antalya Docker image (Linux ELF only).\n"+
+ "Build clickhouse from source and copy the resulting binary into %s — see docs/development_and_testing.md.",
+ binPath, runtime.GOOS, runtime.GOARCH, embeddedClickHouseCacheDir(),
+ )
+ }
+ return extractAntalyaBinaryFromDocker(binPath)
+}
+
+// extractAntalyaBinaryFromDocker pulls AntalyaImageRef and copies
+// /usr/bin/clickhouse out of a non-running container into binPath. Subsequent
+// callers reuse the cached file when the underlying tag hasn't changed.
+func extractAntalyaBinaryFromDocker(binPath string) (string, error) {
+ if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil {
+ return "", err
+ }
+
+ if out, err := exec.Command("docker", "pull", AntalyaImageRef).CombinedOutput(); err != nil {
+ return "", &extractErr{stage: "docker pull", out: string(out), err: err}
+ }
+
+ cname := "antalya-extract-" + strconv.FormatInt(time.Now().UnixNano(), 36)
+ if out, err := exec.Command("docker", "create", "--name", cname, AntalyaImageRef).CombinedOutput(); err != nil {
+ return "", &extractErr{stage: "docker create", out: string(out), err: err}
+ }
+ defer func() {
+ _ = exec.Command("docker", "rm", "-f", cname).Run()
+ }()
+
+ if out, err := exec.Command("docker", "cp", cname+":/usr/bin/clickhouse", binPath).CombinedOutput(); err != nil {
+ return "", &extractErr{stage: "docker cp", out: string(out), err: err}
+ }
+ if err := os.Chmod(binPath, 0o755); err != nil {
+ return "", err
+ }
+ return binPath, nil
+}
+
+type extractErr struct {
+ stage string
+ out string
+ err error
+}
+
+func (e *extractErr) Error() string {
+ if e.out != "" {
+ return e.stage + ": " + e.err.Error() + "\n" + e.out
+ }
+ return e.stage + ": " + e.err.Error()
+}
+
+// embeddedClickHouseCacheDir is the on-disk cache shared between the Antalya
+// extraction path and the cross-process lock used by the stock-CH download
+// path. Always ~/.cache/embedded-clickhouse/ — we deliberately avoid
+// os.UserCacheDir() because it resolves to ~/Library/Caches on macOS, which
+// would split the cache between OSes and surprise developers who built the
+// Antalya binary by hand following docs/development_and_testing.md. Falls
+// back to /tmp only if $HOME cannot be determined.
+func embeddedClickHouseCacheDir() string {
+ home, err := os.UserHomeDir()
+ if err != nil || home == "" {
+ home = "/tmp"
+ }
+ cacheDir := filepath.Join(home, ".cache", "embedded-clickhouse")
+ _ = os.MkdirAll(cacheDir, 0o755)
+ return cacheDir
+}
+
+// embeddedClickHouseCacheLockPath returns the path to a process-private lock
+// file used to serialize concurrent first-time stock-CH binary extractions
+// when go test runs packages in parallel.
+func embeddedClickHouseCacheLockPath() string {
+ return filepath.Join(embeddedClickHouseCacheDir(), ".altinity-mcp-extract.lock")
+}
+
+// acquireFileLock takes an exclusive flock on the given path, returning a
+// release function the caller must defer. Blocks until the lock is granted.
+// The lock file is created if it doesn't exist; we never delete it (multiple
+// concurrent processes need a stable inode to flock on).
+func acquireFileLock(t *testing.T, path string) (func(), error) {
+ t.Helper()
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
+ if err != nil {
+ return nil, err
+ }
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
+ _ = f.Close()
+ return nil, err
+ }
+ return func() {
+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
+ _ = f.Close()
+ }, nil
+}
+
+func safeFileName(s string) string {
+ out := make([]byte, 0, len(s))
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '-', c == '_':
+ out = append(out, c)
+ default:
+ out = append(out, '_')
+ }
+ }
+ return string(out)
+}
diff --git a/pkg/clickhouse/client_test.go b/pkg/clickhouse/client_test.go
index 7a64ae3..771d4d9 100644
--- a/pkg/clickhouse/client_test.go
+++ b/pkg/clickhouse/client_test.go
@@ -5,68 +5,17 @@ import (
"testing"
"time"
+ "github.com/altinity/altinity-mcp/internal/testutil/embeddedch"
"github.com/altinity/altinity-mcp/pkg/config"
"github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
)
-// setupClickHouseContainer sets up a ClickHouse container for testing.
-func setupClickHouseContainer(t *testing.T) *config.ClickHouseConfig {
+// setupEmbeddedClickHouse boots a ClickHouse server as a host subprocess via
+// embedded-clickhouse and returns a TCP-protocol config. This replaces the
+// previous testcontainers-based fixture.
+func setupEmbeddedClickHouse(t *testing.T) *config.ClickHouseConfig {
t.Helper()
- ctx := context.Background()
-
- totalStart := time.Now()
-
- req := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp", "9000/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- "CLICKHOUSE_DB": "default",
- "CLICKHOUSE_USER": "default",
- "CLICKHOUSE_PASSWORD": "",
- "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
- },
- WaitingFor: wait.ForHTTP("/").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(2 * time.Second),
- }
- containerStart := time.Now()
- chContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
- })
- containerElapsed := time.Since(containerStart)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- cleanupStart := time.Now()
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- if err := chContainer.Terminate(cleanupCtx); err != nil {
- t.Logf("Warning: failed to terminate container: %v", err)
- }
- t.Logf("[container/%s] cleanup took %s", req.Image, time.Since(cleanupStart))
- })
-
- host, err := chContainer.Host(ctx)
- require.NoError(t, err)
-
- port, err := chContainer.MappedPort(ctx, "9000")
- require.NoError(t, err)
-
- t.Logf("[container/%s] start=%s total=%s", req.Image, containerElapsed, time.Since(totalStart))
-
- return &config.ClickHouseConfig{
- Host: host,
- Port: int(port.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.TCPProtocol,
- ReadOnly: false,
- MaxExecutionTime: 60,
- Limit: 0,
- }
+ return embeddedch.Setup(t, embeddedch.WithTCPProtocol())
}
// TestNewClient tests client creation
@@ -109,7 +58,7 @@ func TestNewClient(t *testing.T) {
// TestClientOperations tests client operations with real ClickHouse
func TestClientOperations(t *testing.T) {
t.Parallel()
- cfg := setupClickHouseContainer(t)
+ cfg := setupEmbeddedClickHouse(t)
ctx := context.Background()
client, err := NewClient(ctx, *cfg)
@@ -173,7 +122,7 @@ func TestClientErrorPaths(t *testing.T) {
t.Run("describe_table_not_exists", func(t *testing.T) {
t.Parallel()
- cfg := setupClickHouseContainer(t)
+ cfg := setupEmbeddedClickHouse(t)
ctx := context.Background()
client, err := NewClient(ctx, *cfg)
require.NoError(t, err)
@@ -185,7 +134,7 @@ func TestClientErrorPaths(t *testing.T) {
t.Run("non_select_error", func(t *testing.T) {
t.Parallel()
- cfg := setupClickHouseContainer(t)
+ cfg := setupEmbeddedClickHouse(t)
ctx := context.Background()
client, err := NewClient(ctx, *cfg)
require.NoError(t, err)
diff --git a/pkg/clickhouse/cluster_secret_test.go b/pkg/clickhouse/cluster_secret_test.go
index d769f16..599d233 100644
--- a/pkg/clickhouse/cluster_secret_test.go
+++ b/pkg/clickhouse/cluster_secret_test.go
@@ -2,14 +2,11 @@ package clickhouse
import (
"context"
- "fmt"
"testing"
- "time"
+ "github.com/altinity/altinity-mcp/internal/testutil/embeddedch"
"github.com/altinity/altinity-mcp/pkg/config"
"github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
)
const (
@@ -56,57 +53,18 @@ const (
`
)
-// setupClusterSecretClickHouse launches a ClickHouse container whose
-// `remote_servers` list declares a cluster with a shared interserver secret,
-// plus two password-less users (alice, bob) that the driver can impersonate.
-//
-// Config files are written by a shell wrapper before ClickHouse starts. We
-// cannot use testcontainers' file-copy API here — the isolator sandbox
-// blocks the `PUT /containers//archive` Docker endpoint.
+// setupClusterSecretClickHouse boots ClickHouse via embedded-clickhouse with
+// `remote_servers` declaring a cluster + shared interserver secret, plus two
+// password-less users (alice, bob) the driver impersonates over the cluster
+// channel. Both XML pieces are merged via config.d drop-ins.
func setupClusterSecretClickHouse(t *testing.T) (host string, tcpPort int) {
t.Helper()
- ctx := context.Background()
-
- script := fmt.Sprintf(`set -e
-cat > /etc/clickhouse-server/config.d/cluster.xml <<'EOF'
-%sEOF
-cat > /etc/clickhouse-server/users.d/cluster_users.xml <<'EOF'
-%sEOF
-exec /entrypoint.sh
-`, clusterConfigXML, clusterUsersXML)
-
- req := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp", "9000/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_DB": "default",
- "CLICKHOUSE_USER": "default",
- "CLICKHOUSE_PASSWORD": "",
- "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
- },
- Entrypoint: []string{"/bin/sh", "-c", script},
- WaitingFor: wait.ForHTTP("/").WithPort("8123/tcp").
- WithStartupTimeout(60 * time.Second).
- WithPollInterval(2 * time.Second),
- }
- c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
- })
- require.NoError(t, err)
- t.Cleanup(func() {
- cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- if termErr := c.Terminate(cleanCtx); termErr != nil {
- t.Logf("cluster fixture: terminate failed: %v", termErr)
- }
- })
-
- h, err := c.Host(ctx)
- require.NoError(t, err)
- p, err := c.MappedPort(ctx, "9000")
- require.NoError(t, err)
- return h, int(p.Num())
+ cfg := embeddedch.Setup(t,
+ embeddedch.WithTCPProtocol(),
+ embeddedch.WithConfigDropIn(clusterConfigXML),
+ embeddedch.WithConfigDropIn(clusterUsersXML),
+ )
+ return cfg.Host, cfg.Port
}
func clusterClientConfig(host string, port int, username string, secret string) config.ClickHouseConfig {
diff --git a/pkg/server/embedded_clickhouse_helpers_test.go b/pkg/server/embedded_clickhouse_helpers_test.go
new file mode 100644
index 0000000..722a1e3
--- /dev/null
+++ b/pkg/server/embedded_clickhouse_helpers_test.go
@@ -0,0 +1,62 @@
+package server
+
+import (
+ "context"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/altinity/altinity-mcp/internal/testutil/embeddedch"
+ "github.com/altinity/altinity-mcp/pkg/clickhouse"
+ "github.com/altinity/altinity-mcp/pkg/config"
+ "github.com/stretchr/testify/require"
+)
+
+// setupEmbeddedClickHouse boots a ClickHouse server via embedded-clickhouse
+// and seeds the default.test table the broader pkg/server suite relies on.
+// Use setupEmbeddedClickHouseUnseeded when the test needs a clean server.
+func setupEmbeddedClickHouse(t *testing.T, opts ...embeddedch.Option) *config.ClickHouseConfig {
+ t.Helper()
+ chConfig := embeddedch.Setup(t, opts...)
+ seedDefaultTable(t, chConfig)
+ return chConfig
+}
+
+// setupEmbeddedClickHouseUnseeded boots a server without seeding default.test.
+// Use this for tests that need a clean schema or define their own users via
+// config.d drop-ins (where the default user may not have access yet).
+func setupEmbeddedClickHouseUnseeded(t *testing.T, opts ...embeddedch.Option) *config.ClickHouseConfig {
+ t.Helper()
+ return embeddedch.Setup(t, opts...)
+}
+
+func seedDefaultTable(t *testing.T, chConfig *config.ClickHouseConfig) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ client, err := clickhouse.NewClient(ctx, *chConfig)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = client.Close() })
+
+ _, err = client.ExecuteQuery(ctx, `CREATE TABLE IF NOT EXISTS default.test (
+ id UInt64,
+ name String,
+ created_at DateTime
+ ) ENGINE = MergeTree() ORDER BY id`)
+ require.NoError(t, err)
+ _, err = client.ExecuteQuery(ctx, `INSERT INTO default.test VALUES (1, 'test1', now()), (2, 'test2', now())`)
+ require.NoError(t, err)
+}
+
+// Local re-exports so call sites in pkg/server don't need to import the
+// shared package by its full path.
+func withFlavor(f embeddedch.Flavor) embeddedch.Option { return embeddedch.WithFlavor(f) }
+func withConfigDropIn(xml string) embeddedch.Option { return embeddedch.WithConfigDropIn(xml) }
+
+const (
+ flavorStock = embeddedch.FlavorStock
+ flavorAntalya = embeddedch.FlavorAntalya
+)
+
+// portString stringifies an integer port for callers building DSNs by hand.
+func portString(p int) string { return strconv.Itoa(p) }
diff --git a/pkg/server/embedded_xml_config_test.go b/pkg/server/embedded_xml_config_test.go
new file mode 100644
index 0000000..32f5f3e
--- /dev/null
+++ b/pkg/server/embedded_xml_config_test.go
@@ -0,0 +1,83 @@
+package server
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ _ "github.com/Altinity/clickhouse-go/v2"
+ "github.com/stretchr/testify/require"
+)
+
+// TestEmbeddedClickHouseXMLDropIn verifies that ClickHouse's standard
+// auto-merge of config.d/*.xml works under embedded-clickhouse: we drop a
+// custom user, profile, and a server-level setting into config.d/, then assert
+// at runtime that all three took effect.
+//
+// embedded-clickhouse generates a single config.xml with defined
+// inline (no separate users.xml), so users.d/ is ignored. Drop-ins go in
+// config.d/, where ClickHouse merges them into the main config — including
+// and elements.
+func TestEmbeddedClickHouseXMLDropIn(t *testing.T) {
+ t.Parallel()
+ if testing.Short() {
+ t.Skip("skipping CH XML drop-in test in short mode")
+ }
+
+ const customXML = `
+
+ 4242
+
+
+ secret123
+
+ ::/0
+
+ tester_profile
+ default
+
+
+
+
+ 123456789
+
+
+
+`
+ chConfig := setupEmbeddedClickHouseUnseeded(t,
+ withConfigDropIn(customXML),
+ )
+
+ dsn := "http://tester:secret123@" + chConfig.Host + ":" + portString(chConfig.Port) + "/default"
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ db, err := sql.Open("clickhouse", dsn)
+ require.NoError(t, err, "DSN: %s", dsn)
+ t.Cleanup(func() { _ = db.Close() })
+
+ t.Run("custom_user_authenticates", func(t *testing.T) {
+ var who string
+ require.NoError(t, db.QueryRowContext(ctx, "SELECT currentUser()").Scan(&who),
+ "the users.d drop-in must have created the user; auth must succeed")
+ require.Equal(t, "tester", who)
+ })
+
+ t.Run("custom_profile_setting_applied", func(t *testing.T) {
+ var maxMem string
+ require.NoError(t, db.QueryRowContext(ctx, "SELECT getSetting('max_memory_usage')").Scan(&maxMem),
+ "the profile we attached to the custom user via config.d must apply")
+ require.Equal(t, "123456789", maxMem,
+ "max_memory_usage from tester_profile in config.d must be honored")
+ })
+
+ t.Run("config_d_server_setting_applied", func(t *testing.T) {
+ var rows int
+ require.NoError(t, db.QueryRowContext(ctx,
+ "SELECT count() FROM system.server_settings WHERE name = 'max_connections' AND value = '4242'",
+ ).Scan(&rows),
+ "the config.d drop-in must have set max_connections=4242 server-wide")
+ require.Equal(t, 1, rows,
+ "system.server_settings should report max_connections=4242 from our drop-in")
+ })
+}
diff --git a/pkg/server/oauth_e2e_test.go b/pkg/server/oauth_e2e_test.go
index 4482562..b75dda4 100644
--- a/pkg/server/oauth_e2e_test.go
+++ b/pkg/server/oauth_e2e_test.go
@@ -11,25 +11,27 @@ import (
"net/http"
"net/http/httptest"
"net/url"
- "os"
"strings"
"testing"
"time"
+ "github.com/altinity/altinity-mcp/internal/testutil/embeddedch"
"github.com/altinity/altinity-mcp/pkg/config"
"github.com/go-jose/go-jose/v4"
- "github.com/moby/moby/api/types/container"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
)
// TestOAuthE2EWithMockOIDC is an end-to-end test that validates the full OAuth2 flow
// through real MCP client and OpenAPI endpoints:
-// 1. A lightweight mock OIDC provider (in-process Go HTTP server)
-// 2. ClickHouse (Antalya build) with token_processors for JWT auth
-// 3. MCP server forwarding Bearer tokens to ClickHouse
+//
+// 1. A lightweight mock OIDC provider (in-process Go HTTP server bound to 127.0.0.1)
+// 2. Altinity Antalya ClickHouse with token_processors for JWT auth, run as a host
+// subprocess via embedded-clickhouse + an extracted Antalya binary
+// 3. MCP server forwarding Bearer tokens to ClickHouse
+//
+// Antalya is Linux-only (no darwin binaries published), so this test
+// auto-skips on non-Linux hosts via ensureAntalyaBinary. CI runs it on Linux.
func TestOAuthE2EWithMockOIDC(t *testing.T) {
t.Parallel()
if testing.Short() {
@@ -38,29 +40,32 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
ctx := context.Background()
- // ---------- Step 1: Start mock OIDC provider ----------
- provider, dockerOIDCURL := newTestOAuthProviderReachableFromDocker(t, nil)
- t.Logf("Mock OIDC provider URL (Docker): %s", dockerOIDCURL)
+ // Step 1: mock OIDC provider on 127.0.0.1. Use the full-discovery variant
+ // because Antalya's token_processors parser requires introspection_endpoint
+ // (or userinfo_endpoint) plus the standard required OIDC fields — without
+ // them it logs "Cannot extract userinfo_endpoint or introspection_endpoint
+ // from OIDC configuration" and silently disables the processor.
+ provider := newAntalyaOIDCProvider(t, nil)
+ oidcURL := provider.server.URL
+ t.Logf("Mock OIDC provider URL: %s", oidcURL)
- // ---------- Step 2: Start ClickHouse with token_processors ----------
- chConfig := setupAntalyaClickHouseWithOIDC(t, ctx, dockerOIDCURL)
+ // Step 2: Antalya ClickHouse via embedded-clickhouse, configured for OIDC.
+ chConfig := setupEmbeddedAntalyaWithOIDC(t, oidcURL)
- // ---------- Step 3: Issue a signed JWT ----------
+ // Step 3: issue a signed JWT to use as the Bearer.
const tokenSubject = "test-oauth-user"
token := provider.issueJWT(t, map[string]interface{}{
"sub": tokenSubject,
- "iss": dockerOIDCURL,
+ "iss": oidcURL,
"aud": "test-audience",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
})
require.NotEmpty(t, token, "OAuth token should not be empty")
- // Verify it looks like a JWT
parts := strings.Split(token, ".")
require.Equal(t, 3, len(parts), "Token should be a JWT with 3 parts")
- // ---------- Step 4: Test via MCP Client (InMemoryTransports) ----------
t.Run("MCP_Client", func(t *testing.T) {
t.Parallel()
srv := NewClickHouseMCPServer(config.Config{
@@ -73,26 +78,25 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
},
}, "test-e2e")
- // Create in-memory transports
clientTransport, serverTransport := mcp.NewInMemoryTransports()
- // Connect server with context containing the server instance and OAuth token
srvCtx := context.WithValue(ctx, CHJWEServerKey, srv)
srvCtx = context.WithValue(srvCtx, OAuthTokenKey, token)
serverSession, err := srv.MCPServer.Connect(srvCtx, serverTransport, nil)
require.NoError(t, err, "Server connect should succeed")
- defer serverSession.Close()
+ defer func() {
+ require.NoError(t, serverSession.Close())
+ }()
- // Connect MCP client
mcpClient := mcp.NewClient(
&mcp.Implementation{Name: "test-oauth-client", Version: "v0.0.1"}, nil,
)
clientSession, err := mcpClient.Connect(ctx, clientTransport, nil)
require.NoError(t, err, "Client connect should succeed")
- defer clientSession.Close()
+ defer func() {
+ require.NoError(t, clientSession.Close())
+ }()
- // NOTE: MCP subtests are NOT parallel — they share clientSession
- // 4a. ListTools — verify execute_query is registered
t.Run("ListTools", func(t *testing.T) {
toolsResult, err := clientSession.ListTools(ctx, nil)
require.NoError(t, err)
@@ -103,10 +107,8 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
toolNames = append(toolNames, tool.Name)
}
require.Contains(t, toolNames, "execute_query", "execute_query tool should be registered")
- t.Logf("Listed tools: %v", toolNames)
})
- // 4b. CallTool(execute_query) — query should reach ClickHouse via OAuth Bearer token
t.Run("CallTool_ExecuteQuery", func(t *testing.T) {
res, err := clientSession.CallTool(ctx, &mcp.CallToolParams{
Name: "execute_query",
@@ -120,9 +122,7 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
textContent, ok := res.Content[0].(*mcp.TextContent)
require.True(t, ok, "Content should be TextContent")
require.NotEmpty(t, textContent.Text)
- t.Logf("execute_query result: %s", textContent.Text)
- // Parse the JSON result and verify it has rows
var queryResult map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &queryResult))
rows, ok := queryResult["rows"].([]interface{})
@@ -131,7 +131,6 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
require.Equal(t, tokenSubject, firstStringCell(t, rows))
})
- // 4c. ListResources — verify clickhouse://schema is registered
t.Run("ListResources", func(t *testing.T) {
resourcesResult, err := clientSession.ListResources(ctx, nil)
require.NoError(t, err)
@@ -142,10 +141,8 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
resourceURIs = append(resourceURIs, r.URI)
}
require.Contains(t, resourceURIs, "clickhouse://schema", "Schema resource should be registered")
- t.Logf("Listed resources: %v", resourceURIs)
})
- // 4d. ReadResource(clickhouse://schema) — should return schema via OAuth
t.Run("ReadResource_Schema", func(t *testing.T) {
res, err := clientSession.ReadResource(ctx, &mcp.ReadResourceParams{
URI: "clickhouse://schema",
@@ -154,11 +151,9 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
require.NotNil(t, res)
require.Greater(t, len(res.Contents), 0, "Should have contents")
require.NotEmpty(t, res.Contents[0].Text, "Schema should not be empty")
- t.Logf("Schema resource (first 200 chars): %.200s...", res.Contents[0].Text)
})
})
- // ---------- Step 5: Test via OpenAPI Client (httptest) ----------
t.Run("OpenAPI_Client", func(t *testing.T) {
t.Parallel()
srv := NewClickHouseMCPServer(config.Config{
@@ -171,14 +166,12 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
},
}, "test-e2e")
- // 5a. Execute query via OpenAPI with Bearer token
t.Run("ExecuteQuery", func(t *testing.T) {
t.Parallel()
query := url.QueryEscape("SELECT currentUser() AS user, 1 AS ok")
req := httptest.NewRequest(http.MethodGet, "/openapi/execute_query?query="+query, nil)
req.Header.Set("Authorization", "Bearer "+token)
- reqCtx := context.WithValue(req.Context(), CHJWEServerKey, srv)
- req = req.WithContext(reqCtx)
+ req = req.WithContext(context.WithValue(req.Context(), CHJWEServerKey, srv))
rr := httptest.NewRecorder()
srv.OpenAPIHandler(rr, req)
@@ -191,31 +184,26 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
require.True(t, ok, "Result should have Rows")
require.Greater(t, len(rows), 0, "Should have at least one row")
require.Equal(t, tokenSubject, firstStringCell(t, rows))
- t.Logf("OpenAPI result: %s", rr.Body.String())
})
- // 5b. OpenAPI schema endpoint should work
t.Run("OpenAPISchema", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/openapi", nil)
req.Header.Set("Authorization", "Bearer "+token)
- reqCtx := context.WithValue(req.Context(), CHJWEServerKey, srv)
- req = req.WithContext(reqCtx)
+ req = req.WithContext(context.WithValue(req.Context(), CHJWEServerKey, srv))
rr := httptest.NewRecorder()
srv.OpenAPIHandler(rr, req)
require.Equal(t, http.StatusOK, rr.Code, "OpenAPI schema should return 200")
require.Contains(t, rr.Body.String(), "execute_query", "Schema should contain execute_query")
- t.Logf("OpenAPI schema (first 200 chars): %.200s...", rr.Body.String())
})
t.Run("ExecuteQuery_MissingBearerToken", func(t *testing.T) {
t.Parallel()
query := url.QueryEscape("SELECT currentUser() AS user")
req := httptest.NewRequest(http.MethodGet, "/openapi/execute_query?query="+query, nil)
- reqCtx := context.WithValue(req.Context(), CHJWEServerKey, srv)
- req = req.WithContext(reqCtx)
+ req = req.WithContext(context.WithValue(req.Context(), CHJWEServerKey, srv))
rr := httptest.NewRecorder()
srv.OpenAPIHandler(rr, req)
@@ -235,8 +223,7 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
"exp": time.Now().Add(10 * time.Minute).Unix(),
"scope": "openid",
}))
- reqCtx := context.WithValue(req.Context(), CHJWEServerKey, srv)
- req = req.WithContext(reqCtx)
+ req = req.WithContext(context.WithValue(req.Context(), CHJWEServerKey, srv))
rr := httptest.NewRecorder()
srv.OpenAPIHandler(rr, req)
@@ -248,96 +235,98 @@ func TestOAuthE2EWithMockOIDC(t *testing.T) {
})
}
-// ---------- Helper functions ----------
-
-func generateClickHouseStartupScriptsConfig() string {
- return `
-
-
-
- CREATE ROLE OR REPLACE 'default_role'
-
-
- GRANT SELECT ON *.* TO 'default_role'
-
-
-
-`
-}
-
-func extractJWTStringClaim(t *testing.T, token, claim string) string {
- t.Helper()
-
- parts := strings.Split(token, ".")
- require.Len(t, parts, 3, "token should have three JWT parts")
-
- payload, err := base64.RawURLEncoding.DecodeString(parts[1])
- require.NoError(t, err)
-
- var claims map[string]any
- require.NoError(t, json.Unmarshal(payload, &claims))
-
- value, ok := claims[claim].(string)
- require.True(t, ok, "token should include string %q claim", claim)
-
- return value
-}
-
-func firstStringCell(t *testing.T, rows []interface{}) string {
+// setupEmbeddedAntalyaWithOIDC boots an Antalya ClickHouse server as a host
+// subprocess via embedded-clickhouse, configured with a token_processors
+// drop-in pointing at the supplied OIDC discovery URL plus a startup_scripts
+// drop-in that creates the default_role used by the token user_directory.
+//
+// Auto-skips on non-Linux hosts because Antalya only ships Linux binaries.
+func setupEmbeddedAntalyaWithOIDC(t *testing.T, oidcDiscoveryURL string) config.ClickHouseConfig {
t.Helper()
- require.NotEmpty(t, rows, "expected at least one row")
-
- row, ok := rows[0].([]interface{})
- require.True(t, ok, "expected row to be an array")
- require.NotEmpty(t, row, "expected row to have at least one column")
-
- value, ok := row[0].(string)
- require.True(t, ok, "expected first column to be a string")
-
- return value
-}
+ // Antalya's needs a writable for
+ // runtime user storage. The Antalya container shipped with
+ // /var/lib/clickhouse/access/, but the host process running as the CI
+ // user can't create that path. Use a host-writable temp dir.
+ accessDir := t.TempDir()
-func generateUnsignedJWT(t *testing.T, claims map[string]any) string {
- t.Helper()
+ tokenProcessorXML := fmt.Sprintf(`
+
+
+
+ openid
+ %s/.well-known/openid-configuration
+ 60
+
+
+
+
+ users.xml
+
+
+ %s
+
+
+ test_oidc
+
+
+
+
+
+
+`, oidcDiscoveryURL, accessDir)
- headerJSON, err := json.Marshal(map[string]string{
- "alg": "none",
- "typ": "JWT",
- })
- require.NoError(t, err)
+ startupScriptsXML := generateClickHouseStartupScriptsConfig()
- payloadJSON, err := json.Marshal(claims)
- require.NoError(t, err)
+ // The token_processor.xml above declares with a
+ // element pointing at users.xml. The Antalya Docker image
+ // ships /etc/clickhouse-server/users.xml; embedded-clickhouse does not.
+ // Without it, ClickHouse fails startup with CANNOT_LOAD_CONFIG. Provide
+ // a minimal users.xml so the path resolves; the actual users we care
+ // about come from the OIDC token user_directory at runtime.
+ const usersXML = `
+
+
+
+
+ ::/0
+ default
+ default
+ 1
+
+
+
+
+
+`
- return fmt.Sprintf(
- "%s.%s.",
- base64.RawURLEncoding.EncodeToString(headerJSON),
- base64.RawURLEncoding.EncodeToString(payloadJSON),
+ cfg := setupEmbeddedClickHouseUnseeded(t,
+ withFlavor(flavorAntalya),
+ withConfigDropIn(tokenProcessorXML),
+ withConfigDropIn(startupScriptsXML),
+ embeddedch.WithUsersXML(usersXML),
)
+ return *cfg
}
-// getDockerHostIP returns the hostname reachable from Docker containers.
-// We always return "host.docker.internal" and rely on the ExtraHosts container setting
-// (host.docker.internal:host-gateway) so it resolves correctly on both macOS and Linux.
-func getDockerHostIP() string {
- return "host.docker.internal"
-}
-
-// newTestOAuthProviderReachableFromDocker creates an OIDC provider bound to 0.0.0.0
-// so Docker containers can reach it. Returns (provider, dockerAccessURL).
+// newAntalyaOIDCProvider serves the full OIDC discovery document Antalya's
+// token_processors parser requires (issuer + auth/token/jwks/userinfo/
+// introspection endpoints + response_types + subject_types + id_token_signing
+// algs). The shorter doc returned by newTestOAuthProvider is enough for
+// forward-mode bearer-passthrough tests that never look at it — Antalya
+// rejects it with "Cannot extract userinfo_endpoint or introspection_endpoint".
//
-// The discovery document, JWKS, and userinfo endpoints all use dockerURL directly,
-// so ClickHouse containers can reach all endpoints without any forwarding.
-func newTestOAuthProviderReachableFromDocker(t *testing.T, userInfoClaims map[string]interface{}) (*testOAuthProvider, string) {
+// Uses an explicit net.Listen + http.Server.Serve loop instead of
+// httptest.NewServer so we control Content-Length / chunked-vs-fixed framing
+// exactly as the original docker-reachable variant did. Antalya's HTTP
+// client occasionally drops the trailing bytes when chunked encoding kicks
+// in mid-response on small bodies.
+func newAntalyaOIDCProvider(t *testing.T, userInfoClaims map[string]interface{}) *testOAuthProvider {
t.Helper()
-
- // Bind to all interfaces so Docker containers can reach us.
- ln, err := net.Listen("tcp", "0.0.0.0:0")
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
- dockerURL := fmt.Sprintf("http://%s:%d", getDockerHostIP(), port)
- localURL := fmt.Sprintf("http://127.0.0.1:%d", port) // for host-side access in tests
+ oidcProviderURL := fmt.Sprintf("http://127.0.0.1:%d", port)
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
@@ -351,23 +340,17 @@ func newTestOAuthProviderReachableFromDocker(t *testing.T, userInfoClaims map[st
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
- // Antalya CH requires a complete OIDC discovery document with introspection_endpoint
- // (or userinfo_endpoint) plus the standard required OIDC fields. Without them CH
- // silently ignores the document and logs "Cannot extract userinfo_endpoint or
- // introspection_endpoint from OIDC configuration". Content-Length must be set
- // explicitly so CH's HTTP client receives the full body over the OrbStack NAT.
- doc := map[string]interface{}{
- "issuer": dockerURL,
- "authorization_endpoint": dockerURL + "/auth",
- "token_endpoint": dockerURL + "/token",
- "jwks_uri": dockerURL + "/jwks",
- "userinfo_endpoint": dockerURL + "/userinfo",
- "introspection_endpoint": dockerURL + "/introspect",
+ body, _ := json.Marshal(map[string]interface{}{
+ "issuer": oidcProviderURL,
+ "authorization_endpoint": oidcProviderURL + "/auth",
+ "token_endpoint": oidcProviderURL + "/token",
+ "jwks_uri": oidcProviderURL + "/jwks",
+ "userinfo_endpoint": oidcProviderURL + "/userinfo",
+ "introspection_endpoint": oidcProviderURL + "/introspect",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
- }
- body, _ := json.Marshal(doc)
+ })
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
w.WriteHeader(200)
@@ -402,117 +385,70 @@ func newTestOAuthProviderReachableFromDocker(t *testing.T, userInfoClaims map[st
_ = json.NewEncoder(w).Encode(provider.userInfoClaims)
})
- // Use plain http.Serve (not httptest) — httptest replaces the listener
- // in a way that breaks 0.0.0.0 binding needed for Docker-to-host connectivity.
httpSrv := &http.Server{Handler: mux}
go func() { _ = httpSrv.Serve(ln) }()
- time.Sleep(50 * time.Millisecond) // ensure goroutine is scheduled before container starts
+ time.Sleep(50 * time.Millisecond)
t.Cleanup(func() {
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutCtx)
})
- // Set a stub server so provider.server.URL works in test verification code.
stub := httptest.NewUnstartedServer(nil)
- stub.Listener.Close() // free the auto-created listener immediately
- stub.URL = localURL
+ _ = stub.Listener.Close()
+ stub.URL = oidcProviderURL
provider.server = stub
- return provider, dockerURL
+ return provider
}
-// setupAntalyaClickHouseWithOIDC starts an Antalya ClickHouse container with token_processors
-// pointing at the given OIDC discovery URL.
-// On Linux, host.docker.internal is resolved via ExtraHosts (host-gateway).
-// On macOS/Docker Desktop/OrbStack it resolves automatically.
-func setupAntalyaClickHouseWithOIDC(t *testing.T, ctx context.Context, oidcDiscoveryURL string) config.ClickHouseConfig {
- t.Helper()
-
- tokenProcessorXML := fmt.Sprintf(`
+// generateClickHouseStartupScriptsConfig returns the XML drop-in that
+// pre-creates the default_role granted to OIDC-authenticated users.
+func generateClickHouseStartupScriptsConfig() string {
+ return `
-
-
- openid
- %s/.well-known/openid-configuration
- 60
-
-
-
-
- users.xml
-
-
- /var/lib/clickhouse/access/
-
-
- test_oidc
-
-
-
-
-
+
+
+ CREATE ROLE OR REPLACE 'default_role'
+
+
+ GRANT SELECT ON *.* TO 'default_role'
+
+
-`, oidcDiscoveryURL)
- startupScriptsXML := generateClickHouseStartupScriptsConfig()
+`
+}
- tmpDir := t.TempDir()
- tokenProcessorFile := tmpDir + "/token_processor.xml"
- require.NoError(t, os.WriteFile(tokenProcessorFile, []byte(tokenProcessorXML), 0644))
- startupScriptsFile := tmpDir + "/startup_scripts.xml"
- require.NoError(t, os.WriteFile(startupScriptsFile, []byte(startupScriptsXML), 0644))
-
- // Inject CH config via read-only bind mounts rather than testcontainers'
- // File-stream-via-archive path: PUT /containers/{id}/archive is blocked
- // in some sandboxes (e.g. our agent isolator). Bind mounts go through
- // container create and avoid the archive endpoint entirely.
- req := testcontainers.ContainerRequest{
- Image: "altinity/clickhouse-server:25.8.16.20001.altinityantalya",
- ExposedPorts: []string{"8123/tcp", "9000/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- "CLICKHOUSE_DB": "default",
- "CLICKHOUSE_USER": "default",
- "CLICKHOUSE_PASSWORD": "",
- "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
- },
- HostConfigModifier: func(hc *container.HostConfig) {
- hc.ExtraHosts = append(hc.ExtraHosts, "host.docker.internal:host-gateway")
- hc.Binds = append(hc.Binds,
- tokenProcessorFile+":/etc/clickhouse-server/config.d/token_processor.xml:ro",
- startupScriptsFile+":/etc/clickhouse-server/config.d/startup_scripts.xml:ro",
- )
- },
- WaitingFor: wait.ForHTTP("/").WithPort("8123/tcp").
- WithStartupTimeout(120 * time.Second).WithPollInterval(2 * time.Second),
- }
+func firstStringCell(t *testing.T, rows []interface{}) string {
+ t.Helper()
+
+ require.NotEmpty(t, rows, "expected at least one row")
+
+ row, ok := rows[0].([]interface{})
+ require.True(t, ok, "expected row to be an array")
+ require.NotEmpty(t, row, "expected row to have at least one column")
+
+ value, ok := row[0].(string)
+ require.True(t, ok, "expected first column to be a string")
+
+ return value
+}
+
+func generateUnsignedJWT(t *testing.T, claims map[string]any) string {
+ t.Helper()
- ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
+ headerJSON, err := json.Marshal(map[string]string{
+ "alg": "none",
+ "typ": "JWT",
})
require.NoError(t, err)
- t.Cleanup(func() {
- cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- _ = ctr.Terminate(cleanupCtx)
- })
- host, err := ctr.Host(ctx)
- require.NoError(t, err)
- httpPort, err := ctr.MappedPort(ctx, "8123")
+ payloadJSON, err := json.Marshal(claims)
require.NoError(t, err)
- t.Logf("Antalya ClickHouse HTTP at %s:%s", host, httpPort.Port())
-
- return config.ClickHouseConfig{
- Host: host,
- Port: int(httpPort.Num()),
- Database: "default",
- Username: "default",
- Password: "",
- Protocol: config.HTTPProtocol,
- ReadOnly: false,
- MaxExecutionTime: 60,
- }
+ return fmt.Sprintf(
+ "%s.%s.",
+ base64.RawURLEncoding.EncodeToString(headerJSON),
+ base64.RawURLEncoding.EncodeToString(payloadJSON),
+ )
}
diff --git a/pkg/server/oauth_gating_embedded_test.go b/pkg/server/oauth_gating_embedded_test.go
new file mode 100644
index 0000000..938c79b
--- /dev/null
+++ b/pkg/server/oauth_gating_embedded_test.go
@@ -0,0 +1,59 @@
+package server
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/altinity/altinity-mcp/pkg/config"
+ "github.com/stretchr/testify/require"
+)
+
+// TestOAuthGatingViaOpenAPI_Embedded mirrors the
+// TestOpenAPIHandlers/combined_auth_oauth_only_via_openapi subtest in
+// server_test.go but uses embedded-clickhouse instead of testcontainers.
+//
+// Gating mode validates the bearer locally (HS256 over the gating secret) and
+// connects to CH with static credentials, so this test exercises the full MCP
+// OAuth path without any Antalya-specific CH-side config.
+func TestOAuthGatingViaOpenAPI_Embedded(t *testing.T) {
+ t.Parallel()
+
+ chConfig := setupEmbeddedClickHouse(t)
+
+ const gatingSecret = "test-gating-secret-32-byte-key!!"
+ srv := NewClickHouseMCPServer(config.Config{
+ ClickHouse: *chConfig,
+ Server: config.ServerConfig{
+ JWE: config.JWEConfig{
+ Enabled: true,
+ JWESecretKey: "this-is-a-32-byte-secret-key!!",
+ JWTSecretKey: "jwt-secret",
+ },
+ OAuth: config.OAuthConfig{
+ Enabled: true,
+ Mode: "gating",
+ GatingSecretKey: gatingSecret,
+ },
+ },
+ }, "test")
+
+ oauthToken := mintSelfIssuedToken(t, gatingSecret, map[string]interface{}{
+ "sub": "user123",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/openapi/execute_query?query=SELECT%201", nil)
+ req.Header.Set("Authorization", "Bearer "+oauthToken)
+ req = req.WithContext(context.WithValue(req.Context(), CHJWEServerKey, srv))
+
+ rr := httptest.NewRecorder()
+ srv.OpenAPIHandler(rr, req)
+
+ require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+ require.True(t, strings.Contains(rr.Body.String(), `"data"`) || strings.Contains(rr.Body.String(), `"rows"`),
+ "response missing data/rows: %s", rr.Body.String())
+}
diff --git a/pkg/server/server_auth_jwe_test.go b/pkg/server/server_auth_jwe_test.go
index 83cd6e1..98cf1c6 100644
--- a/pkg/server/server_auth_jwe_test.go
+++ b/pkg/server/server_auth_jwe_test.go
@@ -16,7 +16,7 @@ import (
func TestJWEAuthentication(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
jweSecretKey := "this-is-a-32-byte-secret-key!!"
jwtSecretKey := "test-jwt-secret-key-123"
diff --git a/pkg/server/server_auth_oauth_test.go b/pkg/server/server_auth_oauth_test.go
index d6a2442..8ac268a 100644
--- a/pkg/server/server_auth_oauth_test.go
+++ b/pkg/server/server_auth_oauth_test.go
@@ -633,7 +633,7 @@ func TestOAuthClearClickHouseCredentials(t *testing.T) {
func TestOAuthAndJWECombined(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
provider := newTestOAuthProvider(t, nil)
jweSecretKey := "this-is-a-32-byte-secret-key!!"
@@ -883,7 +883,7 @@ func TestOAuthAndJWECombined(t *testing.T) {
// TestOAuthOpenAPIHandler tests OpenAPI handler with OAuth authentication
func TestOAuthOpenAPIHandler(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
provider := newTestOAuthProvider(t, nil)
t.Run("oauth_only_valid", func(t *testing.T) {
@@ -1070,7 +1070,7 @@ func TestGetOAuthClaimsFromCtx(t *testing.T) {
func TestGetClickHouseClientWithOAuth(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
t.Run("no_oauth_forwarding", func(t *testing.T) {
t.Parallel()
@@ -1330,7 +1330,7 @@ func TestValidateAuth(t *testing.T) {
func TestOAuthMCPToolExecution(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
provider := newTestOAuthProvider(t, nil)
t.Run("execute_query_with_oauth", func(t *testing.T) {
@@ -1456,21 +1456,24 @@ func TestOAuthMCPToolExecution(t *testing.T) {
// TestOAuthOpenAPIFullFlow tests complete OAuth flow for OpenAPI endpoint
func TestOAuthOpenAPIFullFlow(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
provider := newTestOAuthProvider(t, nil)
t.Run("complete_oauth_openapi_flow", func(t *testing.T) {
t.Parallel()
- ctx := context.Background()
- dockerProvider, dockerOIDCURL := newTestOAuthProviderReachableFromDocker(t, nil)
- dockerChConfig := setupAntalyaClickHouseWithOIDC(t, ctx, dockerOIDCURL)
+ // Antalya is required for token_processors-driven OIDC validation in CH.
+ // Use newAntalyaOIDCProvider (full discovery doc) — Antalya rejects
+ // the shorter doc returned by newTestOAuthProvider.
+ // setupEmbeddedAntalyaWithOIDC auto-skips on non-Linux hosts.
+ oidcProvider := newAntalyaOIDCProvider(t, nil)
+ antalyaCH := setupEmbeddedAntalyaWithOIDC(t, oidcProvider.server.URL)
srv := NewClickHouseMCPServer(config.Config{
- ClickHouse: dockerChConfig,
+ ClickHouse: antalyaCH,
Server: config.ServerConfig{OAuth: config.OAuthConfig{Enabled: true, Mode: "forward"}},
}, "test")
- oauthToken := dockerProvider.issueJWT(t, map[string]interface{}{
+ oauthToken := oidcProvider.issueJWT(t, map[string]interface{}{
"sub": "service-account-123",
- "iss": dockerOIDCURL,
+ "iss": oidcProvider.server.URL,
"exp": time.Now().Add(time.Hour).Unix(),
})
req := httptest.NewRequest(http.MethodGet, "/openapi/execute_query?query=SELECT%20version()%20as%20version", nil)
diff --git a/pkg/server/server_dynamic_tools_test.go b/pkg/server/server_dynamic_tools_test.go
index 4a18040..7f353f7 100644
--- a/pkg/server/server_dynamic_tools_test.go
+++ b/pkg/server/server_dynamic_tools_test.go
@@ -98,7 +98,7 @@ func TestMakeDynamicToolHandler_NoServerInContext(t *testing.T) {
func TestMakeDynamicToolHandler_WithClickHouse(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
// prepare parameterized view
client, err := clickhouse.NewClient(ctx, *chConfig)
@@ -158,7 +158,7 @@ func TestMakeDynamicToolHandler_WithClickHouse(t *testing.T) {
func TestRegisterDynamicTools_SuccessAndOverlap(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
client, err := clickhouse.NewClient(ctx, *chConfig)
require.NoError(t, err)
defer func() { require.NoError(t, client.Close()) }()
@@ -352,7 +352,7 @@ func TestEnsureDynamicTools_NoRules(t *testing.T) {
// TestEnsureDynamicTools_InvalidRegexp tests invalid regexp in rules
func TestEnsureDynamicTools_InvalidRegexp(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -372,7 +372,7 @@ func TestEnsureDynamicTools_InvalidRegexp(t *testing.T) {
// TestEnsureDynamicTools_NamedRuleNoMatch tests named rule that matches no views
func TestEnsureDynamicTools_NamedRuleNoMatch(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -394,7 +394,7 @@ func TestEnsureDynamicTools_NamedRuleNoMatch(t *testing.T) {
func TestEnsureDynamicTools_NamedRuleMultipleMatches(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
client, err := clickhouse.NewClient(ctx, *chConfig)
require.NoError(t, err)
@@ -426,7 +426,7 @@ func TestEnsureDynamicTools_NamedRuleMultipleMatches(t *testing.T) {
// TestMakeDynamicToolHandler_QueryError tests handler when query fails
func TestMakeDynamicToolHandler_QueryError(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := &ClickHouseJWEServer{
Config: config.Config{
@@ -519,7 +519,7 @@ func TestMakeDynamicToolHandler_GetClientError(t *testing.T) {
func TestMakeDynamicToolHandler_WithParams(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
// Create a view with multiple param types
client, err := clickhouse.NewClient(ctx, *chConfig)
@@ -959,7 +959,7 @@ func TestBuildDynamicToolAnnotations(t *testing.T) {
func TestEnsureDynamicToolsE2E(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
t.Run("no_dynamic_tools_config", func(t *testing.T) {
t.Parallel()
diff --git a/pkg/server/server_query_test.go b/pkg/server/server_query_test.go
index 2bbe026..f90e802 100644
--- a/pkg/server/server_query_test.go
+++ b/pkg/server/server_query_test.go
@@ -17,7 +17,7 @@ import (
func TestHandleExecuteQuery(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -227,7 +227,7 @@ func TestHandleExecuteQuery_EmptyQuery(t *testing.T) {
// TestHandleExecuteQuery_ExceedsMaxLimit tests limit exceeding config max
func TestHandleExecuteQuery_ExceedsMaxLimit(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: config.ClickHouseConfig{
@@ -261,7 +261,7 @@ func TestHandleExecuteQuery_ExceedsMaxLimit(t *testing.T) {
// TestHandleExecuteQuery_WithQueryWithExistingLimit tests query already having limit
func TestHandleExecuteQuery_WithQueryWithExistingLimit(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -547,7 +547,7 @@ func TestEffectiveMaxQueryLength(t *testing.T) {
func TestHandleExecuteQueryE2E(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
diff --git a/pkg/server/server_resources_test.go b/pkg/server/server_resources_test.go
index 4b52bb5..757fd8e 100644
--- a/pkg/server/server_resources_test.go
+++ b/pkg/server/server_resources_test.go
@@ -13,7 +13,7 @@ import (
func TestHandleSchemaResource(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -44,7 +44,7 @@ func TestHandleSchemaResource(t *testing.T) {
func TestHandleTableResource(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -133,7 +133,7 @@ func TestHandleTableResource_EmptyDatabaseOrTable(t *testing.T) {
func TestHandleSchemaResourceE2E(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -160,7 +160,7 @@ func TestHandleSchemaResourceE2E_NoServer(t *testing.T) {
func TestHandleTableResourceE2E(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 9a1beb1..aedb65d 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -14,8 +14,6 @@ import (
"github.com/altinity/altinity-mcp/pkg/jwe_auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
)
type captureServer struct {
@@ -52,86 +50,10 @@ func generateJWEToken(t *testing.T, claims map[string]interface{}, jweSecretKey
return token
}
-// setupClickHouseContainer sets up a ClickHouse container for testing.
-func setupClickHouseContainer(t *testing.T) *config.ClickHouseConfig {
- t.Helper()
- ctx := context.Background() // Use background context instead of test context to avoid cancellation issues
-
- totalStart := time.Now()
-
- req := testcontainers.ContainerRequest{
- Image: "clickhouse/clickhouse-server:latest",
- ExposedPorts: []string{"8123/tcp", "9000/tcp"},
- Env: map[string]string{
- "CLICKHOUSE_SKIP_USER_SETUP": "1",
- "CLICKHOUSE_DB": "default",
- "CLICKHOUSE_USER": "default",
- "CLICKHOUSE_PASSWORD": "",
- "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT": "1",
- },
- WaitingFor: wait.ForHTTP("/").WithPort("8123/tcp").WithStartupTimeout(30 * time.Second).WithPollInterval(2 * time.Second),
- }
- containerStart := time.Now()
- chContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: req,
- Started: true,
- })
- containerElapsed := time.Since(containerStart)
- require.NoError(t, err)
-
- t.Cleanup(func() {
- cleanupStart := time.Now()
- if err := chContainer.Terminate(context.Background()); err != nil {
- t.Logf("Failed to terminate container: %v", err)
- }
- t.Logf("[container/%s] cleanup took %s", req.Image, time.Since(cleanupStart))
- })
-
- host, err := chContainer.Host(ctx)
- require.NoError(t, err)
-
- port, err := chContainer.MappedPort(ctx, "8123")
- require.NoError(t, err)
-
- chConfig := &config.ClickHouseConfig{
- Host: host,
- Port: int(port.Num()),
- Database: "default",
- Username: "default",
- Protocol: config.HTTPProtocol,
- }
-
- // Create a client to test the connection and create test tables
- setupStart := time.Now()
- client, err := clickhouse.NewClient(ctx, *chConfig)
- require.NoError(t, err)
-
- // Create a test table
- _, err = client.ExecuteQuery(ctx, `CREATE TABLE IF NOT EXISTS default.test (
- id UInt64,
- name String,
- created_at DateTime
- ) ENGINE = MergeTree() ORDER BY id`)
- require.NoError(t, err)
-
- // Insert some test data
- _, err = client.ExecuteQuery(ctx, `INSERT INTO default.test VALUES (1, 'test1', now()), (2, 'test2', now())`)
- require.NoError(t, err)
-
- // Close the client after setup
- err = client.Close()
- require.NoError(t, err)
- setupElapsed := time.Since(setupStart)
-
- t.Logf("[container/%s] start=%s setup=%s total=%s", req.Image, containerElapsed, setupElapsed, time.Since(totalStart))
-
- return chConfig
-}
-
// TestOpenAPIHandlers tests the OpenAPI handlers
func TestOpenAPIHandlers(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
t.Run("serves_openapi_schema", func(t *testing.T) {
t.Parallel()
@@ -542,7 +464,7 @@ func TestOpenAPI_SchemaIncludesCombinedAuthPaths(t *testing.T) {
func TestHandleDynamicToolOpenAPI_PostExecutes(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
client, err := clickhouse.NewClient(ctx, *chConfig)
require.NoError(t, err)
defer func() { require.NoError(t, client.Close()) }()
@@ -636,7 +558,7 @@ func TestHandleDynamicToolOpenAPI_Errors(t *testing.T) {
func TestLazyLoading_OpenAPISchema(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
// Create view
client, err := clickhouse.NewClient(ctx, *chConfig)
@@ -692,7 +614,7 @@ func TestLazyLoading_OpenAPISchema(t *testing.T) {
func TestLazyLoading_MCPTools(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
// Create view
client, err := clickhouse.NewClient(ctx, *chConfig)
@@ -791,7 +713,7 @@ func TestAddPrompt(t *testing.T) {
func TestHandleExecuteQueryOpenAPI_MethodNotAllowed(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -810,7 +732,7 @@ func TestHandleExecuteQueryOpenAPI_MethodNotAllowed(t *testing.T) {
// TestHandleExecuteQueryOpenAPI_InvalidLimit tests invalid limit parameter
func TestHandleExecuteQueryOpenAPI_InvalidLimit(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -854,7 +776,7 @@ func TestHandleExecuteQueryOpenAPI_InvalidLimit(t *testing.T) {
// TestHandleExecuteQueryOpenAPI_ExceedsMaxLimit tests limit exceeding max
func TestHandleExecuteQueryOpenAPI_ExceedsMaxLimit(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: config.ClickHouseConfig{
@@ -900,7 +822,7 @@ func TestHandleDynamicToolOpenAPI_MethodNotAllowed(t *testing.T) {
// TestHandleDynamicToolOpenAPI_MissingRequiredParam tests missing required parameter
func TestHandleDynamicToolOpenAPI_MissingRequiredParam(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := &ClickHouseJWEServer{
Config: config.Config{
@@ -1009,7 +931,7 @@ func TestOpenAPIHandler_InvalidJWEToken(t *testing.T) {
// TestHandleDynamicToolOpenAPI_QueryError tests query execution failure
func TestHandleDynamicToolOpenAPI_QueryError(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := &ClickHouseJWEServer{
Config: config.Config{
@@ -1042,7 +964,7 @@ func TestHandleDynamicToolOpenAPI_QueryError(t *testing.T) {
// TestHandleExecuteQueryOpenAPI_QueryError tests query execution failure
func TestHandleExecuteQueryOpenAPI_QueryError(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -1062,7 +984,7 @@ func TestHandleExecuteQueryOpenAPI_QueryError(t *testing.T) {
// TestHandleExecuteQueryOpenAPI_NonSelectWithLimit tests limit on non-select query
func TestHandleExecuteQueryOpenAPI_NonSelectWithLimit(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -1083,7 +1005,7 @@ func TestHandleExecuteQueryOpenAPI_NonSelectWithLimit(t *testing.T) {
func TestHandleDynamicToolOpenAPI_WithOptionalParams(t *testing.T) {
t.Parallel()
ctx := context.Background()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
// Create a simple view
client, err := clickhouse.NewClient(ctx, *chConfig)
@@ -1126,7 +1048,7 @@ func TestHandleDynamicToolOpenAPI_WithOptionalParams(t *testing.T) {
func TestOpenAPIHandlerE2E(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
srv := NewClickHouseMCPServer(config.Config{
ClickHouse: *chConfig,
@@ -1163,7 +1085,7 @@ func TestOpenAPIHandlerE2E(t *testing.T) {
func TestGetClickHouseClientWithOAuthE2E(t *testing.T) {
t.Parallel()
- chConfig := setupClickHouseContainer(t)
+ chConfig := setupEmbeddedClickHouse(t)
t.Run("default_config_no_oauth", func(t *testing.T) {
t.Parallel()