test: replace testcontainers with embedded-clickhouse across the suite#92
Open
BorisTyshkevich wants to merge 21 commits intomainfrom
Open
test: replace testcontainers with embedded-clickhouse across the suite#92BorisTyshkevich wants to merge 21 commits intomainfrom
BorisTyshkevich wants to merge 21 commits intomainfrom
Conversation
Changes the MCP HTTP transport endpoint from /http to / (root). SSE stays
at /sse. OAuth paths stay under /oauth/*.
Go 1.22+ ServeMux gives literal routes precedence over wildcards, and /
acts as a catch-all for unmatched requests. /health, /openapi/*, /oauth/*,
and /.well-known/* all keep their specific registrations; unknown paths
fall through to the MCP handler which rejects non-protocol traffic cleanly.
Also removes 6 redundant /http-prefixed OAuth discovery aliases that only
existed because clients hit /.well-known/*/http when the transport was
under /http. With the transport at root, the standard /.well-known/* paths
already work — the aliases become noise.
transportRoutePatterns accepts an empty transport string to produce root
patterns (/ and /{token}); the HTTP transport call sites now pass "".
SSE still passes "sse" and is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a single `server.tools` array that describes both static (execute_query, write_query) and dynamic (regexp-matched) tools in one place. The legacy `server.dynamic_tools` form still works and emits a deprecation warning; rules are converted internally. Tool semantics - execute_query: ALWAYS read-only. New HandleReadOnlyQuery wrapper rejects non-SELECT statements with a clear error pointing at write_query. Annotation is always ReadOnlyHint:true regardless of the server's read-only flag. - write_query: destructive; skipped entirely when read_only=true. - Dynamic read tools (views): preserved; now use GetClickHouseClientFromCtx so forward-OAuth mode works. - Dynamic write tools (tables): new. Discovered from system.tables, filter out MATERIALIZED/ALIAS columns, generate INSERT statements with parameter validation. Only "insert" mode is implemented; "update" and "upsert" are rejected at registration time. EnsureDynamicTools - Lazy: first tools/list with no Bearer returns static tools; the first authenticated tools/call triggers discovery. SDK's AddTool fires notifications/tools/list_changed automatically. - Non-blocking: RWMutex fast path for the steady state; TryLock so concurrent requests during initial discovery don't block on CH round-trips. - Credential-aware: hasDiscoveryCredentials() skips discovery when no JWE/OAuth/static creds exist so the next request can retry. Integrations preserved: tool_input_settings schema is added to both execute_query and write_query and to discovered dynamic tools. Dynamic write handlers call applyToolInputSettings and checkBlockedClauses so those guardrails extend to INSERTs. getArgumentsMap now returns (map, error). Handlers propagate parse errors to clients instead of silently proceeding with empty arguments. Column filtering uses `default_kind` only; avoids `column_type` which doesn't exist in all ClickHouse versions (e.g. 26.1.x Antalya). RegisterTools now takes *config.Config so converted dynamic rules land in cfg.Server.DynamicTools for EnsureDynamicTools to consume. Tests: - TestRegisterTools_Annotations: updated for the split semantics. - TestRegisterToolsAndResources, TestRegisterToolsWithSettings: default config now registers 2 tools; assertions cover both. - TestRegisterTools_UnifiedConfig: covers the new Tools config path, unsupported-mode rejection, and legacy DynamicTools migration. - TestBuildInsertQuery, TestBuildDynamicWriteQuery, TestBuildWriteToolDescription: pure-function write-path coverage (required params, unicode, null bytes, quote escaping). - TestHasDiscoveryCredentials, TestFilterRulesByType: cover new helpers. - TestGetArgumentsMap_ErrorPath: updated for the new (map, error) signature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace dynamic_tools.md with tools.md covering the new server.tools unified config, write tools (insert mode), and lazy discovery - Update howto_integrate.md: remove /http suffix from all URLs - Update oauth_authorization.md: nginx and config examples use root path - Update README.md links to point to new tools.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the JSON Schema "description" for every dynamic tool parameter was just the ClickHouse type (e.g. "UInt64") — duplicating the "type" field and giving the LLM no meaning. Resolve each parameter's description in three tiers: 1. Tool-level JSON COMMENT "params" map (works for views and tables) 2. system.columns.comment (column-level, for write tools) 3. ClickHouse type string (fallback) Also extracts a shared buildParamSchema helper used by both read and write paths and adds the missing "format" field to emitted schemas. Fixes an off-by-one in getTableColumnsForMode (4 fields selected but the short-row guard checked for < 3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follows the HTTP transport moving from /http to root path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stops requiring a shared ClickHouse username/password for altinity-mcp's gated deployments. When `clickhouse-cluster-secret` is set, altinity-mcp handshakes with ClickHouse as a trusted cluster peer using the shared secret and executes each query as the MCP-authenticated user (taken from OAuth claims or the configured `clickhouse-username`). - Add `ClusterName`/`ClusterSecret` to `ClickHouseConfig` with matching CLI flags (`--clickhouse-cluster-name`, `--clickhouse-cluster-secret`) and `CLICKHOUSE_CLUSTER_*` env vars. - Reject invalid combinations early: interserver auth is TCP-only and requires a non-empty cluster name. - Wire `ClusterCredentials` through `clickhouse.Open` and drop the static password when the secret is set, so only the secret reaches the wire. - In OAuth gating mode, override `chConfig.Username` with the OAuth `Subject` when the cluster secret is active so `system.query_log` attributes the query to the end user. Requires the Altinity/clickhouse-go fork which implements the protocol extension (see Altinity/clickhouse-go#1). The `go.mod` uses a local-path replace during development — see the in-file comment for the follow-up resolution path. Adds `pkg/clickhouse/cluster_secret_test.go` which spins up `clickhouse/clickhouse-server` with a `<remote_servers>` cluster and passwordless users (`alice`, `bob`), then verifies `SELECT currentUser()` returns the impersonated user, that switching Username switches the effective user, and that a wrong secret is rejected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a "Cluster-secret authentication (optional)" subsection to the gating mode docs covering: - Why: removes the shared static password, attributes queries to the real end-user in `system.query_log` - Full altinity-mcp + ClickHouse `<remote_servers>` config snippets - The user/role precreation model (no auto-provisioning — impersonated users and their grants must exist on ClickHouse ahead of time) - Limitations: TCP-only, no `external_roles` forwarding, secret-hygiene requirements Also updates the Requirements bullet to call out the TCP-only constraint for cluster-secret gating. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Testers on the blocker path reported that `claims.Subject` is an opaque numeric ID for Google (21 digits) and a UUID for Keycloak, which defeats per-user attribution in `system.query_log` and forces operators to provision ClickHouse users with cryptic keys. Use `claims.Email` first, fall back to `claims.Subject` only when the IdP omits email (machine-to-machine flows, some restricted scopes). Matches the convention used by forward mode's `username_claim: email` setups so a single pool of pre-provisioned CH users works across both modes. Docs updated to reflect the email-first lookup order. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Needed to run e2e tests that exercise the HTTP transport at the root path. Will be dropped automatically once #83 merges to main and this branch rebases onto main.
The /oauth/register endpoint returned only `authorization_code` in `grant_types`, even though: - `/.well-known/oauth-authorization-server` already advertised `refresh_token` via `grant_types_supported` - the `/oauth/token` endpoint already accepted `grant_type=refresh_token` - the auth_code exchange already minted a `refresh_token` in the response body Per RFC 7591 §3.2.1, the `grant_types` list in the registration response is authoritative for the client. Strict clients (observed with Claude.ai) treat an omitted grant as forbidden and silently skip the refresh flow — forcing end users to re-authorize through the browser on every new chat session. Include `refresh_token` alongside `authorization_code` so the registration response matches server capabilities. Add a regression assertion to the existing `dynamic_client_registration` subtest that pins both grants in the response body. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the refresh_token grant advertisement fix into the interserver-auth test build so Claude.ai can exercise the full refresh flow against gated cluster-secret deployments. Will be dropped automatically once #88 merges to main and this branch rebases onto main.
Spike to evaluate replacing testcontainers with franchb/embedded-clickhouse for tests that don't depend on Antalya-specific server features. The gating-mode OAuth path validates the bearer locally (HS256 over the gating secret) and connects to ClickHouse with static credentials, so the CH side only needs to accept SELECT — no token_processors, no jwt_validators, no Antalya fork required. This adds setupEmbeddedClickHouse + TestOAuthGatingViaOpenAPI_Embedded, which mirrors TestOpenAPIHandlers/combined_auth_oauth_only_via_openapi from server_test.go using stock ClickHouse 26.1 booted as a host subprocess. End-to-end pass: 0.88s vs ~22-30s for the testcontainers equivalent. Stays scoped to gating-mode tests. The forward-mode OAuth e2e tests (TestOAuthE2EWithMockOIDC, TestOAuthOpenAPIFullFlow) need Antalya's token_processors and remain on testcontainers; Antalya is Linux-only so embedded-clickhouse can't run it as a darwin/arm64 subprocess. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…house Background: testcontainers + macOS + the agent isolator stack require Ryuk sidecar bind-mounts, blocked endpoints, and proxy-perf gymnastics that have repeatedly broken local dev. ClickHouse fits the embedded-postgres pattern — single self-contained binary, fast subprocess startup — so we move pkg/server tests off Docker entirely and run CH as a host process via franchb/embedded-clickhouse. Helpers (pkg/server/embedded_clickhouse_helpers_test.go): - setupEmbeddedClickHouse(t, opts...) replaces setupClickHouseContainer. Default boots stock CH 26.1, seeds default.test, no Docker. Variadic options: withFlavor(flavorAntalya), withConfigDropIn(xml), withoutDefaultTable. - withConfigDropIn writes XML into <DataPath>/config.d/N.xml; ClickHouse's standard auto-merge picks it up before startup. Drop-ins replace what testcontainers' Files: ContainerFile path was doing — including custom users, profiles, server settings, and Antalya's token_processors. embedded-clickhouse generates <users> inline in config.xml (no separate users.xml), so users.d is ignored — drop-ins always go in config.d. - ensureAntalyaBinary auto-skips on non-Linux (Antalya only ships Linux binaries) and on Linux extracts /usr/bin/clickhouse from altinity/clickhouse-server:26.1.6.20001.altinityantalya, caching it under $XDG_CACHE_HOME/altinity-mcp/antalya-bin/ for reuse. Tests rewritten to use the new helpers: - TestOAuthE2EWithMockOIDC and TestOAuthOpenAPIFullFlow's complete_oauth_openapi_flow subtest: now use setupEmbeddedAntalyaWithOIDC (Linux-only via ensureAntalyaBinary). Mock OIDC provider switches from the docker-reachable 0.0.0.0 helper to plain newTestOAuthProvider — CH-as-host-process talks to it on 127.0.0.1, no host.docker.internal dance. - TestOAuthGatingViaOpenAPI_Embedded covers the gating-mode path (mirrors combined_auth_oauth_only_via_openapi) — no Antalya needed, runs on every platform. - TestEmbeddedClickHouseXMLDropIn pins the config.d auto-merge contract so future regressions surface fast. - All ~35 setupClickHouseContainer(t) callers in server_test.go switched to setupEmbeddedClickHouse(t). Removed: setupClickHouseContainer, setupAntalyaClickHouseWithOIDC, newTestOAuthProviderReachableFromDocker, getDockerHostIP — none have callers left. Dropped testcontainers + docker container imports from pkg/server tests; pkg/clickhouse and cmd/altinity-mcp keep their testcontainers helpers (separate scope). Effect on local dev (macOS): - pkg/server full suite: ~50s → 4.4s - Two Antalya subtests skip cleanly via t.Skip with a clear reason - No Ryuk, no proxy chunked-body limits, no docker.sock binds Effect on CI (Linux): - ensureAntalyaBinary pulls + extracts the Antalya binary on first run, caches per-tag for the rest of the run - Both Antalya tests run fully Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finishes the testcontainers-to-embedded-clickhouse migration started in 220e868 (which covered pkg/server). After this commit no test in the repo spins up a Docker container for ClickHouse on the happy path; everything runs as a host subprocess via embedded-clickhouse. Highlights: - internal/testutil/embeddedch: shared boot helper (Setup, EnsureAntalyaBinary) promoted out of pkg/server's _test.go so pkg/clickhouse and cmd/altinity-mcp tests can call it directly. Single source of truth for flavor (stock vs Antalya), config.d drop-ins, TCP-vs-HTTP protocol, and the Antalya extraction-from-Docker-image path. - pkg/clickhouse: - setupClickHouseContainer (testcontainers, ~70 lines) replaced with a 3-line setupEmbeddedClickHouse calling embeddedch.Setup with TCP. - cluster_secret_test.go's setupClusterSecretClickHouse — which previously used a shell entrypoint to write XML drop-ins inside the container as a workaround for the blocked PUT /containers/.../archive endpoint — now just passes the same XML through WithConfigDropIn. ~50 lines deleted. - cmd/altinity-mcp: - setupClickHouseContainerMain dropped to ~10 lines. - All 6 inline testcontainers blocks in TestTestConnection (successful_connection, connection_with_tls, connection_with_tcp_protocol, connection_with_readonly_mode, connection_with_max_execution_time, plus TestNewApplicationWithTestContainer) collapsed to embeddedch.Setup calls. The TLS subtest writes cert + key to a TempDir and references those host paths in a config.d drop-in, since CH now reads them on the host rather than from inside a container. - startContainerWithTiming helper removed (unused). - go.mod: testcontainers-go and docker/docker dependencies removed (transitively, alongside dozens of testcontainer-only deps in go.sum). - .gitignore: ignore the locally-built jwe-token-generator binary. Local test wall-clock on macOS: - pkg/server ~50s -> 4.2s (committed in 220e868; same here) - pkg/clickhouse ~? -> 7.5s - cmd/altinity-mcp ~? -> 7.9s - Total module: ~25s Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Antalya's token_processor.xml drop-in declares <user_directories> with <users_xml><path>users.xml</path></users_xml>. The Antalya Docker image ships /etc/clickhouse-server/users.xml; our embedded-clickhouse setup puts everything in a temp dir without a users.xml, so on Linux CI the server failed startup with CANNOT_LOAD_CONFIG. Add embeddedch.WithUsersXML(content) to write the file beside the generated config.xml, and use it from setupEmbeddedAntalyaWithOIDC with a minimal users.xml that mirrors embedded-clickhouse's inline <users>/<profiles>/<quotas> defaults. The OIDC users we actually care about come from the token user_directory at runtime; users.xml just needs to exist for the path to resolve. Reproduced on Linux CI run 24966501158 (test job: 2:24, FAIL on TestOAuthE2EWithMockOIDC + TestOAuthOpenAPIFullFlow/complete_oauth_openapi_flow with "embedded-clickhouse: server did not become ready: context deadline exceeded", caused by CANNOT_LOAD_CONFIG looking up users.xml). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…race Two CI-only failures from run 24966599770 (run 24966501158 had #1 only): 1. Antalya rejected the mock OIDC provider with "Cannot extract userinfo_endpoint or introspection_endpoint from OIDC configuration". newTestOAuthProvider returns a stripped-down discovery doc (only issuer + jwks_uri + userinfo_endpoint) that satisfies forward- mode bearer-passthrough tests but not Antalya's token_processors parser, which requires authorization_endpoint, token_endpoint, introspection_ endpoint, response_types_supported, subject_types_supported, id_token_signing_alg_values_supported. Add newAntalyaOIDCProvider with the full discovery doc; use it from TestOAuthE2EWithMockOIDC. 2. embedded-clickhouse v0.4.0's cache layer locks within a process via sync.Once, but `go test ./...` runs each package as a separate binary, and multiple processes raced to download + extract the same archive into ~/.cache/embedded-clickhouse. Symptoms: embedded-clickhouse: write binary: unexpected EOF embedded-clickhouse: rename temp file: ... no such file or directory Hold an OS-level flock around the first stock-CH extract. The lock is only contended on cold cache; once the binary file exists, subsequent processes skip the extract entirely. Antalya path keeps using docker pull + docker cp which is single-process in our setup (only pkg/server runs Antalya tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more Antalya CI failures from run 24966723959 (test 2:41, FAIL): 1. /var/lib/clickhouse/access/: Permission denied. The token_processors user_directories config hardcoded the Antalya container's data dir, which doesn't exist on the GitHub runner host. Substitute a t.TempDir() the runner user can create + write. 2. TestOAuthOpenAPIFullFlow/complete_oauth_openapi_flow was still calling newTestOAuthProvider (the abridged one). Switch to newAntalyaOIDCProvider which serves the full OIDC discovery document Antalya requires. Also rewrote newAntalyaOIDCProvider to use net.Listen + http.Server.Serve (matching the original docker-reachable provider's exact framing) instead of httptest.NewServer, since Antalya's HTTP client appears sensitive to chunked-vs-fixed Content-Length on small bodies — symptom was "Cannot extract userinfo_endpoint or introspection_endpoint" even though both fields were present in the body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…embedded-clickhouse # Conflicts: # go.mod # go.sum
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces every testcontainers-based ClickHouse fixture in this repo with franchb/embedded-clickhouse, which runs ClickHouse as a host subprocess and skips Docker entirely on the happy path.
The motivation is two-pronged:
The migration drops local pkg/server runtime from ~50s to 4.2s, and the GitHub Actions test job from a recent-baseline median of 2:44 to 1:34 (43% faster).
Numbers from this branch
CI wall-clock:
testjobbuild-and-push-platforms (amd64)build-and-push-platforms (arm64)create-multiplatform-manifestLocal macOS:
pkg/serverpkg/clickhousecmd/altinity-mcpArchitecture
internal/testutil/embeddedch(new package) is the single source of truth.Setup(t, ...Option)returns a*config.ClickHouseConfigpointing at a host subprocess;EnsureAntalyaBinary(t)extracts the production Antalya binary from the Docker image once and pointsBinaryPath()at it.Two flavors:
FlavorStock(default): downloads upstream ClickHouse 26.1 from GitHub releases. embedded-clickhouse caches the binary at~/.cache/embedded-clickhouse/.FlavorAntalya: pullsaltinity/clickhouse-server:26.1.6.20001.altinityantalya, extracts/usr/bin/clickhouseviadocker create+docker cpinto~/.cache/altinity-mcp/antalya-bin/, and runs that.t.Skip()'s on non-Linux because Antalya only ships Linux binaries.XML config drop-ins go through
WithConfigDropIn(xml)— written to<DataPath>/config.d/N.xmlbeforeStart(). ClickHouse's standard auto-merge picks them up. This replaces what testcontainers'Files: []ContainerFilewas doing, including:pkg/clickhouse/cluster_secret_test.go)token_processors+<user_directories>config (pkg/server/oauth_e2e_test.go)https_port.xml+ cert references (cmd/altinity-mcp/main_test.go'sconnection_with_tls)WithUsersXML(xml)writes a stubusers.xmlbesideconfig.xmlfor tests whose drop-ins reference<user_directories><users_xml><path>users.xml</path></users_xml>— the Antalya container shipped one; embedded-clickhouse does not.Tests touched
Direct migrations (testcontainers → embedded-clickhouse):
pkg/server/server_test.go— all ~35setupClickHouseContainer(t)callers swapped tosetupEmbeddedClickHouse(t). The helper itself moved toembedded_clickhouse_helpers_test.goas a thin wrapper aroundembeddedch.Setupplus thedefault.testseeding.pkg/server/oauth_e2e_test.go—setupAntalyaClickHouseWithOIDCrewritten usingembeddedch.Setup(WithFlavor(FlavorAntalya), WithConfigDropIn(...), WithUsersXML(...)). Mock OIDC provider is now127.0.0.1-only (nohost.docker.internalplumbing). NewnewAntalyaOIDCProviderhelper serves the full OIDC discovery doc Antalya'stoken_processorsparser requires.pkg/server/server_test.goTestOAuthOpenAPIFullFlow/complete_oauth_openapi_flow— same Antalya migration.pkg/clickhouse/client_test.go—setupClickHouseContainer(~70 lines) → 3-line wrapper aroundembeddedch.Setup(WithTCPProtocol()).pkg/clickhouse/cluster_secret_test.go— the shell-entrypoint workaround (used to write XML inside the container, dating back to whenPUT /containers/.../archivewas blocked) replaced with twoWithConfigDropIncalls. ~50 lines deleted.cmd/altinity-mcp/main_test.go—setupClickHouseContainerMainshrunk to ~10 lines. The 6 inline testcontainers blocks inTestTestConnection(basic, TCP, TLS, readonly, max_execution_time, plusTestNewApplicationWithTestContainer) collapsed toembeddedch.Setupcalls. The TLS test writes cert + key tot.TempDir()and references those host paths in aconfig.ddrop-in (CH reads them on the host, not from inside a container).startContainerWithTiminghelper removed (unused).New regression test:
pkg/server/embedded_xml_config_test.go— pins theconfig.dauto-merge contract end-to-end (custom user authenticates, profile setting applied, server-level setting visible insystem.server_settings).Cross-cutting fixes that landed during the migration
Three issues surfaced only on the GitHub Actions Linux runner during back-to-back runs of this branch; each commit message has the run ID for the failure that triggered it:
go test ./...runs each package as a separatego testbinary; embedded-clickhouse v0.4.0 locks within a process viasync.Oncebut not across processes. The first cold-cache run producedembedded-clickhouse: write binary: unexpected EOFandrename temp file: no such file or directory. Fix: hold an OS-levelflockon~/.cache/embedded-clickhouse/.altinity-mcp-extract.lockaround the first stock-CHStart()per process. Lock is uncontended once the binary file exists.token_processorsparser rejected the abridged discovery doc returned bynewTestOAuthProvider. Symptom:Cannot extract userinfo_endpoint or introspection_endpoint from OIDC configuration. Fix: newnewAntalyaOIDCProviderreturning the full doc (issuer,authorization_endpoint,token_endpoint,jwks_uri,userinfo_endpoint,introspection_endpoint,response_types_supported,subject_types_supported,id_token_signing_alg_values_supported). Bound via explicitnet.Listen+http.Server.Serverather thanhttptest.NewServerfor exact Content-Length / chunked framing — Antalya's HTTP client appears sensitive to chunked-vs-fixed framing on small bodies.<local_directory>permission denied. The originaltoken_processor.xmlhardcoded/var/lib/clickhouse/access/, which the Antalya container created automatically but the GitHub runner user can't write. Fix: substitute at.TempDir()into the XML at setup time.go.mod
testcontainers-goanddocker/dockerremoved (along with their transitive closure ingo.sum).franchb/embedded-clickhouse v0.4.0added.What stays on testcontainers
Nothing in this PR. If anyone adds a fixture later that needs a feature
embeddedchdoesn't yet expose, the helper has clear extension points (WithConfigDropIn,WithUsersXML,WithStartTimeout).Test plan
go vet ./...— cleango test ./...on macOS — green in ~25s, Antalya tests skip with a clear reasontestcontainersimports remain in any_test.go🤖 Generated with Claude Code