diff --git a/examples/Elastic.TUnit.ExampleComplex/Clusters.cs b/examples/Elastic.TUnit.ExampleComplex/Clusters.cs index 0be9caa..fcdc9ac 100644 --- a/examples/Elastic.TUnit.ExampleComplex/Clusters.cs +++ b/examples/Elastic.TUnit.ExampleComplex/Clusters.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System; using Elastic.Clients.Elasticsearch; using Elastic.TUnit.Elasticsearch; using Elastic.TUnit.Elasticsearch.Core; @@ -11,6 +12,8 @@ namespace Elastic.TUnit.ExampleComplex; /// /// Shared base — inherits the default Client from . +/// Env-var detection (TEST_ELASTICSEARCH_URL / TEST_ELASTICSEARCH_API_KEY) is handled +/// automatically by the base class — no code changes needed. /// public abstract class MyClusterBase : ElasticsearchCluster { @@ -46,6 +49,10 @@ public TestGenericCluster() : base(new ElasticsearchConfiguration("latest-9")) var settings = new ElasticsearchClientSettings(pool) .EnableDebugMode() .OnRequestCompleted(call => output.WriteLine(call.DebugInformation)); + + if (ExternalApiKey != null) + settings = settings.Authentication(new ApiKey(ExternalApiKey)); + return new ElasticsearchClient(settings); }); @@ -54,3 +61,27 @@ protected override void SeedCluster() var response = Client.Info(); } } + +/// +/// Demonstrates the programmatic hook — override +/// to point at a specific cluster from code (e.g. read from a config file, service +/// discovery, etc.). Falls through to ephemeral startup when returning null. +/// +public class ProgrammaticExternalCluster : ElasticsearchCluster +{ + public ProgrammaticExternalCluster() : base(new ElasticsearchConfiguration("latest-9")) + { + } + + protected override ExternalClusterConfiguration TryUseExternalCluster() + { + var url = Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_URL"); + if (string.IsNullOrEmpty(url)) + return null; + + return new ExternalClusterConfiguration( + new Uri(url), + Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_KEY") + ); + } +} diff --git a/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs b/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs index 990bd1d..d147539 100644 --- a/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs +++ b/src/Elastic.TUnit.Elasticsearch.Core/ElasticsearchCluster{TConfiguration}.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; @@ -21,6 +22,20 @@ namespace Elastic.TUnit.Elasticsearch.Core; /// Base class for an Elasticsearch cluster that integrates with TUnit's lifecycle. /// Implements to start the cluster and /// to tear it down. +/// +/// Supports skipping ephemeral cluster startup when an external cluster is +/// available. The resolution order in is: +/// +/// +/// — override for programmatic control +/// +/// +/// TEST_ELASTICSEARCH_URL environment variable (with optional +/// TEST_ELASTICSEARCH_API_KEY) +/// +/// Start an ephemeral cluster as usual +/// +/// /// public abstract class ElasticsearchCluster : EphemeralCluster, IAsyncInitializer, IAsyncDisposable @@ -35,14 +50,46 @@ public abstract class ElasticsearchCluster : EphemeralCluster internal static ConcurrentDictionary ClusterVersions { get; } = new(); + private ExternalClusterConfiguration _externalCluster; + protected ElasticsearchCluster(TConfiguration configuration) : base(configuration) { } + /// + /// Whether this cluster is backed by an external (remote) Elasticsearch instance + /// rather than a locally managed ephemeral process. + /// + public bool IsExternal => _externalCluster != null; + + /// + /// The API key for the external cluster, or null if not applicable. + /// + public string ExternalApiKey => _externalCluster?.ApiKey; + + /// + /// Override to programmatically provide an external Elasticsearch cluster, + /// skipping ephemeral cluster startup entirely. Return null to fall + /// through to environment variable detection and then ephemeral startup. + /// + protected virtual ExternalClusterConfiguration TryUseExternalCluster() => null; + + /// + /// Returns the node URIs for this cluster. When connected to an external cluster, + /// returns the external URI; otherwise delegates to the ephemeral cluster's nodes. + /// + public override ICollection NodesUris(string hostName = null) => + _externalCluster != null ? [_externalCluster.Uri] : base.NodesUris(hostName); + /// /// Starts the Elasticsearch cluster. Cluster startups are serialized via a /// semaphore since Elasticsearch is resource-intensive. /// + /// Before starting an ephemeral cluster, checks for an external cluster + /// via and then the + /// TEST_ELASTICSEARCH_URL environment variable. + /// + /// /// Bootstrap output mode is determined by /// : /// @@ -57,6 +104,16 @@ protected ElasticsearchCluster(TConfiguration configuration) : base(configuratio /// public async Task InitializeAsync() { + var external = ResolveExternalCluster(); + if (external != null) + { + await external.ValidateAsync().ConfigureAwait(false); + _externalCluster = external; + ClusterVersions[GetType()] = ClusterConfiguration.Version; + WriteExternalClusterInfo(external); + return; + } + await StartupSemaphore.WaitAsync().ConfigureAwait(false); try { @@ -87,11 +144,12 @@ public async Task InitializeAsync() } /// - /// Disposes the Elasticsearch cluster. + /// Disposes the Elasticsearch cluster. No-op when using an external cluster. /// public ValueTask DisposeAsync() { - Dispose(); + if (_externalCluster == null) + Dispose(); return default; } @@ -111,6 +169,35 @@ private IConsoleLineHandler CreateBootstrapWriter() }; } + private ExternalClusterConfiguration ResolveExternalCluster() + { + var programmatic = TryUseExternalCluster(); + if (programmatic != null) + return programmatic; + + var url = Environment.GetEnvironmentVariable("TEST_ELASTICSEARCH_URL"); + if (string.IsNullOrWhiteSpace(url)) + return null; + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + throw new InvalidOperationException( + $"TEST_ELASTICSEARCH_URL is set but is not a valid URI: {url}"); + + var apiKey = Environment.GetEnvironmentVariable("TEST_ELASTICSEARCH_API_KEY"); + return new ExternalClusterConfiguration(uri, string.IsNullOrWhiteSpace(apiKey) ? null : apiKey); + } + + private void WriteExternalClusterInfo(ExternalClusterConfiguration external) + { + var name = GetType().Name; + var auth = string.IsNullOrEmpty(external.ApiKey) ? "no auth" : "API key"; + var message = $"[{name}] using external cluster at {external.Uri} ({auth}) — skipping ephemeral startup"; + + using var stdout = new System.IO.StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }; + stdout.WriteLine(message); + TestContext.Current?.Output.WriteLine(message); + } + private void WriteBootstrapFailure(Exception ex) { var sb = new StringBuilder(); diff --git a/src/Elastic.TUnit.Elasticsearch.Core/ExternalClusterConfiguration.cs b/src/Elastic.TUnit.Elasticsearch.Core/ExternalClusterConfiguration.cs new file mode 100644 index 0000000..2866f6c --- /dev/null +++ b/src/Elastic.TUnit.Elasticsearch.Core/ExternalClusterConfiguration.cs @@ -0,0 +1,57 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Elastic.TUnit.Elasticsearch.Core; + +/// +/// Connection details for an externally managed Elasticsearch cluster. +/// Used by to skip +/// ephemeral cluster startup when a remote cluster is available. +/// +public class ExternalClusterConfiguration +{ + public ExternalClusterConfiguration(Uri uri, string apiKey = null) => + (Uri, ApiKey) = (uri ?? throw new ArgumentNullException(nameof(uri)), apiKey); + + /// The base URI of the external Elasticsearch cluster. + public Uri Uri { get; } + + /// An optional API key for authenticating with the cluster. + public string ApiKey { get; } + + /// + /// Validates that the external cluster is reachable by issuing a GET request to the root endpoint. + /// Throws with a descriptive message on failure. + /// + public async Task ValidateAsync(TimeSpan? timeout = null, CancellationToken ctx = default) + { + timeout ??= TimeSpan.FromSeconds(10); + + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + using var http = new HttpClient(handler) { Timeout = timeout.Value }; + + if (!string.IsNullOrEmpty(ApiKey)) + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("ApiKey", ApiKey); + + try + { + var response = await http.GetAsync(Uri, ctx).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"External Elasticsearch cluster at {Uri} is not reachable: {ex.Message}", ex); + } + } +} diff --git a/src/Elastic.TUnit.Elasticsearch.Core/README.md b/src/Elastic.TUnit.Elasticsearch.Core/README.md index 3e92b30..12cf381 100644 --- a/src/Elastic.TUnit.Elasticsearch.Core/README.md +++ b/src/Elastic.TUnit.Elasticsearch.Core/README.md @@ -25,6 +25,7 @@ It depends on this package transitively and adds: - `ElasticsearchCluster` — generic cluster base with TUnit lifecycle integration - `ElasticsearchConfiguration` — cluster configuration with bootstrap diagnostics settings +- `ExternalClusterConfiguration` — connection details for remote Elasticsearch clusters - `ElasticsearchClusterExtensions` — `GetOrAddClient` helpers for caching any client type - `ElasticsearchTestHooks` — global `[BeforeEvery(Test)]` hook for skip evaluation - `ElasticsearchTestBase` — optional convenience base class @@ -32,6 +33,28 @@ It depends on this package transitively and adds: - `SkipVersionAttribute` / `SkipTestAttribute` — version-based and custom skip conditions - Bootstrap diagnostics writers (ANSI console, progress heartbeat) +## External cluster support + +`ElasticsearchCluster` supports skipping ephemeral cluster startup when a +remote Elasticsearch instance is already available. This significantly speeds up development +feedback loops since you don't need to wait for Elasticsearch to bootstrap on every test run. + +The resolution order during `InitializeAsync()` is: + +1. **Programmatic hook** — override `TryUseExternalCluster()` to return an + `ExternalClusterConfiguration`, or `null` to fall through +2. **Environment variables** — set `TEST_ELASTICSEARCH_URL` (and optionally + `TEST_ELASTICSEARCH_API_KEY`) +3. **Ephemeral startup** — download, install, and start Elasticsearch locally + +When using an external cluster: + +- `NodesUris()` returns the external URI +- `IsExternal` is `true` +- `ExternalApiKey` contains the API key (if provided) +- `Dispose()` is a no-op (the remote cluster is not managed) +- Connectivity is validated with a GET `/` before tests run + ## Dependencies - `TUnit.Core` diff --git a/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs b/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs index a2383f2..1d82189 100644 --- a/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs +++ b/src/Elastic.TUnit.Elasticsearch/ElasticsearchCluster.cs @@ -18,6 +18,11 @@ namespace Elastic.TUnit.Elasticsearch; /// with debug mode enabled and per-request diagnostics routed to the /// current TUnit test's output. Override to customize. /// +/// +/// When connected to an external cluster (via TEST_ELASTICSEARCH_URL or +/// ), +/// the client is automatically configured with the external API key if provided. +/// /// public class ElasticsearchCluster : ElasticsearchCluster { @@ -40,12 +45,18 @@ public ElasticsearchCluster(ElasticsearchConfiguration configuration) /// A default configured with debug mode /// and per-request diagnostics routed to the current TUnit test's output. /// The client is cached for the cluster's lifetime. + /// When using an external cluster with an API key, authentication is + /// configured automatically. /// Override to customize connection settings, authentication, serialization, etc. /// public virtual ElasticsearchClient Client => this.GetOrAddClient((c, output) => { var settings = new ElasticsearchClientSettings(new StaticNodePool(c.NodesUris())) .WireTUnitOutput(output); + + if (ExternalApiKey != null) + settings = settings.Authentication(new ApiKey(ExternalApiKey)); + return new ElasticsearchClient(settings); }); } diff --git a/src/Elastic.TUnit.Elasticsearch/README.md b/src/Elastic.TUnit.Elasticsearch/README.md index ae759ae..ff7cd31 100644 --- a/src/Elastic.TUnit.Elasticsearch/README.md +++ b/src/Elastic.TUnit.Elasticsearch/README.md @@ -4,7 +4,7 @@ Write integration tests against Elasticsearch using [TUnit](https://tunit.dev) a [`Elastic.Clients.Elasticsearch`](https://www.nuget.org/packages/Elastic.Clients.Elasticsearch/). This is the recommended package for most users — it builds on -[`Elastic.TUnit`](https://www.nuget.org/packages/Elastic.TUnit/) and adds a convenience +[`Elastic.TUnit.Elasticsearch.Core`](https://www.nuget.org/packages/Elastic.TUnit.Elasticsearch.Core/) and adds a convenience `ElasticsearchCluster` base class with a pre-configured `ElasticsearchClient`. ## Getting started @@ -23,7 +23,7 @@ This is the recommended package for most users — it builds on ``` -`Elastic.Clients.Elasticsearch` and `Elastic.TUnit` are included as transitive dependencies. +`Elastic.Clients.Elasticsearch` and `Elastic.TUnit.Elasticsearch.Core` are included as transitive dependencies. ### Define a cluster @@ -63,6 +63,75 @@ reference the same key. dotnet run --project MyTests/ ``` +## Using an external cluster + +When developing integration tests, waiting for an ephemeral cluster to start on every run +can be slow. You can point tests at an already-running Elasticsearch instance instead. + +### Environment variables (zero code changes) + +Set `TEST_ELASTICSEARCH_URL` and optionally `TEST_ELASTICSEARCH_API_KEY`: + +```bash +# Basic — no authentication +TEST_ELASTICSEARCH_URL=https://localhost:9200 dotnet run --project MyTests/ + +# With API key authentication +TEST_ELASTICSEARCH_URL=https://localhost:9200 \ +TEST_ELASTICSEARCH_API_KEY=your-api-key-here \ +dotnet run --project MyTests/ +``` + +When `TEST_ELASTICSEARCH_URL` is set, the cluster validates connectivity (GET `/`) +and skips ephemeral startup entirely. The default `Client` on `ElasticsearchCluster` +automatically picks up the API key. + +### Programmatic hook + +Override `TryUseExternalCluster()` for custom logic — service discovery, config files, +conditional per-developer overrides, etc.: + +```csharp +public class MyTestCluster : ElasticsearchCluster +{ + public MyTestCluster() : base(new ElasticsearchConfiguration("latest-9")) { } + + protected override ExternalClusterConfiguration TryUseExternalCluster() + { + var url = Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_URL"); + if (string.IsNullOrEmpty(url)) + return null; // fall through to ephemeral startup + + return new ExternalClusterConfiguration( + new Uri(url), + Environment.GetEnvironmentVariable("MY_DEV_CLUSTER_KEY") + ); + } +} +``` + +The resolution order is: + +1. `TryUseExternalCluster()` override (programmatic hook) +2. `TEST_ELASTICSEARCH_URL` environment variable +3. Start an ephemeral cluster + +### Inspecting external cluster state + +The cluster exposes `IsExternal` and `ExternalApiKey` properties: + +```csharp +[Test] +public async Task SomeTest() +{ + if (cluster.IsExternal) + TestContext.Current.Output.WriteLine("Running against external cluster"); + + var info = await cluster.Client.InfoAsync(); + await Assert.That(info.IsValidResponse).IsTrue(); +} +``` + ## Features ### Custom client configuration @@ -87,6 +156,22 @@ public class MyTestCluster() : ElasticsearchCluster("latest-9") The `.WireTUnitOutput(output)` extension enables debug mode and routes per-request diagnostics to the current test's output. +When overriding `Client` and using external clusters, check `ExternalApiKey` to wire +authentication: + +```csharp +public override ElasticsearchClient Client => this.GetOrAddClient((c, output) => +{ + var settings = new ElasticsearchClientSettings(new StaticNodePool(c.NodesUris())) + .WireTUnitOutput(output); + + if (ExternalApiKey != null) + settings = settings.Authentication(new ApiKey(ExternalApiKey)); + + return new ElasticsearchClient(settings); +}); +``` + For multiple clusters that share the same client setup, use a base class: ```csharp @@ -228,3 +313,4 @@ public class SecurityCluster : ElasticsearchCluster | Client access | `cluster.GetOrAddClient(...)` in test | `cluster.Client` on cluster | | Parallel control | `ElasticXunitRunOptions.MaxConcurrency` | `[ParallelLimiter]` | | Cluster partitioning | `Nullean.Xunit.Partitions` | `SharedType.Keyed` | +| External cluster | `IntegrationTestsMayUseAlreadyRunningNode` | `TEST_ELASTICSEARCH_URL` env var or `TryUseExternalCluster()` override |