diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index acf073d..25a6648 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -130,6 +130,50 @@ jobs: **/TestResults/**/* retention-days: 7 + native-aot-tests: + name: "Native AOT Tests (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-22.04] + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Setup .NET SDK" + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 10.0.x + global-json-file: global.json + + - name: "Start LocalStack (Linux)" + if: runner.os == 'Linux' + shell: bash + run: | + docker run -d -p 4566:4566 localstack/localstack:4.14.0 + if ! timeout 120s bash -c 'until curl --fail --silent --max-time 1 http://localhost:4566/_localstack/health; do sleep 1; done'; then + echo "LocalStack did not become ready within 120 seconds" + exit 1 + fi + echo "LOCALSTACK_NATIVE_AOT_HOST=localhost" >> "$GITHUB_ENV" + + - name: "Publish and run Native AOT tests" + shell: pwsh + run: | + $project = 'tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj' + $rid = '${{ runner.os == 'Windows' && 'win-x64' || 'linux-x64' }}' + $exe = if ($IsWindows) { 'LocalStack.Client.NativeAot.Tests.exe' } else { 'LocalStack.Client.NativeAot.Tests' } + # Every modern target framework the project declares (from Directory.Build.props). + $frameworks = (dotnet msbuild $project -getProperty:TargetFrameworks).Split(';', [StringSplitOptions]::RemoveEmptyEntries) + foreach ($framework in $frameworks) { + dotnet publish $project -c Release -f $framework -r $rid -o "artifacts/nativeaot/$framework" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "./artifacts/nativeaot/$framework/$exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + continuous-deployment: name: "Continuous Deployment" runs-on: ubuntu-22.04 diff --git a/Directory.Packages.props b/Directory.Packages.props index b06559e..3c8e08f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -175,6 +175,7 @@ + all diff --git a/LocalStack.slnx b/LocalStack.slnx index b9d215a..b4b6a9d 100644 --- a/LocalStack.slnx +++ b/LocalStack.slnx @@ -7,6 +7,7 @@ + diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index 35062f9..5c77e89 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -174,11 +174,13 @@ public IEnumerable GetProjMetadata() { string csProjPath = csProj.FullPath; - IEnumerable targetFrameworks = GetProjectTargetFrameworks(csProjPath); + Dictionary properties = GetProjectProperties(csProjPath, "TargetFrameworks", "TargetFramework", RunsAsPublishedExecutableProperty); + IEnumerable targetFrameworks = SplitTargetFrameworks(properties); string directoryPath = csProj.GetDirectory().FullPath; string assemblyName = GetAssemblyName(csProjPath); + bool runsAsPublishedExecutable = string.Equals(properties[RunsAsPublishedExecutableProperty], "true", StringComparison.OrdinalIgnoreCase); - var testProjMetadata = new ProjMetadata(directoryPath, csProjPath, targetFrameworks, assemblyName); + var testProjMetadata = new ProjMetadata(directoryPath, csProjPath, targetFrameworks, assemblyName, runsAsPublishedExecutable); projMetadata.Add(testProjMetadata); } @@ -385,8 +387,21 @@ private static string ToPreReleaseIdentifier(string identifier) return NuGetVersion.TryParse($"0.0.0-{sanitised}", out NuGetVersion? _) ? sanitised : $"g{sanitised}"; } - // Asks MSBuild for the evaluated frameworks, so lists built from Directory.Build.props properties resolve. - private static string[] GetProjectTargetFrameworks(string csprojPath) + // Test projects that set this property are published and run as native executables by CI, not by 'dotnet test'. + private const string RunsAsPublishedExecutableProperty = "LocalStackTestRunsAsPublishedExecutable"; + + private static string[] GetProjectTargetFrameworks(string csprojPath) => + SplitTargetFrameworks(GetProjectProperties(csprojPath, "TargetFrameworks", "TargetFramework")); + + private static string[] SplitTargetFrameworks(Dictionary properties) + { + string frameworks = string.IsNullOrWhiteSpace(properties["TargetFrameworks"]) ? properties["TargetFramework"] : properties["TargetFrameworks"]; + + return frameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + // Asks MSBuild for evaluated property values, so values built from Directory.Build.props properties resolve. + private static Dictionary GetProjectProperties(string csprojPath, params string[] propertyNames) { string dotnetHost = System.Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? throw new InvalidOperationException("DOTNET_HOST_PATH is not set; run the build through the dotnet CLI."); @@ -400,8 +415,11 @@ private static string[] GetProjectTargetFrameworks(string csprojPath) }; startInfo.ArgumentList.Add("msbuild"); startInfo.ArgumentList.Add(csprojPath); - startInfo.ArgumentList.Add("-getProperty:TargetFrameworks"); - startInfo.ArgumentList.Add("-getProperty:TargetFramework"); + + foreach (string propertyName in propertyNames) + { + startInfo.ArgumentList.Add($"-getProperty:{propertyName}"); + } using System.Diagnostics.Process process = System.Diagnostics.Process.Start(startInfo) ?? throw new InvalidOperationException($"Could not start '{dotnetHost} msbuild'."); @@ -414,16 +432,16 @@ private static string[] GetProjectTargetFrameworks(string csprojPath) throw new InvalidOperationException($"'dotnet msbuild {csprojPath}' exited with code {process.ExitCode}: {output}{error}"); } - using var document = System.Text.Json.JsonDocument.Parse(output); - System.Text.Json.JsonElement properties = document.RootElement.GetProperty("Properties"); - string? frameworks = properties.GetProperty("TargetFrameworks").GetString(); - - if (string.IsNullOrWhiteSpace(frameworks)) + // With one property MSBuild prints the bare value; with several it prints JSON. + if (propertyNames.Length == 1) { - frameworks = properties.GetProperty("TargetFramework").GetString(); + return new Dictionary(StringComparer.Ordinal) { [propertyNames[0]] = output.Trim() }; } - return (frameworks ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + using var document = System.Text.Json.JsonDocument.Parse(output); + System.Text.Json.JsonElement properties = document.RootElement.GetProperty("Properties"); + + return propertyNames.ToDictionary(name => name, name => properties.GetProperty(name).GetString() ?? string.Empty, StringComparer.Ordinal); } private string GetAssemblyName(string csprojPath) diff --git a/build/LocalStack.Build/CakeTasks/TestTask.cs b/build/LocalStack.Build/CakeTasks/TestTask.cs index be8e669..bb2a501 100644 --- a/build/LocalStack.Build/CakeTasks/TestTask.cs +++ b/build/LocalStack.Build/CakeTasks/TestTask.cs @@ -25,8 +25,16 @@ public override void Run(BuildContext context) ConsoleHelper.WriteInfo($"Target Frameworks: {targetFrameworks}"); + if (testProj.RunsAsPublishedExecutable) + { + ConsoleHelper.WriteWarning($"Skipping {testProj.AssemblyName}: CI publishes and runs it as a native executable"); + + continue; + } + foreach (string targetFramework in testProj.TargetFrameworks) { + if (context.SkipFunctionalTest && testProj.AssemblyName == "LocalStack.Client.Functional.Tests") { ConsoleHelper.WriteWarning("Skipping Functional Tests"); diff --git a/build/LocalStack.Build/Models/ProjMetadata.cs b/build/LocalStack.Build/Models/ProjMetadata.cs index 38783f2..7e7aaf0 100644 --- a/build/LocalStack.Build/Models/ProjMetadata.cs +++ b/build/LocalStack.Build/Models/ProjMetadata.cs @@ -2,4 +2,4 @@ namespace LocalStack.Build.Models; -public record ProjMetadata(string DirectoryPath, string CsProjPath, IEnumerable TargetFrameworks, string AssemblyName); \ No newline at end of file +public record ProjMetadata(string DirectoryPath, string CsProjPath, IEnumerable TargetFrameworks, string AssemblyName, bool RunsAsPublishedExecutable); \ No newline at end of file diff --git a/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj b/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj new file mode 100644 index 0000000..6d7d62a --- /dev/null +++ b/tests/LocalStack.Client.NativeAot.Tests/LocalStack.Client.NativeAot.Tests.csproj @@ -0,0 +1,25 @@ + + + $(LocalStackModernTargetFrameworks) + Exe + true + true + false + + + + + + + + + + + + + + + + + + diff --git a/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs b/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs new file mode 100644 index 0000000..4aae2ac --- /dev/null +++ b/tests/LocalStack.Client.NativeAot.Tests/NativeAotTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Threading.Tasks; +using Amazon; +using Amazon.Extensions.NETCore.Setup; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using LocalStack.Client.Contracts; +using LocalStack.Client.Enums; +using LocalStack.Client.Extensions; +using LocalStack.Client.Options; +using LocalStack.Client.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LocalStack.Client.NativeAot.Tests; + +public sealed class NativeAotTests +{ + // The Type-based overloads are part of the public contract under test. + private static readonly Type S3Interface = typeof(IAmazonS3); + private static readonly Type S3Implementation = typeof(AmazonS3Client); + private static readonly Type SqsInterface = typeof(IAmazonSQS); + private static readonly Type SqsImplementation = typeof(AmazonSQSClient); + + [Fact] + public void AddAwsService_Should_Configure_S3_For_LocalStack_With_Existing_Consumer_Calls() + { + using ConfigurationRoot configuration = LoadConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + services.AddAwsService(); + using ServiceProvider provider = services.BuildServiceProvider(); + var s3 = provider.GetRequiredService(); + + var client = Assert.IsType(s3); + AssertClient(client, provider.GetRequiredService(), AwsService.S3, useServiceUrl: false); + Assert.Equal("eu-central-1", s3.Config.RegionEndpoint.SystemName); + } + + [Theory] + [InlineData("add", false)] + [InlineData("add", true)] + [InlineData("add-options", false)] + [InlineData("add-options", true)] + [InlineData("alias", false)] + [InlineData("alias", true)] + [InlineData("alias-options", false)] + [InlineData("alias-options", true)] + [InlineData("try-add", false)] + [InlineData("try-add", true)] + [InlineData("try-add-options", false)] + [InlineData("try-add-options", true)] + [InlineData("try-alias", false)] + [InlineData("try-alias", true)] + [InlineData("try-alias-options", false)] + [InlineData("try-alias-options", true)] + public static void Registration_Overloads_Should_Configure_S3_For_LocalStack(string overload, bool useServiceUrl) + { + using ConfigurationRoot configuration = LoadConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + var options = new AWSOptions { Region = RegionEndpoint.USWest2, Credentials = new AnonymousAWSCredentials() }; + const ServiceLifetime lifetime = ServiceLifetime.Transient; + + switch (overload) + { + case "add": + services.AddAwsService(lifetime, useServiceUrl); + break; + case "add-options": + services.AddAwsService(options, lifetime, useServiceUrl); + break; + case "alias": + services.AddAWSServiceLocalStack(lifetime, useServiceUrl); + break; + case "alias-options": + services.AddAWSServiceLocalStack(options, lifetime, useServiceUrl); + break; + case "try-add": + services.TryAddAwsService(lifetime, useServiceUrl); + break; + case "try-add-options": + services.TryAddAwsService(options, lifetime, useServiceUrl); + break; + case "try-alias": + services.TryAddAWSServiceLocalStack(lifetime, useServiceUrl); + break; + case "try-alias-options": + services.TryAddAWSServiceLocalStack(options, lifetime, useServiceUrl); + break; + default: + throw new ArgumentOutOfRangeException(nameof(overload)); + } + + using ServiceProvider provider = services.BuildServiceProvider(); + var client = Assert.IsType(provider.GetRequiredService()); + AssertClient(client, provider.GetRequiredService(), AwsService.S3, useServiceUrl); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public static void Session_Should_Configure_S3_Clients_On_Every_Creation_Path(bool useServiceUrl, bool useSsl, bool standalone) + { + var configOptions = new ConfigOptions(localStackHost: "native-aot-host", useSsl: useSsl, edgePort: 4566); + var config = new Config(configOptions); + ISession session = CreateSession(configOptions, standalone); + + using AmazonServiceClient genericInterface = session.CreateClientByInterface(useServiceUrl); + using AmazonServiceClient typeInterface = session.CreateClientByInterface(S3Interface, useServiceUrl); + using AmazonS3Client genericImplementation = session.CreateClientByImplementation(useServiceUrl); + using AmazonServiceClient typeImplementation = session.CreateClientByImplementation(S3Implementation, useServiceUrl); + Assert.IsType(genericInterface); + Assert.IsType(typeInterface); + Assert.IsType(genericImplementation); + Assert.IsType(typeImplementation); + AssertClient(genericInterface, config, AwsService.S3, useServiceUrl); + AssertClient(typeInterface, config, AwsService.S3, useServiceUrl); + AssertClient(genericImplementation, config, AwsService.S3, useServiceUrl); + AssertClient(typeImplementation, config, AwsService.S3, useServiceUrl); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public static void Session_Should_Configure_Sqs_Clients_On_Every_Creation_Path(bool useServiceUrl, bool useSsl, bool standalone) + { + var configOptions = new ConfigOptions(localStackHost: "native-aot-host", useSsl: useSsl, edgePort: 4566); + var config = new Config(configOptions); + ISession session = CreateSession(configOptions, standalone); + + using AmazonServiceClient genericInterface = session.CreateClientByInterface(useServiceUrl); + using AmazonServiceClient typeInterface = session.CreateClientByInterface(SqsInterface, useServiceUrl); + using AmazonSQSClient genericImplementation = session.CreateClientByImplementation(useServiceUrl); + using AmazonServiceClient typeImplementation = session.CreateClientByImplementation(SqsImplementation, useServiceUrl); + Assert.IsType(genericInterface); + Assert.IsType(typeInterface); + Assert.IsType(genericImplementation); + Assert.IsType(typeImplementation); + AssertClient(genericInterface, config, AwsService.Sqs, useServiceUrl); + AssertClient(typeInterface, config, AwsService.Sqs, useServiceUrl); + AssertClient(genericImplementation, config, AwsService.Sqs, useServiceUrl); + AssertClient(typeImplementation, config, AwsService.Sqs, useServiceUrl); + } + + [Fact] + public async Task S3_Should_Create_And_List_A_Bucket_Against_LocalStack() + { + using ConfigurationRoot configuration = LoadLiveConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + services.AddAwsService(); + using ServiceProvider provider = services.BuildServiceProvider(); + var s3 = provider.GetRequiredService(); + string bucket = $"native-aot-{Guid.NewGuid():N}"; + + await s3.PutBucketAsync(new PutBucketRequest { BucketName = bucket }, TestContext.Current.CancellationToken); + ListBucketsResponse response = await s3.ListBucketsAsync(new ListBucketsRequest(), TestContext.Current.CancellationToken); + Assert.Contains(response.Buckets, item => string.Equals(bucket, item.BucketName, StringComparison.Ordinal)); + } + + [Fact] + public async Task Sqs_Should_Create_And_List_A_Queue_Against_LocalStack() + { + using ConfigurationRoot configuration = LoadLiveConfiguration(); + var services = new ServiceCollection(); + services.AddLocalStack(configuration); + services.AddAwsService(); + using ServiceProvider provider = services.BuildServiceProvider(); + var sqs = provider.GetRequiredService(); + string queue = $"native-aot-{Guid.NewGuid():N}"; + + CreateQueueResponse created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = queue }, TestContext.Current.CancellationToken); + ListQueuesResponse response = await sqs.ListQueuesAsync(new ListQueuesRequest { QueueNamePrefix = queue }, TestContext.Current.CancellationToken); + Assert.EndsWith($"/{queue}", created.QueueUrl, StringComparison.Ordinal); + Assert.Contains(response.QueueUrls, url => string.Equals(created.QueueUrl, url, StringComparison.Ordinal)); + } + + private static ConfigurationRoot LoadConfiguration() => (ConfigurationRoot)new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false) + .AddEnvironmentVariables() + .Build(); + + private static ConfigurationRoot LoadLiveConfiguration() + { + string? host = Environment.GetEnvironmentVariable("LOCALSTACK_NATIVE_AOT_HOST"); + if (string.IsNullOrEmpty(host)) + { + Assert.Skip("LOCALSTACK_NATIVE_AOT_HOST is not set"); + } + + ConfigurationRoot configuration = LoadConfiguration(); + configuration["LocalStack:Config:LocalStackHost"] = host; + return configuration; + } + + private static ISession CreateSession(ConfigOptions configOptions, bool standalone) + { + var sessionOptions = new SessionOptions("native-aot-id", "native-aot-secret", "native-aot-token", "eu-central-1"); + return standalone + ? SessionStandalone.Init().WithSessionOptions(sessionOptions).WithConfigurationOptions(configOptions).Create() + : new Session(sessionOptions, new Config(configOptions), new SessionReflection()); + } + + private static void AssertClient(AmazonServiceClient client, IConfig config, AwsService service, bool useServiceUrl) + { + var endpoint = config.GetAwsServiceEndpoint(service); + Assert.NotNull(endpoint); + if (useServiceUrl) + { + Assert.Equal(endpoint.ServiceUrl.AbsoluteUri, client.Config.ServiceURL); + Assert.Null(client.Config.RegionEndpoint); + } + else + { + Assert.Null(client.Config.ServiceURL); + Assert.Equal("eu-central-1", client.Config.RegionEndpoint.SystemName); + } + + Assert.Equal(!config.GetConfigOptions().UseSsl, client.Config.UseHttp); + Assert.Equal(endpoint.Host, client.Config.ProxyHost); + Assert.Equal(endpoint.Port, client.Config.ProxyPort); + if (service == AwsService.S3) + { + Assert.True(Assert.IsType(client.Config).ForcePathStyle); + } + } +} diff --git a/tests/LocalStack.Client.NativeAot.Tests/appsettings.json b/tests/LocalStack.Client.NativeAot.Tests/appsettings.json new file mode 100644 index 0000000..b70ac8d --- /dev/null +++ b/tests/LocalStack.Client.NativeAot.Tests/appsettings.json @@ -0,0 +1,17 @@ +{ + "LocalStack": { + "UseLocalStack": true, + "Session": { + "AwsAccessKeyId": "native-aot-id", + "AwsAccessKey": "native-aot-secret", + "AwsSessionToken": "native-aot-token", + "RegionName": "eu-central-1" + }, + "Config": { + "LocalStackHost": "localhost", + "UseSsl": false, + "UseLegacyPorts": false, + "EdgePort": 4566 + } + } +}