diff --git a/docs/api/create_docker_container.md b/docs/api/create_docker_container.md index 00b64de5e..1e4b75013 100644 --- a/docs/api/create_docker_container.md +++ b/docs/api/create_docker_container.md @@ -11,7 +11,7 @@ Instead of running the NGINX application, the following container configuration ```csharp _ = new TestcontainersBuilder() .WithEntrypoint("nginx") - .WithCommand("-t") + .WithCommand("-t"); ``` ## Configure container app or service @@ -29,7 +29,7 @@ _ = new TestcontainersBuilder() .WithEnvironment("ASPNETCORE_URLS", "https://+") .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Path", "/app/certificate.crt") .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Password", "password") - .WithResourceMapping("certificate.crt", "/app/certificate.crt") + .WithResourceMapping("certificate.crt", "/app/certificate.crt"); ``` `WithBindMount(string, string)` is another option to provide access to directories or files. It mounts a host directory or file into the container. Note, this does not follow our best practices. Host paths differ between environments and may not be available on every system or Docker setup, e.g. CI. diff --git a/docs/api/create_docker_image.md b/docs/api/create_docker_image.md index 512d60fef..1bea3f354 100644 --- a/docs/api/create_docker_image.md +++ b/docs/api/create_docker_image.md @@ -11,7 +11,7 @@ _ = await new ImageFromDockerfileBuilder() .WithName(Guid.NewGuid().ToString("D")) .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), "src") .WithDockerfile("Dockerfile") - .Build() + .Build(); ``` !!!tip diff --git a/docs/api/wait_strategies.md b/docs/api/wait_strategies.md index 46fcc6e48..8b9fdc598 100644 --- a/docs/api/wait_strategies.md +++ b/docs/api/wait_strategies.md @@ -11,6 +11,40 @@ _ = Wait.ForUnixContainer() .AddCustomWaitStrategy(new MyCustomWaitStrategy()); ``` +## Wait until an HTTP(S) endpoint is available + +You can choose to wait for an HTTP(S) endpoint to return a particular HTTP response status code or to match a predicate. +The default configuration tries to access the HTTP endpoint running inside the container. Chose `ForPort(ushort)` or `ForPath(string)` to adjust the endpoint or `UsingTls()` to switch to HTTPS. +When using `UsingTls()` port 443 is used as a default. +If your container exposes a different HTTPS port, make sure that the correct waiting port is configured accordingly. + +### Waiting for HTTP response status code _200 OK_ on port 80 + +```csharp +_ = Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPath("/")); +``` + +### Waiting for HTTP response status code _200 OK_ or _301 Moved Permanently_ + +```csharp +_ = Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPath("/") + .ForStatusCode(HttpStatusCode.OK) + .ForStatusCode(HttpStatusCode.MovedPermanently)); +``` + +### Waiting for the HTTP response status code to match a predicate + +```csharp +_ = Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPath("/") + .ForStatusCodeMatching(statusCode => statusCode >= HttpStatusCode.OK && statusCode < HttpStatusCode.MultipleChoices)); +``` + ## Wait until the container is healthy If the Docker image supports Dockers's [HEALTHCHECK][docker-docs-healthcheck] feature, like the following configuration: @@ -24,7 +58,7 @@ You can leverage the container's health status as your wait strategy to report r ```csharp _ = new TestcontainersBuilder() - .WithWaitStrategy(Wait.ForUnixContainer().UntilContainerIsHealthy()) + .WithWaitStrategy(Wait.ForUnixContainer().UntilContainerIsHealthy()); ``` [docker-docs-healthcheck]: https://docs.docker.com/engine/reference/builder/#healthcheck diff --git a/src/Testcontainers/Configurations/WaitStrategies/HttpWaitStrategy.cs b/src/Testcontainers/Configurations/WaitStrategies/HttpWaitStrategy.cs new file mode 100644 index 000000000..446bb68be --- /dev/null +++ b/src/Testcontainers/Configurations/WaitStrategies/HttpWaitStrategy.cs @@ -0,0 +1,212 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Threading.Tasks; + using DotNet.Testcontainers.Containers; + using JetBrains.Annotations; + using Microsoft.Extensions.Logging; + + /// + /// Wait for an HTTP(S) endpoint to return a particular status code. + /// + [PublicAPI] + public sealed class HttpWaitStrategy : IWaitUntil + { + private const ushort HttpPort = 80; + + private const ushort HttpsPort = 443; + + private readonly IDictionary httpHeaders = new Dictionary(); + + private readonly ISet httpStatusCodes = new HashSet(); + + private Predicate httpStatusCodePredicate; + + private HttpMethod httpMethod; + + private string schemeName; + + private string pathValue; + + private ushort? portNumber; + + /// + /// Initializes a new instance of the class. + /// + public HttpWaitStrategy() + { + _ = this.WithMethod(HttpMethod.Get).UsingTls(false).ForPath("/"); + } + + /// + public async Task Until(ITestcontainersContainer testcontainers, ILogger logger) + { + // Java fall back to the first exposed port. The .NET wait strategies do not have access to the exposed port information yet. + var containerPort = this.portNumber.GetValueOrDefault(Uri.UriSchemeHttp.Equals(this.schemeName, StringComparison.OrdinalIgnoreCase) ? HttpPort : HttpsPort); + + string host; + + ushort port; + + try + { + host = testcontainers.Hostname; + port = testcontainers.GetMappedPublicPort(containerPort); + } + catch + { + return false; + } + + using (var httpClient = new HttpClient()) + { + using (var httpRequestMessage = new HttpRequestMessage(this.httpMethod, new UriBuilder(this.schemeName, host, port, this.pathValue).Uri)) + { + foreach (var httpHeader in this.httpHeaders) + { + httpRequestMessage.Headers.Add(httpHeader.Key, httpHeader.Value); + } + + HttpResponseMessage httpResponseMessage; + + try + { + httpResponseMessage = await httpClient.SendAsync(httpRequestMessage) + .ConfigureAwait(false); + } + catch + { + return false; + } + + Predicate predicate; + + if (!this.httpStatusCodes.Any() && this.httpStatusCodePredicate == null) + { + predicate = statusCode => HttpStatusCode.OK.Equals(statusCode); + } + else if (this.httpStatusCodes.Any() && this.httpStatusCodePredicate == null) + { + predicate = statusCode => this.httpStatusCodes.Contains(statusCode); + } + else if (this.httpStatusCodes.Any()) + { + predicate = statusCode => this.httpStatusCodes.Contains(statusCode) || this.httpStatusCodePredicate.Invoke(statusCode); + } + else + { + predicate = this.httpStatusCodePredicate; + } + + return predicate.Invoke(httpResponseMessage.StatusCode); + } + } + } + + /// + /// Waits for the status code. + /// + /// The expected status code. + /// A configured instance of . + public HttpWaitStrategy ForStatusCode(HttpStatusCode statusCode) + { + this.httpStatusCodes.Add(statusCode); + return this; + } + + /// + /// Waits for the status code to pass the predicate. + /// + /// The predicate to test the HTTP response against. + /// A configured instance of . + public HttpWaitStrategy ForStatusCodeMatching(Predicate statusCodePredicate) + { + this.httpStatusCodePredicate = statusCodePredicate; + return this; + } + + /// + /// Waits for the path. + /// + /// The path to check. + /// A configured instance of . + public HttpWaitStrategy ForPath(string path) + { + this.pathValue = path; + return this; + } + + /// + /// Waits for the port. + /// + /// + /// default value. + /// + /// The port to check. + /// A configured instance of . + public HttpWaitStrategy ForPort(ushort port) + { + this.portNumber = port; + return this; + } + + /// + /// Indicates that the HTTP request use HTTPS. + /// + /// + /// default value. + /// + /// True if the HTTP request use HTTPS, otherwise false. + /// A configured instance of . + public HttpWaitStrategy UsingTls(bool tlsEnabled = true) + { + this.schemeName = tlsEnabled ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + return this; + } + + /// + /// Indicates the HTTP request method. + /// + /// + /// default value. + /// + /// The HTTP method. + /// A configured instance of . + public HttpWaitStrategy WithMethod(HttpMethod method) + { + this.httpMethod = method; + return this; + } + + /// + /// Adds a custom HTTP header to the HTTP request. + /// + /// The HTTP header name. + /// The HTTP header value. + /// A configured instance of . + public HttpWaitStrategy WithHeader(string name, string value) + { + this.httpHeaders.Add(name, value); + return this; + } + + /// + /// Adds custom HTTP headers to the HTTP request. + /// + /// A list of HTTP headers. + /// A configured instance of . + public HttpWaitStrategy WithHeaders(IReadOnlyDictionary headers) + { + foreach (var header in headers) + { + _ = this.WithHeader(header.Key, header.Value); + } + + return this; + } + } +} diff --git a/src/Testcontainers/Configurations/WaitStrategies/IWaitForContainerOS.cs b/src/Testcontainers/Configurations/WaitStrategies/IWaitForContainerOS.cs index 8ed758b94..dbdc9996e 100644 --- a/src/Testcontainers/Configurations/WaitStrategies/IWaitForContainerOS.cs +++ b/src/Testcontainers/Configurations/WaitStrategies/IWaitForContainerOS.cs @@ -6,8 +6,7 @@ namespace DotNet.Testcontainers.Configurations using JetBrains.Annotations; /// - /// Collection of pre-configured strategies to wait until the Testcontainer is up and running. - /// Waits until all wait strategies are completed. + /// Collection of pre-configured strategies to wait until the container is up and running. /// [PublicAPI] public interface IWaitForContainerOS @@ -42,6 +41,14 @@ public interface IWaitForContainerOS [PublicAPI] IWaitForContainerOS UntilCommandIsCompleted(params string[] command); + /// + /// Waits until the port is available. + /// + /// The port to be checked. + /// A configured instance of . + [PublicAPI] + IWaitForContainerOS UntilPortIsAvailable(int port); + /// /// Waits until the file exists. /// @@ -70,12 +77,12 @@ public interface IWaitForContainerOS IWaitForContainerOS UntilOperationIsSucceeded(Func operation, int maxCallCount); /// - /// Waits until the port is available. + /// Waits until the http request is completed successfully. /// - /// The port to be checked. + /// The http request to be executed. /// A configured instance of . [PublicAPI] - IWaitForContainerOS UntilPortIsAvailable(int port); + IWaitForContainerOS UntilHttpRequestIsSucceeded(Func request); /// /// Waits until the container is healthy. diff --git a/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerOS.cs b/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerOS.cs index 9991c0eae..b7a9873d2 100644 --- a/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerOS.cs +++ b/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerOS.cs @@ -52,7 +52,13 @@ public virtual IWaitForContainerOS UntilOperationIsSucceeded(Func operatio } /// - public virtual IWaitForContainerOS UntilContainerIsHealthy(long failingStreak = 20) + public virtual IWaitForContainerOS UntilHttpRequestIsSucceeded(Func request) + { + return this.AddCustomWaitStrategy(request.Invoke(new HttpWaitStrategy())); + } + + /// + public virtual IWaitForContainerOS UntilContainerIsHealthy(long failingStreak = 3) { return this.AddCustomWaitStrategy(new UntilContainerIsHealthy(failingStreak)); } diff --git a/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerUnix.cs b/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerUnix.cs index 87444d6c5..01653e28e 100644 --- a/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerUnix.cs +++ b/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerUnix.cs @@ -6,22 +6,19 @@ internal sealed class WaitForContainerUnix : WaitForContainerOS /// public override IWaitForContainerOS UntilCommandIsCompleted(string command) { - this.AddCustomWaitStrategy(new UntilUnixCommandIsCompleted(command)); - return this; + return this.AddCustomWaitStrategy(new UntilUnixCommandIsCompleted(command)); } /// public override IWaitForContainerOS UntilCommandIsCompleted(params string[] command) { - this.AddCustomWaitStrategy(new UntilUnixCommandIsCompleted(command)); - return this; + return this.AddCustomWaitStrategy(new UntilUnixCommandIsCompleted(command)); } /// public override IWaitForContainerOS UntilPortIsAvailable(int port) { - this.AddCustomWaitStrategy(new UntilUnixPortIsAvailable(port)); - return this; + return this.AddCustomWaitStrategy(new UntilUnixPortIsAvailable(port)); } } } diff --git a/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerWindows.cs b/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerWindows.cs index 71014d990..a3a45cd77 100644 --- a/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerWindows.cs +++ b/src/Testcontainers/Configurations/WaitStrategies/WaitForContainerWindows.cs @@ -6,22 +6,19 @@ internal sealed class WaitForContainerWindows : WaitForContainerOS /// public override IWaitForContainerOS UntilCommandIsCompleted(string command) { - this.AddCustomWaitStrategy(new UntilWindowsCommandIsCompleted(command)); - return this; + return this.AddCustomWaitStrategy(new UntilWindowsCommandIsCompleted(command)); } /// public override IWaitForContainerOS UntilCommandIsCompleted(params string[] command) { - this.AddCustomWaitStrategy(new UntilWindowsCommandIsCompleted(command)); - return this; + return this.AddCustomWaitStrategy(new UntilWindowsCommandIsCompleted(command)); } /// public override IWaitForContainerOS UntilPortIsAvailable(int port) { - this.AddCustomWaitStrategy(new UntilWindowsPortIsAvailable(port)); - return this; + return this.AddCustomWaitStrategy(new UntilWindowsPortIsAvailable(port)); } } } diff --git a/tests/Testcontainers.Tests/Unit/Configurations/WaitUntilHttpRequestIsSucceededTest.cs b/tests/Testcontainers.Tests/Unit/Configurations/WaitUntilHttpRequestIsSucceededTest.cs new file mode 100644 index 000000000..22cc60d01 --- /dev/null +++ b/tests/Testcontainers.Tests/Unit/Configurations/WaitUntilHttpRequestIsSucceededTest.cs @@ -0,0 +1,47 @@ +namespace DotNet.Testcontainers.Tests.Unit.Configurations +{ + using System.Collections.Generic; + using System.Net; + using System.Threading.Tasks; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Commons; + using DotNet.Testcontainers.Configurations; + using DotNet.Testcontainers.Containers; + using Microsoft.Extensions.Logging.Abstractions; + using Xunit; + + public sealed class WaitUntilHttpRequestIsSucceededTest + { + public static IEnumerable GetHttpWaitStrategies() + { + yield return new object[] { new HttpWaitStrategy() }; + yield return new object[] { new HttpWaitStrategy().ForStatusCode(HttpStatusCode.OK) }; + yield return new object[] { new HttpWaitStrategy().ForStatusCodeMatching(statusCode => HttpStatusCode.OK.Equals(statusCode)) }; + yield return new object[] { new HttpWaitStrategy().ForStatusCode(HttpStatusCode.Moved).ForStatusCodeMatching(statusCode => HttpStatusCode.OK.Equals(statusCode)) }; + } + + [Theory] + [MemberData(nameof(GetHttpWaitStrategies))] + public async Task HttpWaitStrategyShouldSucceeded(HttpWaitStrategy httpWaitStrategy) + { + // Given + const ushort httpPort = 80; + + var container = new TestcontainersBuilder() + .WithImage(CommonImages.Nginx) + .WithPortBinding(httpPort, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(httpPort)) + .Build(); + + // When + await container.StartAsync() + .ConfigureAwait(false); + + var succeeded = await httpWaitStrategy.Until(container, NullLogger.Instance) + .ConfigureAwait(false); + + // Then + Assert.True(succeeded); + } + } +}