diff --git a/.gitignore b/.gitignore index a68c08dfa..d7e2ad777 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,6 @@ test-coverage/ # SonarQube .sonarqube/ + +# Visual Studio Code +.vscode/ diff --git a/Testcontainers.sln b/Testcontainers.sln index a86fd5cb6..2e506756a 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -121,6 +121,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Spanner", "src\Testcontainers.Spanner\Testcontainers.Spanner.csproj", "{7026E057-9F15-4947-AB8C-4AF034323A3E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Spanner.Tests", "tests\Testcontainers.Spanner.Tests\Testcontainers.Spanner.Tests.csproj", "{A84920E9-D781-4457-BD32-43B32C2D451B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -346,6 +350,14 @@ Global {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU + {7026E057-9F15-4947-AB8C-4AF034323A3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7026E057-9F15-4947-AB8C-4AF034323A3E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7026E057-9F15-4947-AB8C-4AF034323A3E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7026E057-9F15-4947-AB8C-4AF034323A3E}.Release|Any CPU.Build.0 = Release|Any CPU + {A84920E9-D781-4457-BD32-43B32C2D451B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A84920E9-D781-4457-BD32-43B32C2D451B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A84920E9-D781-4457-BD32-43B32C2D451B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A84920E9-D781-4457-BD32-43B32C2D451B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {3F2E254F-C203-43FD-A078-DC3E2CBC0F9F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -402,5 +414,7 @@ Global {1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {7026E057-9F15-4947-AB8C-4AF034323A3E} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {A84920E9-D781-4457-BD32-43B32C2D451B} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/src/Testcontainers.Spanner/.editorconfig b/src/Testcontainers.Spanner/.editorconfig new file mode 100644 index 000000000..78b36ca08 --- /dev/null +++ b/src/Testcontainers.Spanner/.editorconfig @@ -0,0 +1 @@ +root = true diff --git a/src/Testcontainers.Spanner/HttpResponseMessageExtensions.cs b/src/Testcontainers.Spanner/HttpResponseMessageExtensions.cs new file mode 100644 index 000000000..dfc2a3cb4 --- /dev/null +++ b/src/Testcontainers.Spanner/HttpResponseMessageExtensions.cs @@ -0,0 +1,24 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Testcontainers.Spanner; +internal static class HttpResponseMessageExtensions +{ + internal static async Task ValidateAsync(this Task response, string operation, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = await response; + + if (!result.IsSuccessStatusCode) + { + string resultContent = await result.Content.ReadAsStringAsync(); + string message = + $"Failed to {operation} with status code {result.StatusCode} response: {resultContent}"; + throw new InvalidOperationException(message); + } + + return result; + } +} diff --git a/src/Testcontainers.Spanner/SpannerBuilder.cs b/src/Testcontainers.Spanner/SpannerBuilder.cs new file mode 100644 index 000000000..75dc15f1d --- /dev/null +++ b/src/Testcontainers.Spanner/SpannerBuilder.cs @@ -0,0 +1,101 @@ +namespace Testcontainers.Spanner; + +/// +[PublicAPI] +public sealed class SpannerBuilder : ContainerBuilder +{ + private const string DefaultProjectId = "my-project"; + private const string SpannerEmulatorImage = "gcr.io/cloud-spanner-emulator/emulator:1.5.3"; + + + internal const int InternalGrpcPort = 9010; + internal const int InternalRestPort = 9020; + + + /// + /// Initializes a new instance of the class. + /// + public SpannerBuilder() + : this(new SpannerConfiguration()) + { + + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private SpannerBuilder(SpannerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + // /// + protected override SpannerConfiguration DockerResourceConfiguration { get; } + + /// + /// Sets the ProjectId. + /// + /// The ProjectId. + /// A configured instance of . + public SpannerBuilder WithProjectId(string projectId) + => Merge(DockerResourceConfiguration, new SpannerConfiguration(projectId: projectId)); + + + /// + public override SpannerContainer Build() + { + Validate(); + return new SpannerContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + + /// + protected override SpannerBuilder Init() + { + return base.Init() + .WithImage(SpannerEmulatorImage) + .WithPortBinding(InternalGrpcPort, true) + .WithPortBinding(InternalRestPort, true) + .WithProjectId(DefaultProjectId) + .WithWaitStrategy( + Wait + .ForUnixContainer() + // The default wait for port implementation keeps waiting untill the test times out, therefor now using this custom flavor of the same concept + .UntilMessageIsLogged($".+REST server listening at 0.0.0.0:{InternalRestPort}") + .UntilMessageIsLogged($".+gRPC server listening at 0.0.0.0:{InternalGrpcPort}") + ); + + } + + + /// + protected override void Validate() + { + base.Validate(); + + _ = Guard.Argument(DockerResourceConfiguration.ProjectId, nameof(DockerResourceConfiguration.ProjectId)) + .NotNull() + .NotEmpty(); + } + + /// + protected override SpannerBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new SpannerConfiguration(resourceConfiguration)); + } + + /// + protected override SpannerBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new SpannerConfiguration(resourceConfiguration)); + } + + /// + protected override SpannerBuilder Merge(SpannerConfiguration oldValue, SpannerConfiguration newValue) + { + return new SpannerBuilder(new SpannerConfiguration(oldValue, newValue)); + } +} diff --git a/src/Testcontainers.Spanner/SpannerConfiguration.cs b/src/Testcontainers.Spanner/SpannerConfiguration.cs new file mode 100644 index 000000000..041de36e6 --- /dev/null +++ b/src/Testcontainers.Spanner/SpannerConfiguration.cs @@ -0,0 +1,58 @@ +namespace Testcontainers.Spanner; + +/// +[PublicAPI] +public class SpannerConfiguration : ContainerConfiguration +{ + public string? ProjectId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + public SpannerConfiguration(string? projectId = null) + { + ProjectId = projectId; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public SpannerConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public SpannerConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public SpannerConfiguration(SpannerConfiguration resourceConfiguration) + : this(new SpannerConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public SpannerConfiguration(SpannerConfiguration oldValue, SpannerConfiguration newValue) + : base(oldValue, newValue) + { + ProjectId = BuildConfiguration.Combine(oldValue.ProjectId, newValue.ProjectId); + } +} diff --git a/src/Testcontainers.Spanner/SpannerContainer.cs b/src/Testcontainers.Spanner/SpannerContainer.cs new file mode 100644 index 000000000..d062e8ca1 --- /dev/null +++ b/src/Testcontainers.Spanner/SpannerContainer.cs @@ -0,0 +1,34 @@ +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Testcontainers.Spanner; + +/// +[PublicAPI] +public sealed class SpannerContainer : DockerContainer +{ + private const string EnvironmentVariableEmulatorHost = "SPANNER_EMULATOR_HOST"; + private readonly SpannerConfiguration _configuration; + + public int GrpcPort => GetMappedPublicPort(SpannerBuilder.InternalGrpcPort); + public int RestPort => GetMappedPublicPort(SpannerBuilder.InternalRestPort); + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public SpannerContainer(SpannerConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + _configuration = configuration; + } + + public override async Task StartAsync(CancellationToken ct = default) + { + await base.StartAsync(ct); + + Environment.SetEnvironmentVariable(EnvironmentVariableEmulatorHost, $"{Hostname}:{GrpcPort}"); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Spanner/Testcontainers.Spanner.csproj b/src/Testcontainers.Spanner/Testcontainers.Spanner.csproj new file mode 100644 index 000000000..1d8a7a69e --- /dev/null +++ b/src/Testcontainers.Spanner/Testcontainers.Spanner.csproj @@ -0,0 +1,15 @@ + + + netstandard2.0;netstandard2.1 + latest + enable + + + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Spanner/Usings.cs b/src/Testcontainers.Spanner/Usings.cs new file mode 100644 index 000000000..0192278ae --- /dev/null +++ b/src/Testcontainers.Spanner/Usings.cs @@ -0,0 +1,8 @@ +global using System; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; +global using Microsoft.Extensions.Logging; diff --git a/tests/Testcontainers.Spanner.Tests/.editorconfig b/tests/Testcontainers.Spanner.Tests/.editorconfig new file mode 100644 index 000000000..78b36ca08 --- /dev/null +++ b/tests/Testcontainers.Spanner.Tests/.editorconfig @@ -0,0 +1 @@ +root = true diff --git a/tests/Testcontainers.Spanner.Tests/Properties/AssemblyInfo.cs b/tests/Testcontainers.Spanner.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..7a23a8555 --- /dev/null +++ b/tests/Testcontainers.Spanner.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: CollectionBehavior(DisableTestParallelization = true)] // The c.net client libraries alwys use the host from the environment variable, even if another one is passed diff --git a/tests/Testcontainers.Spanner.Tests/SpannerContainerStartAsyncTests.cs b/tests/Testcontainers.Spanner.Tests/SpannerContainerStartAsyncTests.cs new file mode 100644 index 000000000..c363a71eb --- /dev/null +++ b/tests/Testcontainers.Spanner.Tests/SpannerContainerStartAsyncTests.cs @@ -0,0 +1,111 @@ +using DotNet.Testcontainers.Containers; +using Google.Api.Gax; +using Google.Cloud.Spanner.Admin.Instance.V1; + + +namespace Testcontainers.Spanner.Tests; + +public class SpannerContainerStartAsyncTests +{ + private const string InstanceId = "my-pretty-test-instance"; + private const string ProjectUri = "projects/my-project"; + + [Fact] + public async Task WhenCompleteThenContainerIsRunning() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + + // Act + await containerManager.StartAsync(); + // Assert + + Assert.Equal(TestcontainersStates.Running, containerManager.State); + } + } + + [Fact] + public async Task WhenCompleteThenContainerHasRandomizedRestPortMapped() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + // Assert + + Assert.NotEqual(default, containerManager.RestPort); + Assert.NotEqual(9020, containerManager.RestPort); + } + } + + [Fact] + public async Task WhenCompleteThenContainerHasRandomizedGrpcPortMapped() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + + // Assert + Assert.NotEqual(default, containerManager.GrpcPort); + Assert.NotEqual(9010, containerManager.GrpcPort); + } + } + + + [Fact] + public async Task WhenCompleteThenEnvironmentVariableEmulatorHostSet() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + + // Assert + Assert.Equal($"{containerManager.Hostname}:{containerManager.GrpcPort}", Environment.GetEnvironmentVariable("SPANNER_EMULATOR_HOST")); + } + } + + [Fact] + public async Task WhenCompleteThenConnectionIsUseable() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + + var clientBuilder = new InstanceAdminClientBuilder() + { + EmulatorDetection = EmulatorDetection.EmulatorOrProduction, + Endpoint = $"{containerManager.Hostname}:{containerManager.GrpcPort}", + }; + + var client = await clientBuilder.BuildAsync(); + + // Act + await client.CreateInstanceAsync(new CreateInstanceRequest() + { + InstanceId = InstanceId, + Parent = ProjectUri, + }); + + var instances = client.ListInstances(new ListInstancesRequest + { + Parent = ProjectUri, + }); + + // Assert + Assert.Contains(instances, i => i.InstanceName.InstanceId == InstanceId); + } + } +} diff --git a/tests/Testcontainers.Spanner.Tests/SpannerContainerTestsForEnvironmentVariable.cs b/tests/Testcontainers.Spanner.Tests/SpannerContainerTestsForEnvironmentVariable.cs new file mode 100644 index 000000000..5814bfe11 --- /dev/null +++ b/tests/Testcontainers.Spanner.Tests/SpannerContainerTestsForEnvironmentVariable.cs @@ -0,0 +1,97 @@ +using Google.Api.Gax; +using Google.Cloud.Spanner.Admin.Instance.V1; + + +namespace Testcontainers.Spanner.Tests; + +public class SpannerContainerTestsForEnvironmentVariable +{ + private const string InstanceId = "my-pretty-test-instance"; + private const string ProjectUri = "projects/my-project"; + + + + [Fact] + public async Task WhenProductionOnlyConnectionNotUsable() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + + var clientBuilder = new InstanceAdminClientBuilder() + { + EmulatorDetection = EmulatorDetection.EmulatorOrProduction, + Endpoint = $"{containerManager.Hostname}:{containerManager.GrpcPort}", + }; + + var client = await clientBuilder.BuildAsync(); + + // Assert + await Assert.ThrowsAsync(async () => await client.CreateInstanceAsync(new CreateInstanceRequest() + { + InstanceId = InstanceId, + Parent = ProjectUri, + })); + } + } + + [Fact] + public async Task WhenNotSetThenConnectionIsNotUseable() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + + var clientBuilder = new InstanceAdminClientBuilder() + { + EmulatorDetection = EmulatorDetection.EmulatorOrProduction, + Endpoint = $"{containerManager.Hostname}:{containerManager.GrpcPort}", + }; + + var client = await clientBuilder.BuildAsync(); + + Environment.SetEnvironmentVariable("SPANNER_EMULATOR_HOST", null); + + // Assert + await Assert.ThrowsAsync(async () => await client.CreateInstanceAsync(new CreateInstanceRequest() + { + InstanceId = InstanceId, + Parent = ProjectUri, + })); + } + } + [Fact] + public async Task WhenNotSetAndEmulatorOnlyThenConnectionIsNotUseable() + { + // Arrange + var builder = new SpannerBuilder(); + await using (var containerManager = builder.Build()) + { + // Act + await containerManager.StartAsync(); + + var clientBuilder = new InstanceAdminClientBuilder() + { + EmulatorDetection = EmulatorDetection.EmulatorOnly, + Endpoint = $"{containerManager.Hostname}:{containerManager.GrpcPort}", + }; + + var client = await clientBuilder.BuildAsync(); + + Environment.SetEnvironmentVariable("SPANNER_EMULATOR_HOST", null); + + // Assert + await Assert.ThrowsAsync(async () => await client.CreateInstanceAsync(new CreateInstanceRequest() + { + InstanceId = InstanceId, + Parent = ProjectUri, + })); + } + } +} diff --git a/tests/Testcontainers.Spanner.Tests/Testcontainers.Spanner.Tests.csproj b/tests/Testcontainers.Spanner.Tests/Testcontainers.Spanner.Tests.csproj new file mode 100644 index 000000000..08b599aed --- /dev/null +++ b/tests/Testcontainers.Spanner.Tests/Testcontainers.Spanner.Tests.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Testcontainers.Spanner.Tests/Usings.cs b/tests/Testcontainers.Spanner.Tests/Usings.cs new file mode 100644 index 000000000..7bb6613f8 --- /dev/null +++ b/tests/Testcontainers.Spanner.Tests/Usings.cs @@ -0,0 +1,3 @@ +global using System; +global using System.Threading.Tasks; +global using Xunit;