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
+ }
+ }
+}