From f9fb9e46733b9fdc8d986148448e39d6fcef0529 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 01:54:07 +0300 Subject: [PATCH 01/15] chore(deps): refresh dependencies and add AWS SDK compatibility tracks Updates AWSSDK.Core to 4.0.100.6 and AWSSDK.Extensions.NETCore.Setup to 4.0.100.5 (near-latest, well-adopted, both published 2026-07-17), 120 AWSSDK service packages, Microsoft.Extensions.* to 10.0.10, and the analyzer/test toolchain to latest. Resolves NU1901 (GHSA-9cvc-h2w8-phrp) on AWSSDK.Core 4.0.0.15, which was failing restore outright because TreatWarningsAsErrors promotes the advisory to an error. The previous pin was 403 days old. Adds the $(AwsSetupTrack) switch to Directory.Packages.props so the whole package graph can move between AWSSDK.Extensions.NETCore.Setup shapes at once: current (default) - post-4.0.4 ClientFactory(AWSOptions, Action<...>) legacy - pre-4.0.4 ClientFactory(AWSOptions) latest - floating, scheduled canary only A per-project VersionOverride does not work here: src and tests must move together or NuGet reports NU1605. Floating versions are opt-in under CPM (NU1011), so they are enabled only on the canary track. Exposed through Cake as --aws-setup-track. Two upstream behaviour changes required source adaptations, none in src/: - Testcontainers 4.13.0 obsoleted the parameterless LocalStackBuilder() ctor. - AWSSDK.Core moved constructor-supplied credentials from Config.DefaultAWSCredentials to the new protected internal ExplicitAWSCredentials, leaving the old property null. Caught immediately by the existing unit tests. New analyzer diagnostics fixed rather than suppressed, except CA1873 which joins the CA1848/CA2254 logging-performance opt-out already present in those test projects. Note S8969 flagged eight null-forgiving operators of which seven were NOT redundant - they masked CS8620 nullability variance; the correct fix was Dictionary. Also updates global.json to SDK 10.0.302 and the LICENSE copyright range to 2026. --- .mcp.json | 22 ++ Directory.Packages.props | 341 ++++++++++-------- LICENSE | 2 +- build/LocalStack.Build/BuildContext.cs | 14 +- build/LocalStack.Build/CakeTasks/BuildTask.cs | 8 +- .../Nuget/Services/PackageOperations.cs | 2 +- build/LocalStack.Build/CakeTasks/TestTask.cs | 8 +- global.json | 4 +- src/LocalStack.Client.Extensions/LICENSE.txt | 2 +- src/LocalStack.Client.Extensions/README.md | 6 + src/LocalStack.Client/LICENSE.txt | 2 +- src/LocalStack.Client/README.md | 6 + .../ServiceCollectionExtensionsTests.cs | 24 +- .../CloudFormationProvisioner.cs | 2 +- .../Fixtures/TestFixture.cs | 4 +- .../LocalStack.Client.Functional.Tests.csproj | 2 +- .../TestContainers.cs | 2 +- .../MockAmazonServiceClient.cs | 4 +- .../Program.cs | 2 +- ...tack.Client.Sandbox.WithGenericHost.csproj | 2 +- .../SampleS3Service.cs | 2 +- .../LocalStack.Client.Sandbox/Program.cs | 2 +- .../sandboxes/LocalStack.Container/Program.cs | 2 +- 23 files changed, 279 insertions(+), 186 deletions(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..f000015 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "aspire": { + "command": "aspire", + "args": [ + "agent", + "mcp" + ] + }, + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/readonly", + "headers": { + "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + }, + "rider": { + "type": "sse", + "url": "http://127.0.0.1:64811/sse" + } + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 2ebfc00..3f8cad5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,177 +1,212 @@ - + + + + current + + + + + true + + + + + + + + + + + + - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + \ No newline at end of file diff --git a/LICENSE b/LICENSE index f9478d1..0299445 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2025 LocalStack.NET +Copyright (c) 2019-2026 LocalStack.NET Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index e41badd..3bcf202 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -31,7 +31,11 @@ public BuildContext(ICakeContext context) : base(context) UseDirectoryPropsVersion = context.Argument("use-directory-props-version", defaultValue: false); BranchName = context.Argument("branch-name", "master"); - var sourceBuilder = ImmutableDictionary.CreateBuilder(); + // Selects which AWSSDK.Extensions.NETCore.Setup shape to build/test against. + // See the $(AwsSetupTrack) switch in Directory.Packages.props. Values: current | legacy | latest. + AwsSetupTrack = context.Argument("aws-setup-track", "current"); + + var sourceBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); sourceBuilder.AddRange([ new KeyValuePair(MyGetPackageSource, "https://www.myget.org/F/localstack-dotnet-client/api/v3/index.json"), new KeyValuePair(NuGetPackageSource, "https://api.nuget.org/v3/index.json"), @@ -50,7 +54,7 @@ public BuildContext(ICakeContext context) : base(context) LocalStackClientProjFile = LocalStackClientFolder + context.File($"{LocalStackClientProjName}.csproj"); LocalStackClientExtProjFile = LocalStackClientExtFolder + context.File($"{LocalStackClientExtensionsProjName}.csproj"); - var packIdBuilder = ImmutableDictionary.CreateBuilder(); + var packIdBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); packIdBuilder.AddRange( [ new KeyValuePair(LocalStackClientProjName, LocalStackClientProjFile), @@ -81,6 +85,12 @@ public BuildContext(ICakeContext context) : base(context) public string BranchName { get; } + /// + /// Which AWSSDK.Extensions.NETCore.Setup constructor shape to build and test against + /// (current = post-4.0.4, legacy = pre-4.0.4, latest = floating canary). + /// + public string AwsSetupTrack { get; } + public ImmutableDictionary PackageSourceMap { get; } public ImmutableDictionary PackageIdProjMap { get; } diff --git a/build/LocalStack.Build/CakeTasks/BuildTask.cs b/build/LocalStack.Build/CakeTasks/BuildTask.cs index e07da72..68a9771 100644 --- a/build/LocalStack.Build/CakeTasks/BuildTask.cs +++ b/build/LocalStack.Build/CakeTasks/BuildTask.cs @@ -3,6 +3,12 @@ public sealed class BuildTask : FrostingTask { public override void Run(BuildContext context) { - context.DotNetBuild(context.SlnFilePath, new DotNetBuildSettings { Configuration = context.BuildConfiguration }); + ConsoleHelper.WriteInfo($"AWS SDK setup track: {context.AwsSetupTrack}"); + + context.DotNetBuild(context.SlnFilePath, new DotNetBuildSettings + { + Configuration = context.BuildConfiguration, + MSBuildSettings = new DotNetMSBuildSettings().WithProperty("AwsSetupTrack", context.AwsSetupTrack), + }); } } \ No newline at end of file diff --git a/build/LocalStack.Build/CakeTasks/Nuget/Services/PackageOperations.cs b/build/LocalStack.Build/CakeTasks/Nuget/Services/PackageOperations.cs index afdf69a..a41c14e 100644 --- a/build/LocalStack.Build/CakeTasks/Nuget/Services/PackageOperations.cs +++ b/build/LocalStack.Build/CakeTasks/Nuget/Services/PackageOperations.cs @@ -150,7 +150,7 @@ private static void ValidatePublishInputs(BuildContext context, string packageId private static void ValidatePackageVersion(BuildContext context, string packageId, string version) { - Match match = Regex.Match(version, @"^(\d+)\.(\d+)\.(\d+)([\.\-].*)*$", RegexOptions.IgnoreCase); + Match match = Regex.Match(version, @"^(\d+)\.(\d+)\.(\d+)([\.\-].*)*$", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5)); if (!match.Success) { diff --git a/build/LocalStack.Build/CakeTasks/TestTask.cs b/build/LocalStack.Build/CakeTasks/TestTask.cs index 319bcee..09b99f6 100644 --- a/build/LocalStack.Build/CakeTasks/TestTask.cs +++ b/build/LocalStack.Build/CakeTasks/TestTask.cs @@ -7,7 +7,13 @@ public override void Run(BuildContext context) var settings = new DotNetTestSettings { - NoRestore = !context.ForceRestore, NoBuild = !context.ForceBuild, Configuration = context.BuildConfiguration, Blame = true, + NoRestore = !context.ForceRestore, + NoBuild = !context.ForceBuild, + Configuration = context.BuildConfiguration, + Blame = true, + // Must match the track the solution was built with, otherwise dotnet test re-evaluates + // the project with a different AWSSDK.Extensions.NETCore.Setup version than was compiled. + MSBuildSettings = new DotNetMSBuildSettings().WithProperty("AwsSetupTrack", context.AwsSetupTrack), }; IEnumerable projMetadata = context.GetProjMetadata(); diff --git a/global.json b/global.json index 2bb272e..910367f 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "9.0.200", + "version": "10.0.302", "rollForward": "latestFeature", "allowPrerelease": false } -} \ No newline at end of file +} diff --git a/src/LocalStack.Client.Extensions/LICENSE.txt b/src/LocalStack.Client.Extensions/LICENSE.txt index f9478d1..0299445 100644 --- a/src/LocalStack.Client.Extensions/LICENSE.txt +++ b/src/LocalStack.Client.Extensions/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2025 LocalStack.NET +Copyright (c) 2019-2026 LocalStack.NET Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/LocalStack.Client.Extensions/README.md b/src/LocalStack.Client.Extensions/README.md index 88d6fb0..8343b2b 100644 --- a/src/LocalStack.Client.Extensions/README.md +++ b/src/LocalStack.Client.Extensions/README.md @@ -285,6 +285,12 @@ Linux ./build.sh --target=tests ``` +## Community + +Got questions or wild feature ideas? + +👉 Join the conversation in [GitHub Discussions](https://github.com/localstack-dotnet/localstack-dotnet-client/discussions). + ## Changelog Please refer to [`CHANGELOG.md`](CHANGELOG.md) to see the complete list of changes for each release. diff --git a/src/LocalStack.Client/LICENSE.txt b/src/LocalStack.Client/LICENSE.txt index f9478d1..0299445 100644 --- a/src/LocalStack.Client/LICENSE.txt +++ b/src/LocalStack.Client/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2025 LocalStack.NET +Copyright (c) 2019-2026 LocalStack.NET Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/LocalStack.Client/README.md b/src/LocalStack.Client/README.md index 88d6fb0..8343b2b 100644 --- a/src/LocalStack.Client/README.md +++ b/src/LocalStack.Client/README.md @@ -285,6 +285,12 @@ Linux ./build.sh --target=tests ``` +## Community + +Got questions or wild feature ideas? + +👉 Join the conversation in [GitHub Discussions](https://github.com/localstack-dotnet/localstack-dotnet-client/discussions). + ## Changelog Please refer to [`CHANGELOG.md`](CHANGELOG.md) to see the complete list of changes for each release. diff --git a/tests/LocalStack.Client.Extensions.Tests/ServiceCollectionExtensionsTests.cs b/tests/LocalStack.Client.Extensions.Tests/ServiceCollectionExtensionsTests.cs index 23a9934..49049c7 100644 --- a/tests/LocalStack.Client.Extensions.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/LocalStack.Client.Extensions.Tests/ServiceCollectionExtensionsTests.cs @@ -22,8 +22,8 @@ public void AddLocalStack_Should_Configure_LocalStackOptions_If_There_Is_Not_Loc [Fact] public void AddLocalStack_Should_Configure_LocalStackOptions_By_LocalStack_Section() { - var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", "true" } }; - IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue!).Build(); + var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", "true" } }; + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue).Build(); var serviceCollection = new ServiceCollection(); serviceCollection.AddLocalStack(configuration); @@ -61,7 +61,7 @@ public void AddLocalStack_Should_Configure_SessionOptions_By_Session_Section() const string awsSessionToken = "myawsst"; const string regionName = "mars-central-1"; - var configurationValue = new Dictionary(StringComparer.Ordinal) + var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:Session:AwsAccessKeyId", awsAccessKeyId }, { "LocalStack:Session:AwsAccessKey", awsAccessKey }, @@ -69,7 +69,7 @@ public void AddLocalStack_Should_Configure_SessionOptions_By_Session_Section() { "LocalStack:Session:RegionName", regionName }, }; - IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue!).Build(); + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue).Build(); var serviceCollection = new ServiceCollection(); serviceCollection.AddLocalStack(configuration); @@ -107,7 +107,7 @@ public void AddLocalStack_Should_Configure_ConfigOptions_By_Session_Section() const bool useLegacyPorts = true; const int edgePort = 1245; - var configurationValue = new Dictionary(StringComparer.Ordinal) + var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:Config:LocalStackHost", localStackHost }, { "LocalStack:Config:UseSsl", useSsl.ToString() }, @@ -115,7 +115,7 @@ public void AddLocalStack_Should_Configure_ConfigOptions_By_Session_Section() { "LocalStack:Config:EdgePort", edgePort.ToString(CultureInfo.InvariantCulture) }, }; - IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue!).Build(); + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue).Build(); var serviceCollection = new ServiceCollection(); serviceCollection.AddLocalStack(configuration); @@ -199,8 +199,8 @@ public void AddLocalStackServices_Should_Add_ISessionReflection_To_Container_As_ public void GetRequiredService_Should_Return_AmazonService_That_Configured_For_LocalStack_If_UseLocalStack_Is_True( bool useAlternateNameAddServiceMethod, string systemName) { - var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", "true" }, { "LocalStack:Session:RegionName", systemName } }; - IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue!).Build(); + var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", "true" }, { "LocalStack:Session:RegionName", systemName } }; + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue).Build(); var mockServiceMetadata = new MockServiceMetadata(); var mockAwsServiceEndpoint = new MockAwsServiceEndpoint(); @@ -241,8 +241,8 @@ public void GetRequiredService_Should_Return_AmazonService_That_Configured_For_LocalStack_If_UseLocalStack_Is_True_And_Should_Configure_ServiceUrl_Or_RegionEndpoint_By_Given_UseServiceUrl_Parameter( bool useAlternateNameAddServiceMethod, bool useServiceUrl) { - var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", "true" } }; - IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue!).Build(); + var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", "true" } }; + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue).Build(); var mockServiceMetadata = new MockServiceMetadata(); var mockAwsServiceEndpoint = new MockAwsServiceEndpoint(); @@ -298,8 +298,8 @@ public void GetRequiredService_Should_Use_Suitable_ClientFactory_To_Create_AwsSe int sessionInvolved = useLocalStack ? 1 : 0; int awsClientFactoryInvolved = useLocalStack ? 0 : 1; - var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", useLocalStack.ToString() } }; - IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue!).Build(); + var configurationValue = new Dictionary(StringComparer.Ordinal) { { "LocalStack:UseLocalStack", useLocalStack.ToString() } }; + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(configurationValue).Build(); var mockSession = new Mock(MockBehavior.Strict); var mockClientFactory = new Mock(MockBehavior.Strict); diff --git a/tests/LocalStack.Client.Functional.Tests/CloudFormation/CloudFormationProvisioner.cs b/tests/LocalStack.Client.Functional.Tests/CloudFormation/CloudFormationProvisioner.cs index 4798507..526a172 100644 --- a/tests/LocalStack.Client.Functional.Tests/CloudFormation/CloudFormationProvisioner.cs +++ b/tests/LocalStack.Client.Functional.Tests/CloudFormation/CloudFormationProvisioner.cs @@ -32,7 +32,7 @@ private async Task ProcessCloudFormationStackResourceAsync(CloudFormationResourc // Capture the CloudFormation stack output parameters on to the Aspire CloudFormation resource. This // allows projects that have a reference to the stack have the output parameters applied to the // projects IConfiguration. - resource.Outputs = stack!.Outputs; + resource.Outputs = stack.Outputs; resource.ProvisioningTaskCompletionSource?.TrySetResult(); } diff --git a/tests/LocalStack.Client.Functional.Tests/Fixtures/TestFixture.cs b/tests/LocalStack.Client.Functional.Tests/Fixtures/TestFixture.cs index 88e646d..eb2acc2 100644 --- a/tests/LocalStack.Client.Functional.Tests/Fixtures/TestFixture.cs +++ b/tests/LocalStack.Client.Functional.Tests/Fixtures/TestFixture.cs @@ -11,8 +11,8 @@ public ConfigurationBuilder CreateConfigureAppConfiguration(string configFile, u builder.SetBasePath(Directory.GetCurrentDirectory()); builder.AddJsonFile("appsettings.json", optional: true); builder.AddJsonFile(configFile, optional: true); - var keyValuePairs = new Dictionary(StringComparer.Ordinal) { { "LocalStack:Config:EdgePort", hostPort.ToString(CultureInfo.InvariantCulture) }, }; - builder.AddInMemoryCollection(keyValuePairs!); + var keyValuePairs = new Dictionary(StringComparer.Ordinal) { { "LocalStack:Config:EdgePort", hostPort.ToString(CultureInfo.InvariantCulture) }, }; + builder.AddInMemoryCollection(keyValuePairs); builder.AddEnvironmentVariables(); return builder; diff --git a/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj index 171edcb..10110f1 100644 --- a/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj +++ b/tests/LocalStack.Client.Functional.Tests/LocalStack.Client.Functional.Tests.csproj @@ -3,7 +3,7 @@ net8.0;net9.0 latest - $(NoWarn);CA1707;MA0006;MA0004;CA1711;CA2007;MA0132;CA1848;CA2254;S4144;CA1515 + $(NoWarn);CA1707;MA0006;MA0004;CA1711;CA2007;MA0132;CA1848;CA2254;CA1873;S4144;CA1515 diff --git a/tests/LocalStack.Client.Functional.Tests/TestContainers.cs b/tests/LocalStack.Client.Functional.Tests/TestContainers.cs index 9e9fc4f..c4b0266 100644 --- a/tests/LocalStack.Client.Functional.Tests/TestContainers.cs +++ b/tests/LocalStack.Client.Functional.Tests/TestContainers.cs @@ -6,7 +6,7 @@ internal static class TestContainers { public static LocalStackBuilder LocalStackBuilder(string version) { - return new LocalStackBuilder().WithImage($"localstack/localstack:{version}") + return new LocalStackBuilder($"localstack/localstack:{version}") .WithName($"localStack-{version}-{Guid.NewGuid().ToString().ToLower()}") .WithEnvironment("DOCKER_HOST", "unix:///var/run/docker.sock") .WithEnvironment("DEBUG", "1") diff --git a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs index 01b2e73..5eeb1f7 100644 --- a/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs +++ b/tests/common/LocalStack.Tests.Common/Mocks/MockServiceClients/MockAmazonServiceClient.cs @@ -22,7 +22,9 @@ public MockAmazonServiceClient(string awsAccessKeyId, string awsSecretAccessKey, { } - public AWSCredentials AwsCredentials => Config.DefaultAWSCredentials; + // AWSSDK.Core >= 4.0.100 stores constructor-supplied credentials in ExplicitAWSCredentials. + // Older versions assigned them to Config.DefaultAWSCredentials, which is now left null. + public AWSCredentials AwsCredentials => ExplicitAWSCredentials; #if NET8_0_OR_GREATER public static ClientConfig CreateDefaultClientConfig() diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/Program.cs b/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/Program.cs index 81d8dfa..e7a4ebe 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/Program.cs +++ b/tests/sandboxes/LocalStack.Client.Sandbox.DependencyInjection/Program.cs @@ -100,7 +100,7 @@ static async Task FindBucketLocationAsync(IAmazonS3 client, string bucke { var request = new GetBucketLocationRequest() { BucketName = bucketName }; GetBucketLocationResponse response = await client.GetBucketLocationAsync(request); - var bucketLocation = response.Location.ToString(); + var bucketLocation = response.Location.Value; return bucketLocation; } \ No newline at end of file diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj index a6a6439..fa42c52 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj +++ b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/LocalStack.Client.Sandbox.WithGenericHost.csproj @@ -4,7 +4,7 @@ Exe net8.0;net9.0 latest - $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;MA0004;CA2007 + $(NoWarn);CS0246;S125;CA1305;CA1031;CA1303;CA1848;CA1873;MA0004;CA2007 diff --git a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/SampleS3Service.cs b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/SampleS3Service.cs index e77f03f..7a670d9 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/SampleS3Service.cs +++ b/tests/sandboxes/LocalStack.Client.Sandbox.WithGenericHost/SampleS3Service.cs @@ -62,7 +62,7 @@ private static async Task FindBucketLocationAsync(IAmazonS3 client, stri { var request = new GetBucketLocationRequest() { BucketName = bucketName }; GetBucketLocationResponse response = await client.GetBucketLocationAsync(request); - var bucketLocation = response.Location.ToString(); + var bucketLocation = response.Location.Value; return bucketLocation; } diff --git a/tests/sandboxes/LocalStack.Client.Sandbox/Program.cs b/tests/sandboxes/LocalStack.Client.Sandbox/Program.cs index 96e4bf2..ca34052 100644 --- a/tests/sandboxes/LocalStack.Client.Sandbox/Program.cs +++ b/tests/sandboxes/LocalStack.Client.Sandbox/Program.cs @@ -67,7 +67,7 @@ static async Task FindBucketLocationAsync(IAmazonS3 client, string bucke { var request = new GetBucketLocationRequest() { BucketName = bucketName }; GetBucketLocationResponse response = await client.GetBucketLocationAsync(request); - var bucketLocation = response.Location.ToString(); + var bucketLocation = response.Location.Value; return bucketLocation; } \ No newline at end of file diff --git a/tests/sandboxes/LocalStack.Container/Program.cs b/tests/sandboxes/LocalStack.Container/Program.cs index 0359e91..ffa70d4 100644 --- a/tests/sandboxes/LocalStack.Container/Program.cs +++ b/tests/sandboxes/LocalStack.Container/Program.cs @@ -2,7 +2,7 @@ Console.ReadLine(); string containerId = Guid.NewGuid().ToString().ToUpperInvariant(); -LocalStackBuilder localStackBuilder = new LocalStackBuilder().WithImage($"localstack/localstack:4.6.0") +LocalStackBuilder localStackBuilder = new LocalStackBuilder("localstack/localstack:4.6.0") .WithName($"localStack-latest-{containerId}") .WithEnvironment("DOCKER_HOST", "unix:///var/run/docker.sock") .WithEnvironment("DEBUG", "1") From d53d2c59821e54207dd4d8a9fd3774e90737418f Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 01:54:25 +0300 Subject: [PATCH 02/15] fix(extensions): tolerate ClientFactory ctor change in AWS SDK 4.0.4+ (#52) AWSSDK.Extensions.NETCore.Setup 4.0.4 (2026-05-20) changed the internal ClientFactory constructor: <= 4.0.3.40 ClientFactory(AWSOptions awsOptions) >= 4.0.4 ClientFactory(AWSOptions awsOptions, Action configAction = null) The new parameter is optional, so this was source-compatible for AWS and shipped as a patch-level bump with nothing in the release notes. It is binary-breaking for our exact-signature reflection lookup, which returned null and threw LocalStackClientConfigurationException. Only the UseLocalStack:false path was affected, which is why it went unreported for ~70 days while consumers floated past 4.0.4 (our nuspec declares [4.0.2, )) and CI stayed pinned at 4.0.2. AwsClientFactoryWrapper now matches on the AWSOptions parameter rather than the full signature, prefers the fewest-parameter overload so the choice stays deterministic if AWS appends more optional parameters, and defaults any trailing arguments. Passing null for configAction is what the SDK itself does - the parameter is declared = null and null-guarded before use. Requiring AWSOptions in first position also excludes the private parameterless ctor present in every version. Failure messages now include the constructor signatures actually discovered, so the next drift is diagnosable from a bug report instead of needing a version bisect. Tests model both known shapes - plus a shape AWS has not shipped - with local stand-ins, because a test bound to the real SDK can only ever see the pinned version. The real-SDK check is deliberately shape-agnostic so it passes on either track. Verified green against 4.0.3.40, 4.0.100.5 and 4.0.100.7 (the reporter's version). Public API unchanged; the resolution helpers are internal via InternalsVisibleTo. --- CHANGELOG.md | 28 ++ .../AwsClientFactoryWrapper.cs | 153 ++++++++++- .../GlobalUsings.cs | 1 + .../LocalStack.Client.Extensions.csproj | 6 + .../AwsClientFactoryWrapperResolutionTests.cs | 256 ++++++++++++++++++ .../AwsClientFactoryWrapperTests.cs | 1 + 6 files changed, 439 insertions(+), 6 deletions(-) create mode 100644 tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 92632c8..d511667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ This document outlines the changes, updates, and important notes for the LocalSt See v1.x change log for previous versions: [CHANGELOG.md](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/sdkv3-lts/CHANGELOG.md) +## [Unreleased] + +### 🐞 Fixes + +- **Fixed `LocalStackClientConfigurationException` when `UseLocalStack` is `false` + ([#52](https://github.com/localstack-dotnet/localstack-dotnet-client/issues/52)).** + `AWSSDK.Extensions.NETCore.Setup` **4.0.4** (2026-05-20) changed the internal `ClientFactory` + constructor from `(AWSOptions)` to `(AWSOptions, Action = null)`. + Because the added parameter is optional this was source-compatible for AWS and shipped as a patch, + but it broke our exact-signature reflection lookup. `AwsClientFactoryWrapper` now matches on the + `AWSOptions` parameter instead of the full signature and defaults any trailing parameters, so both + the pre-4.0.4 and 4.0.4+ shapes work. Only the `UseLocalStack: false` path was affected. + - Failure messages now list the constructor signatures actually discovered, so future AWS SDK + changes can be diagnosed from a bug report without a version bisect. + +### 🛠️ General + +- **Dependency refresh:** `AWSSDK.Core` → 4.0.100.6, `AWSSDK.Extensions.NETCore.Setup` → 4.0.100.5, + 120 AWSSDK service packages, `Microsoft.Extensions.*` → 10.0.10, and the full analyzer/test + toolchain updated. Resolves the `NU1901` advisory (GHSA-9cvc-h2w8-phrp) on `AWSSDK.Core` 4.0.0.15. +- **AWS SDK compatibility testing:** the `AwsSetupTrack` MSBuild property + (`current` | `legacy` | `latest`) switches the whole package graph, so the same suite runs against + both the pre-4.0.4 and 4.0.4+ `ClientFactory` shapes. Exposed through Cake as + `--aws-setup-track`, wired into CI, plus a scheduled canary that floats to the newest AWS SDK. +- **Public API unchanged.** + +--- + ## [v2.0.0](https://github.com/localstack-dotnet/localstack-dotnet-client/releases/tag/v2.0.0) > **Heads‑up**: Native AOT is not yet supported in GA. diff --git a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs index ffe3977..24f1e20 100644 --- a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs +++ b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs @@ -1,4 +1,4 @@ -#pragma warning disable S3011 // We need to use reflection to access private fields for service metadata +#pragma warning disable S3011 // We need to use reflection to access private fields for service metadata #pragma warning disable CS8600,CS8603 // Not possible to get null value from this private field #pragma warning disable CA1802 // We need to use reflection to access private fields for service metadata namespace LocalStack.Client.Extensions; @@ -23,22 +23,163 @@ public AmazonServiceClient CreateServiceClient(IServiceProvider provide // Create ClientFactory Type concreteFactoryType = genericFactoryType.MakeGenericType(typeof(TClient)); - ConstructorInfo? constructor = concreteFactoryType.GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(AWSOptions) }, null); + ConstructorInfo[] candidates = concreteFactoryType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + ConstructorInfo? constructor = SelectFactoryConstructor(candidates); if (constructor == null) { - throw new LocalStackClientConfigurationException("ClientFactory missing constructor with AWSOptions parameter."); + throw new LocalStackClientConfigurationException( + $"ClientFactory has no non-public instance constructor whose first parameter is {nameof(AWSOptions)}. " + + $"Discovered constructors: {DescribeConstructors(candidates)}. " + + "This usually means the AWS SDK changed its internals; please open an issue at " + + "https://github.com/localstack-dotnet/localstack-dotnet-client/issues"); } - object factory = constructor.Invoke(new object[] { awsOptions! }); + object factory = constructor.Invoke(BuildConstructorArguments(constructor, awsOptions)); MethodInfo? createMethod = factory.GetType().GetMethod(CreateServiceClientMethodName, BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(IServiceProvider) }, null); if (createMethod == null) { - throw new LocalStackClientConfigurationException($"ClientFactory missing {CreateServiceClientMethodName}(IServiceProvider) method."); + throw new LocalStackClientConfigurationException( + $"ClientFactory missing {CreateServiceClientMethodName}(IServiceProvider) method. " + + "This usually means the AWS SDK changed its internals; please open an issue at " + + "https://github.com/localstack-dotnet/localstack-dotnet-client/issues"); } object serviceInstance = createMethod.Invoke(factory, new object[] { provider }); return (AmazonServiceClient)serviceInstance; } -} \ No newline at end of file + + /// + /// Picks the constructor to build ClientFactory<T> with. + /// + /// + /// AWS has changed this constructor before: AWSSDK.Extensions.NETCore.Setup 4.0.4 replaced + /// ClientFactory(AWSOptions) with ClientFactory(AWSOptions, Action<ClientConfig, IServiceProvider> = null), + /// which broke exact-signature matching (issue #52). It is source-compatible on their side - the new + /// parameter is optional - so it ships as a patch with no signal in the release notes. + /// + /// We therefore match on the parameter instead of the full signature, and prefer + /// the smallest overload so the choice stays deterministic if AWS appends further optional parameters. + /// Requiring in first position also naturally excludes the private parameterless + /// constructor that has been present in every version. + /// + /// + internal static ConstructorInfo? SelectFactoryConstructor(ConstructorInfo[] candidates) + { + if (candidates == null) + { + throw new ArgumentNullException(nameof(candidates)); + } + + ConstructorInfo? selected = null; + var fewestParameters = int.MaxValue; + + foreach (ConstructorInfo candidate in candidates) + { + ParameterInfo[] parameters = candidate.GetParameters(); + + if (parameters.Length == 0 || parameters[0].ParameterType != typeof(AWSOptions)) + { + continue; + } + + if (parameters.Length < fewestParameters) + { + selected = candidate; + fewestParameters = parameters.Length; + } + } + + return selected; + } + + /// + /// Builds the argument array for the selected constructor: the caller's first, + /// then each remaining parameter's own default. + /// + /// + /// Passing null for the trailing parameters is not a workaround - it is exactly what the AWS SDK + /// itself passes. In 4.0.4+ the added configAction parameter is declared = null and is + /// null-guarded before use, and AWSOptions may legitimately be null because the factory then + /// resolves it from the or IConfiguration. + /// +#if NET8_0_OR_GREATER + [RequiresDynamicCode("May instantiate value-type parameter defaults via Activator.CreateInstance.")] +#endif + internal static object?[] BuildConstructorArguments(ConstructorInfo constructor, AWSOptions? awsOptions) + { + if (constructor == null) + { + throw new ArgumentNullException(nameof(constructor)); + } + + ParameterInfo[] parameters = constructor.GetParameters(); + var arguments = new object?[parameters.Length]; + arguments[0] = awsOptions; + + for (var index = 1; index < parameters.Length; index++) + { + ParameterInfo parameter = parameters[index]; + + if (parameter.HasDefaultValue) + { + arguments[index] = parameter.DefaultValue; + } + else if (parameter.ParameterType.IsValueType) + { + arguments[index] = Activator.CreateInstance(parameter.ParameterType); + } + else + { + arguments[index] = null; + } + } + + return arguments; + } + + /// + /// Renders the discovered constructor signatures so an unexpected AWS SDK change can be diagnosed + /// straight from a bug report, instead of requiring a version bisect. + /// + internal static string DescribeConstructors(ConstructorInfo[] candidates) + { + if (candidates == null) + { + throw new ArgumentNullException(nameof(candidates)); + } + + if (candidates.Length == 0) + { + return ""; + } + + var builder = new StringBuilder(); + + foreach (ConstructorInfo candidate in candidates) + { + if (builder.Length > 0) + { + builder.Append("; "); + } + + builder.Append(".ctor("); + ParameterInfo[] parameters = candidate.GetParameters(); + + for (var index = 0; index < parameters.Length; index++) + { + if (index > 0) + { + builder.Append(", "); + } + + builder.Append(parameters[index].ParameterType.Name); + } + + builder.Append(')'); + } + + return builder.ToString(); + } +} diff --git a/src/LocalStack.Client.Extensions/GlobalUsings.cs b/src/LocalStack.Client.Extensions/GlobalUsings.cs index de1f8e2..df69e1c 100644 --- a/src/LocalStack.Client.Extensions/GlobalUsings.cs +++ b/src/LocalStack.Client.Extensions/GlobalUsings.cs @@ -2,6 +2,7 @@ global using System.Diagnostics.CodeAnalysis; global using System.Reflection; global using System.Runtime.Serialization; +global using System.Text; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; diff --git a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index d23ee89..51030d6 100644 --- a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj +++ b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj @@ -28,6 +28,12 @@ + + + + + diff --git a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs new file mode 100644 index 0000000..4e1e9ab --- /dev/null +++ b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs @@ -0,0 +1,256 @@ +// The nested *Factory types below are reflection-only stand-ins for AWS SDK ClientFactory shapes. +// They are never constructed with `new` - only through ConstructorInfo.Invoke - so the analyzers +// cannot see the usage and report them as dead code. +#pragma warning disable CA1812 // Internal class is apparently never instantiated +#pragma warning disable S1144 // Remove unused constructor of private type + +namespace LocalStack.Client.Extensions.Tests; + +/// +/// Guards the constructor-resolution logic behind . +/// +/// +/// AWSSDK.Extensions.NETCore.Setup 4.0.4 changed the internal ClientFactory<T> constructor from +/// (AWSOptions) to (AWSOptions, Action<ClientConfig, IServiceProvider> = null) and broke us +/// (issue #52). Tests that talk to the real AWS SDK can only ever see whichever version is pinned, so the shapes +/// are modelled with local stand-ins here. That covers both known shapes at once - and shapes AWS has not shipped +/// yet - without a second test project or a package-version dance. +/// +/// The companion real-SDK check is , +/// which is deliberately shape-agnostic so it passes on both -p:AwsSetupTrack=current and +/// -p:AwsSetupTrack=legacy. +/// +/// +public class AwsClientFactoryWrapperResolutionTests +{ + private const string CategoryTrait = "Category"; + private const string SdkCompatCategory = "SdkCompat"; + + [Fact] + [Trait(CategoryTrait, SdkCompatCategory)] + public void SelectFactoryConstructor_Should_Select_Ctor_On_Pre_4_0_4_Shape() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.NotNull(selected); + Assert.Single(selected.GetParameters()); + Assert.Equal(typeof(AWSOptions), selected.GetParameters()[0].ParameterType); + } + + [Fact] + [Trait(CategoryTrait, SdkCompatCategory)] + public void SelectFactoryConstructor_Should_Select_Ctor_On_Post_4_0_4_Shape() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.NotNull(selected); + Assert.Equal(2, selected.GetParameters().Length); + Assert.Equal(typeof(AWSOptions), selected.GetParameters()[0].ParameterType); + } + + [Fact] + [Trait(CategoryTrait, SdkCompatCategory)] + public void SelectFactoryConstructor_Should_Select_Ctor_On_Unknown_Future_Shape() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.NotNull(selected); + Assert.Equal(3, selected.GetParameters().Length); + Assert.Equal(typeof(AWSOptions), selected.GetParameters()[0].ParameterType); + } + + [Fact] + public void SelectFactoryConstructor_Should_Ignore_The_Parameterless_Ctor() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.NotNull(selected); + Assert.NotEmpty(selected.GetParameters()); + } + + [Fact] + public void SelectFactoryConstructor_Should_Prefer_The_Fewest_Parameter_Overload() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.NotNull(selected); + Assert.Single(selected.GetParameters()); + } + + [Fact] + public void SelectFactoryConstructor_Should_Return_Null_When_No_Ctor_Takes_AwsOptions_First() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.Null(selected); + } + + [Fact] + [Trait(CategoryTrait, SdkCompatCategory)] + public void BuildConstructorArguments_Should_Default_The_Trailing_Parameters() + { + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + var awsOptions = new AWSOptions(); + + object?[] arguments = AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions); + + Assert.Equal(2, arguments.Length); + Assert.Same(awsOptions, arguments[0]); + Assert.Null(arguments[1]); + } + + [Fact] + public void BuildConstructorArguments_Should_Default_Value_Type_Parameters_Without_Defaults() + { + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + + object?[] arguments = AwsClientFactoryWrapper.BuildConstructorArguments(constructor, new AWSOptions()); + + Assert.Equal(3, arguments.Length); + Assert.Null(arguments[1]); + Assert.Equal(0, arguments[2]); + } + + [Fact] + [Trait(CategoryTrait, SdkCompatCategory)] + public void BuildConstructorArguments_Should_Allow_Null_AwsOptions() + { + // AddAwsService() without explicit options passes null, and the AWS factory then resolves + // AWSOptions from the IServiceProvider or IConfiguration. Null must survive the call. + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + + object?[] arguments = AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions: null); + + Assert.Null(arguments[0]); + } + + [Fact] + public void BuildConstructorArguments_Should_Produce_Invokable_Arguments() + { + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + var awsOptions = new AWSOptions(); + + var created = (PostV404Factory)constructor.Invoke(AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions)); + + Assert.Same(awsOptions, created.AwsOptions); + Assert.Null(created.ConfigAction); + } + + [Fact] + public void DescribeConstructors_Should_Render_Discovered_Signatures() + { + string described = AwsClientFactoryWrapper.DescribeConstructors(CtorsOf()); + + Assert.Contains("AWSOptions", described, StringComparison.Ordinal); + Assert.Contains(".ctor(", described, StringComparison.Ordinal); + } + + [Fact] + public void DescribeConstructors_Should_Report_None_When_Empty() + { + Assert.Equal("", AwsClientFactoryWrapper.DescribeConstructors([])); + } + + [Fact] + [Trait(CategoryTrait, SdkCompatCategory)] + public void SelectFactoryConstructor_Should_Resolve_Against_The_Real_Aws_Sdk() + { + // Shape-agnostic on purpose: pre-4.0.4 exposes one parameter, 4.0.4+ exposes two. + // Both must resolve, so this passes on either AwsSetupTrack. + Type factoryType = typeof(ConfigurationException).Assembly.GetType("Amazon.Extensions.NETCore.Setup.ClientFactory`1")! + .MakeGenericType(typeof(IAmazonS3)); + + ConstructorInfo[] candidates = factoryType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(candidates); + + Assert.NotNull(selected); + + ParameterInfo[] parameters = selected.GetParameters(); + Assert.Equal(typeof(AWSOptions), parameters[0].ParameterType); + Assert.InRange(parameters.Length, 1, 2); + } + + private static ConstructorInfo[] CtorsOf() + { + return typeof(T).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + } + + /// Mirrors AWSSDK.Extensions.NETCore.Setup <= 4.0.3.40. + private sealed class PreV404Factory + { + private PreV404Factory() + { + } + + internal PreV404Factory(AWSOptions awsOptions) + { + AwsOptions = awsOptions; + } + + public AWSOptions? AwsOptions { get; } + } + + /// Mirrors AWSSDK.Extensions.NETCore.Setup >= 4.0.4. + private sealed class PostV404Factory + { + private PostV404Factory() + { + } + + internal PostV404Factory(AWSOptions awsOptions, Action? configAction = null) + { + AwsOptions = awsOptions; + ConfigAction = configAction; + } + + public AWSOptions? AwsOptions { get; } + + public Action? ConfigAction { get; } + } + + /// A shape AWS has not shipped, including a required value-type parameter. + private sealed class FutureShapeFactory + { + internal FutureShapeFactory(AWSOptions awsOptions, Action? configAction, int retryCount) + { + AwsOptions = awsOptions; + ConfigAction = configAction; + RetryCount = retryCount; + } + + public AWSOptions AwsOptions { get; } + + public Action? ConfigAction { get; } + + public int RetryCount { get; } + } + + private sealed class MultipleOverloadFactory + { + internal MultipleOverloadFactory(AWSOptions awsOptions, Action? configAction, bool flag) + { + _ = awsOptions; + _ = configAction; + _ = flag; + } + + internal MultipleOverloadFactory(AWSOptions awsOptions) + { + _ = awsOptions; + } + } + + private sealed class UnrelatedFactory + { + internal UnrelatedFactory(string name) + { + _ = name; + } + + internal UnrelatedFactory(IServiceProvider provider, AWSOptions awsOptions) + { + _ = provider; + _ = awsOptions; + } + } +} diff --git a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs index 936e416..a166137 100644 --- a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs +++ b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs @@ -54,6 +54,7 @@ public void CreateServiceClient_Should_Throw_LocalStackClientConfigurationExcept } [Fact] + [Trait("Category", "SdkCompat")] public void CreateServiceClient_Should_Create_Client_When_UseLocalStack_False() { ConfigurationBuilder configurationBuilder = new(); From 91f868e09009e7148d14cbe504a950ce4a5a5e77 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 01:54:41 +0300 Subject: [PATCH 03/15] ci: modernize workflows and add AWS SDK canary Updates every action to latest: checkout v4->v7, cache v4->v6, setup-dotnet v4->v6, upload-artifact v4->v7, dependency-review-action v4->v5, dorny/test-reporter v1->v3. setup-dotnet now also reads global-json-file so the SDK matches the repo pin, while the explicit 8.0.x/9.0.x entries are kept because the multi-TFM test run needs those runtimes present - the .NET 10 SDK alone cannot execute net8.0/net9.0 test assemblies. No setup-node pinning: nothing in the workflows uses setup-node, and update-test-badge is a composite action running bash steps. The JS action runtimes come from the actions themselves, so bumping them is what moves Node forward. Adds a Linux leg running the pre-4.0.4 AWS SDK track, so backwards compatibility is proven on every push rather than assumed. Adds a scheduled canary that floats AWSSDK.Extensions.NETCore.Setup to the newest 4.x and runs the SDK-sensitive tests against it, deliberately without NuGet caching. The pinned build is reproducible by design, which is exactly why the 4.0.4 break went unnoticed for 70 days - CI only ever saw the pinned version while consumers floated. A failure there means "AWS moved", not "we broke something". --- .github/workflows/aws-sdk-canary.yml | 65 +++++++++++++++++++++++++ .github/workflows/ci-cd.yml | 26 ++++++---- .github/workflows/dependency-review.yml | 4 +- .github/workflows/publish-nuget.yml | 9 ++-- 4 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/aws-sdk-canary.yml diff --git a/.github/workflows/aws-sdk-canary.yml b/.github/workflows/aws-sdk-canary.yml new file mode 100644 index 0000000..ae7edaa --- /dev/null +++ b/.github/workflows/aws-sdk-canary.yml @@ -0,0 +1,65 @@ +name: "AWS SDK Canary" + +# Early warning for AWS SDK drift. +# +# The normal build pins AWSSDK.* so it stays reproducible - which is exactly why the +# AWSSDK.Extensions.NETCore.Setup 4.0.4 breaking change (issue #52) went unnoticed for 70 days: +# CI only ever saw the pinned version, while consumers float to whatever is newest. +# +# This job floats AWSSDK.Extensions.NETCore.Setup to the latest 4.x and runs the SDK-sensitive +# tests against it. A failure here means "AWS moved", not "we broke something". + +on: + schedule: + # 05:00 UTC daily + - cron: "0 5 * * *" + workflow_dispatch: + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_NOLOGO: true + +jobs: + canary: + name: "Latest AWS SDK" + runs-on: ubuntu-22.04 + if: github.repository == 'localstack-dotnet/localstack-dotnet-client' + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Setup .NET SDK" + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 9.0.x + global-json-file: global.json + + - name: "Make build script executable" + run: chmod +x ./build.sh + + # Deliberately no NuGet cache: the point is to resolve the newest AWSSDK packages every run. + - name: "Build & test against floating AWS SDK" + run: ./build.sh --target tests --skipFunctionalTest true --aws-setup-track latest --force-restore true --exclusive + + - name: "Report resolved AWS SDK versions" + if: always() + run: | + echo "### Resolved AWS SDK versions" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + grep -rhoE '"AWSSDK\.(Extensions\.NETCore\.Setup|Core)/[^"]+"' \ + tests/*/obj/project.assets.json 2>/dev/null | sort -u >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: "Upload test artifacts" + uses: actions/upload-artifact@v7 + if: failure() + with: + name: canary-test-results + path: | + **/*.trx + **/TestResults/**/* + retention-days: 7 diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index c9b728a..8fd82ab 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -49,23 +49,24 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 # Full history for better caching - name: "Setup .NET SDK" - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 8.0.x 9.0.x + global-json-file: global.json - name: "Make build script executable" if: runner.os != 'Windows' run: chmod +x ./build.sh - name: "Cache NuGet packages" - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ runner.os == 'Windows' && format('{0}\.nuget\packages', github.workspace) || format('{0}/.nuget/packages', github.workspace) }} key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj', '**/Directory.Packages.props') }} @@ -78,9 +79,15 @@ jobs: - name: "Run Tests" run: ${{ matrix.script }} --target tests --skipFunctionalTest ${{ runner.os == 'Linux' && 'false' || 'true' }} --exclusive + # AWSSDK.Extensions.NETCore.Setup 4.0.4 changed the internal ClientFactory constructor (issue #52). + # The default track pins a post-4.0.4 version; this leg proves we still work against the older shape. + - name: "Run Tests (legacy AWS SDK track, pre-4.0.4)" + if: runner.os == 'Linux' + run: ${{ matrix.script }} --target tests --skipFunctionalTest true --aws-setup-track legacy --force-restore true --exclusive + - name: "Publish Test Results" id: test-results - uses: dorny/test-reporter@v1 + uses: dorny/test-reporter@v3 if: success() || failure() with: name: "Test Results (${{ matrix.name }})" @@ -108,7 +115,7 @@ jobs: server_url: ${{ github.server_url }} - name: "Upload Test Artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: test-results-${{ matrix.name }} @@ -134,19 +141,20 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: "Setup .NET SDK" - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 8.0.x 9.0.x + global-json-file: global.json - name: "Cache NuGet packages" - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj', '**/Directory.Packages.props') }} @@ -207,7 +215,7 @@ jobs: --package-secret ${{ secrets.GITHUB_TOKEN }} - name: "Upload Package Artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: "packages-${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}-${{ github.run_number }}" path: | diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f51a099..a5e5e83 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-22.04 steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: "Dependency Review" - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 with: # Fail the check if a vulnerability with 'moderate' severity or higher is found. fail-on-severity: moderate diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 11a75aa..2437f33 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -44,17 +44,18 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: "Setup .NET SDK" - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 8.0.x 9.0.x + global-json-file: global.json - name: "Cache NuGet packages" - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ github.workspace }}/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj', '**/Directory.Packages.props') }} @@ -124,7 +125,7 @@ jobs: --package-secret ${{ secrets.NUGET_API_KEY }} - name: "Upload Package Artifacts" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: "packages-${{ github.event.inputs.package-id }}-${{ github.event.inputs.package-version }}" path: | From 3c63ac6ae4668a66d877ca18e11cad280a737388 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 10:34:09 +0300 Subject: [PATCH 04/15] ci: install Mono on the macOS runner for net472 tests The macos-latest image no longer ships Mono, so VSTest aborts the net472 run with "Could not find 'mono' host". Linux is unaffected - ubuntu-22.04 still has it. Guarded by a presence check so the step becomes a no-op if a future image brings Mono back. --- .github/workflows/ci-cd.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8fd82ab..1b8f1cc 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -65,6 +65,18 @@ jobs: if: runner.os != 'Windows' run: chmod +x ./build.sh + # The macOS runner image no longer ships Mono, and VSTest needs the real 'mono' host + # on the PATH to execute the net472 test assemblies. + - name: "Install Mono (macOS)" + if: runner.os == 'macOS' + run: | + if command -v mono >/dev/null 2>&1; then + echo "✅ Mono already present: $(mono --version | head -1)" + else + brew install mono + echo "✅ Installed: $(mono --version | head -1)" + fi + - name: "Cache NuGet packages" uses: actions/cache@v6 with: From c3176a1c76799ed9fa47bcf49b1e84d40fd61fd7 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 10:53:24 +0300 Subject: [PATCH 05/15] test(functional): guarantee per-test resource teardown in shared containers Scenario classes in a collection share one LocalStack container, so anything a test leaves behind is visible to every test that runs afterwards - including in other classes. That made assertions about container state order dependent: Multi_Region_Tests_Async("eu-central-1") asserts Assert.Single over ListTopics and failed once another class left a topic alive. The same test passed or failed depending on which subset was run, which is the definition of a non-isolated test. The assertion is deliberately left untouched. Asserting more than strictly necessary is what surfaced the leak in the first place; weakening it would hide the defect rather than fix it. Cleanup previously sat at the end of the test body, so it never ran when an assertion threw - one red test would poison every test after it. BaseScenario now implements IAsyncLifetime: xUnit builds a fresh instance per test method and awaits DisposeAsync regardless of outcome, which is exactly per-test teardown. TrackForCleanup(resourceId, cleanup) - registers a resource for removal UntrackCleanup(resourceId) - drops it when deletion is the behaviour under test Teardown runs in reverse registration order, so a subscription goes before the topic it depends on, and reports failures via AggregateException instead of swallowing them - a leaked resource is a defect and should be loud. Applied across every scenario that creates resources: - BaseRealLife created a topic, a queue and a subscription and deleted none of them. - BaseCloudFormationScenario never deleted its stack, so the template's ChatTopic survived. Deletion now waits for DELETE_COMPLETE, because DeleteStack returns before the resources it owns are actually gone. - SNS/SQS/S3/DynamoDB create helpers register cleanup; delete helpers de-register. - S3 buckets are emptied via AmazonS3Util.DeleteS3BucketWithObjectsAsync, since S3 refuses to drop a non-empty bucket and these scenarios upload into it. Verified: full functional suite 50/50 on net8.0 and net9.0, and the previously order-dependent subsets (~SNS, ~Scenarios.SNS, ~CloudFormation) now agree. --- .../GlobalUsings.cs | 1 + .../Scenarios/BaseScenario.cs | 92 ++++++++++++++++++- .../BaseCloudFormationScenario.cs | 46 ++++++++++ .../DynamoDb/BaseDynamoDbScenario.cs | 23 +++-- .../Scenarios/RealLife/BaseRealLife.cs | 8 ++ .../Scenarios/S3/BaseS3Scenario.cs | 23 +++-- .../Scenarios/SNS/BaseSnsScenario.cs | 4 + .../Scenarios/SQS/BaseSqsScenario.cs | 20 +++- 8 files changed, 199 insertions(+), 18 deletions(-) diff --git a/tests/LocalStack.Client.Functional.Tests/GlobalUsings.cs b/tests/LocalStack.Client.Functional.Tests/GlobalUsings.cs index 337ce58..9a94dd8 100644 --- a/tests/LocalStack.Client.Functional.Tests/GlobalUsings.cs +++ b/tests/LocalStack.Client.Functional.Tests/GlobalUsings.cs @@ -22,6 +22,7 @@ global using Amazon.DynamoDBv2.DocumentModel; global using Amazon.DynamoDBv2.Model; global using Amazon.S3; +global using Amazon.S3.Util; global using Amazon.S3.Model; global using Amazon.S3.Transfer; global using Amazon.SQS; diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/BaseScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/BaseScenario.cs index 0f45c02..d4f47c0 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/BaseScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/BaseScenario.cs @@ -1,7 +1,25 @@ -namespace LocalStack.Client.Functional.Tests.Scenarios; +namespace LocalStack.Client.Functional.Tests.Scenarios; -public abstract class BaseScenario +/// +/// Base for every functional scenario. Owns the service provider and, crucially, the cleanup of any +/// AWS resource a test creates. +/// +/// +/// All scenario classes in a collection share a single LocalStack container, so a resource left behind by +/// one test is visible to every test that runs after it - including in other classes. That makes assertions +/// about container state (for example Assert.Single over ListTopics) order dependent, and an +/// order-dependent test is not isolated. +/// +/// xUnit creates a new instance of the test class for every test method and awaits +/// afterwards whether the test passed or failed. Registering cleanup here therefore gives per-test teardown +/// that still runs when an assertion throws - which the previous "delete at the end of the test body" pattern +/// could not do. +/// +/// +public abstract class BaseScenario : IAsyncLifetime { + private readonly List _cleanups = []; + protected BaseScenario(TestFixture testFixture, ILocalStackFixture localStackFixture, string configFile = TestConstants.LocalStackConfig, bool useServiceUrl = false) { ArgumentNullException.ThrowIfNull(testFixture); @@ -30,4 +48,72 @@ protected BaseScenario(TestFixture testFixture, ILocalStackFixture localStackFix protected IConfiguration Configuration { get; set; } protected ServiceProvider ServiceProvider { get; private set; } -} \ No newline at end of file + + public virtual Task InitializeAsync() + { + return Task.CompletedTask; + } + + /// + /// Removes everything the test registered, most recent first, then fails the test if anything survived. + /// + /// + /// Reverse order matters: a queue subscribed to a topic has to go before the topic it depends on. + /// Cleanup failures are reported rather than swallowed - a leaked resource is a defect, and staying silent + /// about it is what let the shared container drift in the first place. + /// + [SuppressMessage("Design", "CA1031:Do not catch general exception types", + Justification = "Teardown must attempt every registered resource and report all failures together; narrowing the catch would let one AWS service's error abandon the rest.")] + public virtual async Task DisposeAsync() + { + List? failures = null; + + for (int index = _cleanups.Count - 1; index >= 0; index--) + { + CleanupRegistration registration = _cleanups[index]; + + try + { + await registration.Cleanup().ConfigureAwait(false); + } + catch (Exception exception) + { + (failures ??= []).Add(new System.InvalidOperationException($"Failed to clean up '{registration.ResourceId}'.", exception)); + } + } + + _cleanups.Clear(); + await ServiceProvider.DisposeAsync().ConfigureAwait(false); + + if (failures is not null) + { + throw new AggregateException( + "Test resources were left behind. Scenario classes share one LocalStack container per collection, so a leak makes later tests order dependent.", + failures); + } + } + + /// + /// Registers a resource to be removed after the current test, whatever its outcome. + /// + /// Identifier used both for de-registration and for cleanup failure messages. + /// The removal call. + protected void TrackForCleanup(string resourceId, Func cleanup) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceId); + ArgumentNullException.ThrowIfNull(cleanup); + + _cleanups.Add(new CleanupRegistration(resourceId, cleanup)); + } + + /// + /// Drops a registration because the test removed the resource itself - deletion being the behaviour under + /// test - so teardown does not try to delete it twice. + /// + protected void UntrackCleanup(string resourceId) + { + _cleanups.RemoveAll(registration => string.Equals(registration.ResourceId, resourceId, StringComparison.Ordinal)); + } + + private sealed record CleanupRegistration(string ResourceId, Func Cleanup); +} diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs index ecc3f22..9f6bbd0 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs @@ -2,6 +2,9 @@ public abstract class BaseCloudFormationScenario : BaseScenario { + private static readonly TimeSpan StackDeletionTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan StackDeletionPollInterval = TimeSpan.FromMilliseconds(200); + protected BaseCloudFormationScenario(TestFixture testFixture, ILocalStackFixture localStackFixture, string configFile = TestConstants.LocalStackConfig, bool useServiceUrl = false) : base(testFixture, localStackFixture, configFile, useServiceUrl) { @@ -30,6 +33,9 @@ public virtual async Task CloudFormationService_Should_Create_A_CloudFormation_S var cloudFormationResource = new CloudFormationResource(stackName, templatePath); cloudFormationResource.AddParameter("DefaultVisibilityTimeout", "30"); + // Registered before provisioning so a partially created stack is still torn down. + TrackForCleanup(stackName, () => DeleteStackAndWaitAsync(stackName)); + await CloudFormationProvisioner.ConfigureCloudFormationAsync(cloudFormationResource); DescribeStacksResponse response = await AmazonCloudFormation.DescribeStacksAsync(new DescribeStacksRequest() { StackName = stackName }); @@ -57,4 +63,44 @@ public virtual async Task CloudFormationService_Should_Create_A_CloudFormation_S Assert.NotNull(queueAttResponse.Attributes["QueueArn"]); } } + + /// + /// Deletes the stack and waits until CloudFormation has actually removed it. + /// + /// + /// DeleteStack returns as soon as the request is accepted. The SNS topic and SQS queue the template + /// owns only disappear once deletion completes, so returning early would leave them in the shared container + /// for whichever test runs next. + /// + private async Task DeleteStackAndWaitAsync(string stackName) + { + await AmazonCloudFormation.DeleteStackAsync(new DeleteStackRequest { StackName = stackName }).ConfigureAwait(false); + + using var timeout = new CancellationTokenSource(StackDeletionTimeout); + + while (!timeout.IsCancellationRequested) + { + try + { + DescribeStacksResponse response = + await AmazonCloudFormation.DescribeStacksAsync(new DescribeStacksRequest { StackName = stackName }).ConfigureAwait(false); + + Stack? stack = response.Stacks?.FirstOrDefault(); + + if (stack is null || stack.StackStatus == StackStatus.DELETE_COMPLETE) + { + return; + } + } + catch (AmazonCloudFormationException) + { + // Once the stack is fully gone CloudFormation refuses to describe it, which is the success signal. + return; + } + + await Task.Delay(StackDeletionPollInterval).ConfigureAwait(false); + } + + throw new TimeoutException($"CloudFormation stack '{stackName}' was not deleted within {StackDeletionTimeout.TotalSeconds:0} seconds."); + } } \ No newline at end of file diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/BaseDynamoDbScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/BaseDynamoDbScenario.cs index 79261b5..484c417 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/BaseDynamoDbScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/DynamoDb/BaseDynamoDbScenario.cs @@ -115,8 +115,10 @@ public virtual async Task DynamoDbService_Should_List_Records_In_A_DynamoDb_Tabl }); } - protected Task CreateTestTableAsync(string? tableName = null) + protected async Task CreateTestTableAsync(string? tableName = null) { + string effectiveTableName = tableName ?? TestTableName; + var postTableCreateRequest = new CreateTableRequest { AttributeDefinitions = @@ -125,7 +127,7 @@ protected Task CreateTestTableAsync(string? tableName = nul new AttributeDefinition { AttributeName = nameof(MovieEntity.CreateDate), AttributeType = ScalarAttributeType.S }, new AttributeDefinition { AttributeName = nameof(MovieEntity.MovieId), AttributeType = ScalarAttributeType.S }, ], - TableName = tableName ?? TestTableName, + TableName = effectiveTableName, KeySchema = [ new KeySchemaElement { AttributeName = nameof(MovieEntity.DirectorId), KeyType = KeyType.HASH }, @@ -145,13 +147,22 @@ protected Task CreateTestTableAsync(string? tableName = nul ProvisionedThroughput = new ProvisionedThroughput { ReadCapacityUnits = 5, WriteCapacityUnits = 6 }, }; - return DynamoDb.CreateTableAsync(postTableCreateRequest); + CreateTableResponse createTableResponse = await DynamoDb.CreateTableAsync(postTableCreateRequest); + + TrackForCleanup(effectiveTableName, () => DynamoDb.DeleteTableAsync(new DeleteTableRequest(effectiveTableName))); + + return createTableResponse; } - protected Task DeleteTestTableAsync(string? tableName = null) + protected async Task DeleteTestTableAsync(string? tableName = null) { - var deleteTableRequest = new DeleteTableRequest(tableName ?? TestTableName); + string effectiveTableName = tableName ?? TestTableName; + var deleteTableRequest = new DeleteTableRequest(effectiveTableName); + + DeleteTableResponse deleteTableResponse = await DynamoDb.DeleteTableAsync(deleteTableRequest); + + UntrackCleanup(effectiveTableName); - return DynamoDb.DeleteTableAsync(deleteTableRequest); + return deleteTableResponse; } } \ No newline at end of file diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/RealLife/BaseRealLife.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/RealLife/BaseRealLife.cs index 11af890..ea4afa1 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/RealLife/BaseRealLife.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/RealLife/BaseRealLife.cs @@ -26,11 +26,15 @@ public virtual async Task var createTopicRequest = new CreateTopicRequest(topicName); CreateTopicResponse createTopicResponse = await AmazonSimpleNotificationService.CreateTopicAsync(createTopicRequest); + TrackForCleanup(createTopicResponse.TopicArn, () => AmazonSimpleNotificationService.DeleteTopicAsync(new DeleteTopicRequest(createTopicResponse.TopicArn))); + Assert.Equal(HttpStatusCode.OK, createTopicResponse.HttpStatusCode); var createQueueRequest = new CreateQueueRequest(queueName); CreateQueueResponse createQueueResponse = await AmazonSqs.CreateQueueAsync(createQueueRequest); + TrackForCleanup(createQueueResponse.QueueUrl, () => AmazonSqs.DeleteQueueAsync(new DeleteQueueRequest(createQueueResponse.QueueUrl))); + Assert.Equal(HttpStatusCode.OK, createQueueResponse.HttpStatusCode); const string queueArnAttribute = "QueueArn"; @@ -44,6 +48,10 @@ public virtual async Task var subscribeRequest = new SubscribeRequest(createTopicResponse.TopicArn, "sqs", queueArn); SubscribeResponse subscribeResponse = await AmazonSimpleNotificationService.SubscribeAsync(subscribeRequest); + // Registered after the topic and queue so teardown removes it first - LIFO. + TrackForCleanup(subscribeResponse.SubscriptionArn, + () => AmazonSimpleNotificationService.UnsubscribeAsync(new UnsubscribeRequest(subscribeResponse.SubscriptionArn))); + Assert.Equal(HttpStatusCode.OK, subscribeResponse.HttpStatusCode); string serializedObject = JsonConvert.SerializeObject(jobCreatedEvent); diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/S3/BaseS3Scenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/S3/BaseS3Scenario.cs index bc60c53..b8a7613 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/S3/BaseS3Scenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/S3/BaseS3Scenario.cs @@ -83,18 +83,29 @@ public async Task S3Service_Should_List_Files_In_A_Bucket_Async() Assert.All(fileNames, s => Assert.NotNull(s3Objects.Find(o => o.Key == s))); } - protected Task CreateTestBucketAsync(string? bucketName = null) + protected async Task CreateTestBucketAsync(string? bucketName = null) { - var putBucketRequest = new PutBucketRequest { BucketName = bucketName ?? BucketName, UseClientRegion = true }; + string effectiveBucketName = bucketName ?? BucketName; + var putBucketRequest = new PutBucketRequest { BucketName = effectiveBucketName, UseClientRegion = true }; - return AmazonS3.PutBucketAsync(putBucketRequest); + PutBucketResponse putBucketResponse = await AmazonS3.PutBucketAsync(putBucketRequest); + + // S3 refuses to drop a non-empty bucket, and these scenarios upload into it, so the objects go first. + TrackForCleanup(effectiveBucketName, () => AmazonS3Util.DeleteS3BucketWithObjectsAsync(AmazonS3, effectiveBucketName)); + + return putBucketResponse; } - protected Task DeleteTestBucketAsync(string? bucketName = null) + protected async Task DeleteTestBucketAsync(string? bucketName = null) { - var deleteBucketRequest = new DeleteBucketRequest { BucketName = bucketName ?? BucketName, UseClientRegion = true }; + string effectiveBucketName = bucketName ?? BucketName; + var deleteBucketRequest = new DeleteBucketRequest { BucketName = effectiveBucketName, UseClientRegion = true }; + + DeleteBucketResponse deleteBucketResponse = await AmazonS3.DeleteBucketAsync(deleteBucketRequest); + + UntrackCleanup(effectiveBucketName); - return AmazonS3.DeleteBucketAsync(deleteBucketRequest); + return deleteBucketResponse; } protected async Task UploadTestFileAsync(string? key = null, string? bucketName = null) diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/SNS/BaseSnsScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/SNS/BaseSnsScenario.cs index 97f2912..43d8f0e 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/SNS/BaseSnsScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/SNS/BaseSnsScenario.cs @@ -106,6 +106,8 @@ protected async Task CreateSnsTopicAsync(string topic) CreateTopicResponse createTopicResponse = await AmazonSimpleNotificationService.CreateTopicAsync(createTopicRequest); + TrackForCleanup(createTopicResponse.TopicArn, () => AmazonSimpleNotificationService.DeleteTopicAsync(new DeleteTopicRequest(createTopicResponse.TopicArn))); + return createTopicResponse; } @@ -115,6 +117,8 @@ protected async Task DeleteSnsTopicAsync(string topic) DeleteTopicResponse deleteTopicResponse = await AmazonSimpleNotificationService.DeleteTopicAsync(deleteTopicRequest); + UntrackCleanup(topic); + return deleteTopicResponse; } } \ No newline at end of file diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/SQS/BaseSqsScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/SQS/BaseSqsScenario.cs index a5020bd..130cc04 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/SQS/BaseSqsScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/SQS/BaseSqsScenario.cs @@ -109,6 +109,8 @@ protected async Task CreateFifoQueueWithRedriveAsync(string CreateQueueResponse createDlqResult = await AmazonSqs.CreateQueueAsync(createDlqRequest); + TrackForCleanup(createDlqResult.QueueUrl, () => AmazonSqs.DeleteQueueAsync(new DeleteQueueRequest(createDlqResult.QueueUrl))); + GetQueueAttributesResponse attributes = await AmazonSqs.GetQueueAttributesAsync(new GetQueueAttributesRequest { QueueUrl = createDlqResult.QueueUrl, @@ -127,14 +129,22 @@ protected async Task CreateFifoQueueWithRedriveAsync(string }, }; - return await AmazonSqs.CreateQueueAsync(createQueueRequest); + CreateQueueResponse createFifoQueueResponse = await AmazonSqs.CreateQueueAsync(createQueueRequest); + + TrackForCleanup(createFifoQueueResponse.QueueUrl, () => AmazonSqs.DeleteQueueAsync(new DeleteQueueRequest(createFifoQueueResponse.QueueUrl))); + + return createFifoQueueResponse; } protected async Task CreateQueueAsync(string? queueName = null) { var createQueueRequest = new CreateQueueRequest(queueName ?? TestQueueName); - return await AmazonSqs.CreateQueueAsync(createQueueRequest); + CreateQueueResponse createQueueResponse = await AmazonSqs.CreateQueueAsync(createQueueRequest); + + TrackForCleanup(createQueueResponse.QueueUrl, () => AmazonSqs.DeleteQueueAsync(new DeleteQueueRequest(createQueueResponse.QueueUrl))); + + return createQueueResponse; } [SuppressMessage("Design", "CA1054:URI-like parameters should not be strings")] @@ -142,6 +152,10 @@ protected async Task DeleteQueueAsync(string queueUrl) { var deleteQueueRequest = new DeleteQueueRequest(queueUrl); - return await AmazonSqs.DeleteQueueAsync(deleteQueueRequest); + DeleteQueueResponse deleteQueueResponse = await AmazonSqs.DeleteQueueAsync(deleteQueueRequest); + + UntrackCleanup(queueUrl); + + return deleteQueueResponse; } } \ No newline at end of file From 1fc7ab6066ee57f44459671b9d8fd9e5f3d5c8dd Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 11:06:16 +0300 Subject: [PATCH 06/15] fix(build): repair package version generation for GitHub Packages Two defects in the dynamic version generated for nightly/feature packages. 1. Invalid SemVer between 00:00 and 09:59 UTC. The commit-SHA fallback emits DateTime.UtcNow.ToString("HHmmss"), so a build at 07:58 produced "2.0.0-.20260729.075853". SemVer 2.0.0 forbids leading zeroes in numeric pre-release identifiers and NuGet enforces it, so dotnet pack failed with "is not a valid version string" - purely as a function of the time of day. Master's last successful deploy ran at 10:38 UTC, which is why this never surfaced. Numeric identifiers with a leading zero are now prefixed, keeping them alphanumeric, which SemVer permits. A git short SHA that happens to be all digits hits the same trap and is covered by the same guard. 2. The commit SHA was never actually captured. Cake's StartProcess returned exit code 0 with empty stdout, so every build silently fell through to the timestamp branch and no published package has ever identified its commit. Reading the process directly makes the capture explicit and independent of Cake's redirection behaviour, and the fallback now logs a warning instead of passing silently. Verified locally: dotnet pack now produces 2.0.0-test.20260729.c3176a1, matching git rev-parse --short HEAD. --- build/LocalStack.Build/BuildContext.cs | 47 +++++++++++++++++++++----- build/LocalStack.Build/GlobalUsings.cs | 1 + 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index 3bcf202..6fa3f45 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -308,27 +308,37 @@ private string GetDynamicVersionFromProps(string versionPropertyName) /// Gets the short git commit SHA for version metadata /// /// Short commit SHA or timestamp fallback + [SuppressMessage("Security", "S4036:Use an absolute path for this command", + Justification = "Build tooling deliberately resolves git from PATH; the absolute location differs per developer machine and CI image.")] private string GetGitCommitSha() { try { - var processSettings = new ProcessSettings + // Cake's StartProcess returned exit code 0 but an empty stdout here, so every build fell through + // to the timestamp and no published package ever carried its commit SHA. Reading the process + // directly keeps the capture explicit and independent of Cake's redirection behaviour. + var startInfo = new System.Diagnostics.ProcessStartInfo("git", "rev-parse --short HEAD") { - Arguments = "rev-parse --short HEAD", RedirectStandardOutput = true, RedirectStandardError = true, - Silent = true, + UseShellExecute = false, + CreateNoWindow = true, }; - var exitCode = this.StartProcess("git", processSettings, out IEnumerable output); + using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(startInfo); - if (exitCode == 0 && output?.Any() == true) + if (process != null) { - string? commitSha = output.FirstOrDefault()?.Trim(); - if (!string.IsNullOrEmpty(commitSha)) + string commitSha = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(commitSha)) { - return commitSha; + return ToSemVerIdentifier(commitSha); } + + this.Warning($"'git rev-parse --short HEAD' exited with code {process.ExitCode} and no usable output; " + + "the package version will carry a timestamp instead of the commit SHA."); } } catch (Exception ex) @@ -337,7 +347,26 @@ private string GetGitCommitSha() } // Fallback to timestamp-based identifier - return DateTime.UtcNow.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); + return ToSemVerIdentifier(DateTime.UtcNow.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// Makes an identifier safe to use inside a SemVer pre-release tag. + /// + /// + /// SemVer 2.0.0 forbids leading zeroes in numeric pre-release identifiers, and NuGet enforces it. + /// The timestamp fallback produces exactly that for any build before 10:00 UTC - "075853" - which made + /// `dotnet pack` fail with "is not a valid version string" depending purely on the time of day. A git short + /// SHA can hit the same trap when it happens to be all digits. Prefixing keeps the identifier alphanumeric, + /// which SemVer allows to start with anything. + /// + private static string ToSemVerIdentifier(string identifier) + { + bool isNumericWithLeadingZero = identifier.Length > 1 + && identifier[0] == '0' + && identifier.All(char.IsDigit); + + return isNumericWithLeadingZero ? $"g{identifier}" : identifier; } private string[] GetProjectTargetFrameworks(string csprojPath) diff --git a/build/LocalStack.Build/GlobalUsings.cs b/build/LocalStack.Build/GlobalUsings.cs index 7ce57ab..683a0f8 100644 --- a/build/LocalStack.Build/GlobalUsings.cs +++ b/build/LocalStack.Build/GlobalUsings.cs @@ -20,6 +20,7 @@ global using System; global using System.Collections.Generic; global using System.Collections.Immutable; +global using System.Diagnostics.CodeAnalysis; global using System.IO; global using System.Linq; global using System.Text; From 13aa2984f3a4c39860f2546167c622c440f2c461 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 11:13:36 +0300 Subject: [PATCH 07/15] fix(ci): sanitise branch name used for the package artifact upload-artifact rejects '/' in artifact names, and the deploy job only ever runs on master pushes or feature/* pull requests - so every feature branch deploy failed at the upload step with "The artifact name is not valid ... Contains the following character: Forward slash /", after the packages had already been published. The branch name is now sanitised into SAFE_REF first, mirroring the Replace('/', '-') that BuildContext already applies when composing the package version. --- .github/workflows/ci-cd.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 1b8f1cc..a0683a7 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -176,6 +176,13 @@ jobs: - name: "Make build script executable" run: chmod +x ./build.sh + # Upload-artifact rejects '/' in artifact names, and every deploy from this job runs on a + # feature/* branch. Mirrors the sanitising BuildContext already applies to the package version. + - name: "Compute artifact-safe branch name" + run: | + RAW_REF="${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" + echo "SAFE_REF=${RAW_REF//\//-}" >> "$GITHUB_ENV" + - name: "Setup GitHub Packages Configuration" run: | echo "🔐 Adding GitHub Packages authentication..." @@ -229,7 +236,7 @@ jobs: - name: "Upload Package Artifacts" uses: actions/upload-artifact@v7 with: - name: "packages-${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}-${{ github.run_number }}" + name: "packages-${{ env.SAFE_REF }}-${{ github.run_number }}" path: | artifacts/*.nupkg artifacts/*.snupkg From 90269eb62e5856dd3a4b4c69a54146f49d0ff751 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 11:29:23 +0300 Subject: [PATCH 08/15] ci: isolate the legacy AWS SDK leg and stop tracking .mcp.json The pre-4.0.4 compatibility run shared the build-and-test job, which broke reporting. TestTask names its TRX by target framework alone (net8-0_results.trx), so the second pass overwrote the first and dorny/test-reporter published the legacy results instead of the real ones - without the functional tests, which that leg skips. The badge took the same wrong numbers. Moved to its own sdk-compat job: separate workspace, no TRX collision, and no test report at all, since these results are a compatibility gate rather than the suite's outcome. Deployment now waits on it as well as build-and-test. .mcp.json is per-developer configuration - it pins a machine-local Rider SSE port and personal tooling unrelated to building this library - so it follows the convention the repo already applies to .idea/, *.user and Properties/launchSettings.json. It stays on disk, only untracked. No history rewrite: the file carries no secret, the GitHub token is an ${ENV_VAR} reference. --- .github/workflows/ci-cd.yml | 40 ++++++++++++++++++++++++++++++------- .gitignore | 6 +++++- .mcp.json | 22 -------------------- 3 files changed, 38 insertions(+), 30 deletions(-) delete mode 100644 .mcp.json diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a0683a7..363e5eb 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -91,12 +91,6 @@ jobs: - name: "Run Tests" run: ${{ matrix.script }} --target tests --skipFunctionalTest ${{ runner.os == 'Linux' && 'false' || 'true' }} --exclusive - # AWSSDK.Extensions.NETCore.Setup 4.0.4 changed the internal ClientFactory constructor (issue #52). - # The default track pins a post-4.0.4 version; this leg proves we still work against the older shape. - - name: "Run Tests (legacy AWS SDK track, pre-4.0.4)" - if: runner.os == 'Linux' - run: ${{ matrix.script }} --target tests --skipFunctionalTest true --aws-setup-track legacy --force-restore true --exclusive - - name: "Publish Test Results" id: test-results uses: dorny/test-reporter@v3 @@ -136,10 +130,42 @@ jobs: **/TestResults/**/* retention-days: 7 + # AWSSDK.Extensions.NETCore.Setup 4.0.4 changed the internal ClientFactory constructor (issue #52). + # We support both shapes, so the older one is exercised on every run. + # + # This lives in its own job on purpose. Sharing the build-and-test job would rerun the same projects + # and target frameworks, and TestTask names its TRX by target framework alone + # (net8-0_results.trx), so the second pass overwrote the first - the published report and the badge + # would have shown these results instead of the real ones, minus the functional tests this leg skips. + # A separate job gets its own workspace, and deliberately publishes no test report. + sdk-compat: + name: "AWS SDK Compat (pre-4.0.4)" + runs-on: ubuntu-22.04 + env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Setup .NET SDK" + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 9.0.x + global-json-file: global.json + + - name: "Make build script executable" + run: chmod +x ./build.sh + + - name: "Build & test against the pre-4.0.4 AWS SDK" + run: ./build.sh --target tests --skipFunctionalTest true --aws-setup-track legacy --exclusive + continuous-deployment: name: "Continuous Deployment" runs-on: ubuntu-22.04 - needs: build-and-test + needs: [build-and-test, sdk-compat] if: | github.repository == 'localstack-dotnet/localstack-dotnet-client' && ((github.event_name == 'push' && github.ref == 'refs/heads/master') || diff --git a/.gitignore b/.gitignore index a961f2e..95b1409 100644 --- a/.gitignore +++ b/.gitignore @@ -340,4 +340,8 @@ testrunner # Visual Studio Code options directory -.vscode/ \ No newline at end of file +.vscode/ +# Agent/MCP server configuration is per-developer: it pins machine-local endpoints +# (e.g. a Rider SSE port) and personal tooling, so it is not shareable. Commit a +# .mcp.json.example instead if the project ever needs shared agent servers. +.mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index f000015..0000000 --- a/.mcp.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "mcpServers": { - "aspire": { - "command": "aspire", - "args": [ - "agent", - "mcp" - ] - }, - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/readonly", - "headers": { - "Authorization": "Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}" - } - }, - "rider": { - "type": "sse", - "url": "http://127.0.0.1:64811/sse" - } - } -} From 8da194b2c6abb7b06b14128394a3fb5c9338d17a Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 12:48:52 +0300 Subject: [PATCH 09/15] refactor(build): simplify AWS SDK tracks and pin the toolchain to .NET 9 The nuspec floor is deliberately raised to AWSSDK.Extensions.NETCore.Setup 4.0.100.5, so proving the pre-4.0.4 constructor shape still works tests a configuration no consumer can install. Drop the `legacy` track, the `sdk-compat` CI job and the `SdkCompat` traits that only existed to filter it, and trim the resolution tests to forward-drift coverage. A NuGet floor is a minimum, so consumers still float upward and will meet the next AWS change before the pinned build does. The canary is what catches that, so it now floats AWSSDK.Core as well - Core's move of constructor credentials to ExplicitAWSCredentials already bit us once, and the canary summary was printing a version that could never change. The canary had a second, quieter problem: --force-restore only means "do restore", it does not bypass NuGet's HTTP cache, and floating versions resolve against the cached feed index. Measured locally it resolved Setup 4.0.100.5 instead of 4.0.100.7 - reporting "AWS has not moved" while AWS had. Clear the HTTP cache before the run. Also: - Move $(AwsSetupTrack) and CentralPackageFloatingVersionsEnabled to Directory.Build.props; Directory.Packages.props keeps only conditional PackageVersion items. - Validate generated package versions through NuGet.Versioning instead of a hand-rolled SemVer rule. NuGetVersion decides whether a pre-release identifier is legal, the branch name is sanitised to [0-9A-Za-z-], and the composed version is parsed as a backstop so an invalid version fails the build with the offending value rather than at pack time. - Carry build.sh's executable bit in the index, removing five `chmod +x` steps across three workflows. - Give every workflow an explicit permissions block, resolving the CodeQL actions/missing-workflow-permissions alerts. build-and-test needs checks: write because dorny/test-reporter publishes a check run; the badge step uses its own PAT. - Pin global.json to SDK 9.0.316 and Microsoft.Extensions.*/System.Text.Json to 9.0.18. The highest TFM in the repo is net9.0 and there is no net10.0 target anywhere. Microsoft.SourceLink.GitHub stays on 10.0.301 - it has no 9.x release. Every Microsoft.Extensions.* reference in src/ carries an explicit VersionOverride, so no shipped dependency floor moves. --- .github/workflows/aws-sdk-canary.yml | 15 ++-- .github/workflows/ci-cd.yml | 51 +++---------- .github/workflows/publish-nuget.yml | 3 - Directory.Build.props | 7 ++ Directory.Packages.props | 67 ++++++---------- build.sh | 0 build/LocalStack.Build/BuildContext.cs | 63 +++++++++------ build/LocalStack.Build/GlobalUsings.cs | 2 + .../LocalStack.Build/LocalStack.Build.csproj | 1 + global.json | 2 +- .../AwsClientFactoryWrapperResolutionTests.cs | 76 +++++-------------- .../AwsClientFactoryWrapperTests.cs | 1 - 12 files changed, 111 insertions(+), 177 deletions(-) mode change 100644 => 100755 build.sh diff --git a/.github/workflows/aws-sdk-canary.yml b/.github/workflows/aws-sdk-canary.yml index ae7edaa..b2a1cb0 100644 --- a/.github/workflows/aws-sdk-canary.yml +++ b/.github/workflows/aws-sdk-canary.yml @@ -6,8 +6,8 @@ name: "AWS SDK Canary" # AWSSDK.Extensions.NETCore.Setup 4.0.4 breaking change (issue #52) went unnoticed for 70 days: # CI only ever saw the pinned version, while consumers float to whatever is newest. # -# This job floats AWSSDK.Extensions.NETCore.Setup to the latest 4.x and runs the SDK-sensitive -# tests against it. A failure here means "AWS moved", not "we broke something". +# This job floats both AWSSDK.Core and AWSSDK.Extensions.NETCore.Setup to the latest 4.x and runs +# the suite against them. A failure here means "AWS moved", not "we broke something". on: schedule: @@ -20,6 +20,9 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_NOLOGO: true +permissions: + contents: read + jobs: canary: name: "Latest AWS SDK" @@ -38,10 +41,12 @@ jobs: 9.0.x global-json-file: global.json - - name: "Make build script executable" - run: chmod +x ./build.sh + # Floating versions resolve against NuGet's cached feed index, so a warm HTTP cache can pin this + # job to a stale "latest" without saying so - the exact drift it exists to detect. + - name: "Clear NuGet HTTP cache" + run: dotnet nuget locals http-cache --clear - # Deliberately no NuGet cache: the point is to resolve the newest AWSSDK packages every run. + # Deliberately no NuGet package cache either: the point is to resolve the newest AWSSDK every run. - name: "Build & test against floating AWS SDK" run: ./build.sh --target tests --skipFunctionalTest true --aws-setup-track latest --force-restore true --exclusive diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 363e5eb..1d50c26 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -20,6 +20,9 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_NOLOGO: true +permissions: + contents: read + jobs: build-and-test: name: "Build & Test (${{ matrix.name }})" @@ -28,6 +31,13 @@ jobs: NUGET_PACKAGES: ${{ contains(matrix.os, 'windows') && format('{0}\.nuget\packages', github.workspace) || format('{0}/.nuget/packages', github.workspace) }} BADGE_GIST_ID: "472c59b7c2a1898c48a29f3c88897c5a" + # dorny/test-reporter publishes a check run. The badge step authenticates with its own + # PAT (secrets.GIST_SECRET), so it needs nothing from GITHUB_TOKEN. + permissions: + contents: read + actions: read + checks: write + strategy: fail-fast: false matrix: @@ -61,10 +71,6 @@ jobs: 9.0.x global-json-file: global.json - - name: "Make build script executable" - if: runner.os != 'Windows' - run: chmod +x ./build.sh - # The macOS runner image no longer ships Mono, and VSTest needs the real 'mono' host # on the PATH to execute the net472 test assemblies. - name: "Install Mono (macOS)" @@ -130,42 +136,10 @@ jobs: **/TestResults/**/* retention-days: 7 - # AWSSDK.Extensions.NETCore.Setup 4.0.4 changed the internal ClientFactory constructor (issue #52). - # We support both shapes, so the older one is exercised on every run. - # - # This lives in its own job on purpose. Sharing the build-and-test job would rerun the same projects - # and target frameworks, and TestTask names its TRX by target framework alone - # (net8-0_results.trx), so the second pass overwrote the first - the published report and the badge - # would have shown these results instead of the real ones, minus the functional tests this leg skips. - # A separate job gets its own workspace, and deliberately publishes no test report. - sdk-compat: - name: "AWS SDK Compat (pre-4.0.4)" - runs-on: ubuntu-22.04 - env: - NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages - - steps: - - name: "Checkout" - uses: actions/checkout@v7 - - - name: "Setup .NET SDK" - uses: actions/setup-dotnet@v6 - with: - dotnet-version: | - 8.0.x - 9.0.x - global-json-file: global.json - - - name: "Make build script executable" - run: chmod +x ./build.sh - - - name: "Build & test against the pre-4.0.4 AWS SDK" - run: ./build.sh --target tests --skipFunctionalTest true --aws-setup-track legacy --exclusive - continuous-deployment: name: "Continuous Deployment" runs-on: ubuntu-22.04 - needs: [build-and-test, sdk-compat] + needs: [build-and-test] if: | github.repository == 'localstack-dotnet/localstack-dotnet-client' && ((github.event_name == 'push' && github.ref == 'refs/heads/master') || @@ -199,9 +173,6 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- - - name: "Make build script executable" - run: chmod +x ./build.sh - # Upload-artifact rejects '/' in artifact names, and every deploy from this job runs on a # feature/* branch. Mirrors the sanitising BuildContext already applies to the package version. - name: "Compute artifact-safe branch name" diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 2437f33..8ce393f 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -62,9 +62,6 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- - - name: "Make build script executable" - run: chmod +x ./build.sh - - name: "Build & Test" run: ./build.sh --target tests --skipFunctionalTest true diff --git a/Directory.Build.props b/Directory.Build.props index 2c1d5d2..933902f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -32,6 +32,13 @@ true + + current + + + true + diff --git a/Directory.Packages.props b/Directory.Packages.props index 3f8cad5..ca47eed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,39 +1,14 @@  - - - current - - - - - true - - - + + + - - - + - @@ -47,8 +22,7 @@ - - + @@ -169,21 +143,24 @@ - - - - - - - - - - - - + + + + + + + + + + + + + - + + diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index 6fa3f45..65d8d33 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -31,8 +31,7 @@ public BuildContext(ICakeContext context) : base(context) UseDirectoryPropsVersion = context.Argument("use-directory-props-version", defaultValue: false); BranchName = context.Argument("branch-name", "master"); - // Selects which AWSSDK.Extensions.NETCore.Setup shape to build/test against. - // See the $(AwsSetupTrack) switch in Directory.Packages.props. Values: current | legacy | latest. + // Which AWS SDK versions to build/test against: 'current' (pinned) or 'latest' (floating, canary only). AwsSetupTrack = context.Argument("aws-setup-track", "current"); var sourceBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); @@ -286,22 +285,31 @@ private string GetDynamicVersionFromProps(string versionPropertyName) string baseVersion = content[startIndex..endIndex]; + if (!NuGetVersion.TryParse(baseVersion, out NuGetVersion? _)) + { + throw new InvalidOperationException($"<{versionPropertyName}> in Directory.Build.props is not a valid NuGet version: '{baseVersion}'."); + } + // Generate build metadata string buildDate = DateTime.UtcNow.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); string commitSha = GetGitCommitSha(); - string safeBranchName = BranchName.Replace('/', '-').Replace('_', '-'); - // SemVer-compliant pre-release versioning - if (BranchName == "master") - { - // Master nightlies: 2.0.0-nightly.20250725.sha - return $"{baseVersion}-nightly.{buildDate}.{commitSha}"; - } - else + // Master nightlies: 2.0.0-nightly.20250725.sha + // Feature branches: 2.0.0-feature-name.20250725.sha + string label = BranchName == "master" ? "nightly" : ToPreReleaseIdentifier(BranchName); + string version = $"{baseVersion}-{label}.{buildDate}.{commitSha}"; + + // Backstop: NuGet is the consumer of this string, so let it be the judge. A version that only + // fails at `dotnet pack` time - as the leading-zero timestamp did - should fail here instead, + // with the offending value in the message. + if (!NuGetVersion.TryParse(version, out NuGetVersion? _)) { - // Feature branches: 2.0.0-feature-name.20250725.sha - return $"{baseVersion}-{safeBranchName}.{buildDate}.{commitSha}"; + throw new InvalidOperationException( + $"Generated package version '{version}' is not a valid NuGet version " + + $"(base '{baseVersion}', branch '{BranchName}', build date '{buildDate}', commit '{commitSha}')."); } + + return version; } /// @@ -334,7 +342,7 @@ private string GetGitCommitSha() if (process.ExitCode == 0 && !string.IsNullOrEmpty(commitSha)) { - return ToSemVerIdentifier(commitSha); + return ToPreReleaseIdentifier(commitSha); } this.Warning($"'git rev-parse --short HEAD' exited with code {process.ExitCode} and no usable output; " + @@ -347,26 +355,31 @@ private string GetGitCommitSha() } // Fallback to timestamp-based identifier - return ToSemVerIdentifier(DateTime.UtcNow.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture)); + return ToPreReleaseIdentifier(DateTime.UtcNow.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture)); } /// - /// Makes an identifier safe to use inside a SemVer pre-release tag. + /// Makes an arbitrary string safe to use as a single pre-release identifier. /// /// - /// SemVer 2.0.0 forbids leading zeroes in numeric pre-release identifiers, and NuGet enforces it. - /// The timestamp fallback produces exactly that for any build before 10:00 UTC - "075853" - which made - /// `dotnet pack` fail with "is not a valid version string" depending purely on the time of day. A git short - /// SHA can hit the same trap when it happens to be all digits. Prefixing keeps the identifier alphanumeric, - /// which SemVer allows to start with anything. + /// Pre-release identifiers are limited to [0-9A-Za-z-], and SemVer 2.0.0 additionally forbids a + /// leading zero on a purely numeric one. The timestamp fallback produces exactly that for any build before + /// 10:00 UTC - "075853" - which made `dotnet pack` fail depending purely on the time of day; an all-digit + /// git short SHA can hit the same trap. Rather than reimplement the rule, we ask NuGet - the library that + /// decides whether the package is publishable - and only prefix when it objects. /// - private static string ToSemVerIdentifier(string identifier) + private static string ToPreReleaseIdentifier(string identifier) { - bool isNumericWithLeadingZero = identifier.Length > 1 - && identifier[0] == '0' - && identifier.All(char.IsDigit); + var builder = new StringBuilder(identifier.Length); + + foreach (char character in identifier) + { + builder.Append(char.IsAsciiLetterOrDigit(character) || character == '-' ? character : '-'); + } + + string sanitised = builder.ToString(); - return isNumericWithLeadingZero ? $"g{identifier}" : identifier; + return NuGetVersion.TryParse($"0.0.0-{sanitised}", out NuGetVersion? _) ? sanitised : $"g{sanitised}"; } private string[] GetProjectTargetFrameworks(string csprojPath) diff --git a/build/LocalStack.Build/GlobalUsings.cs b/build/LocalStack.Build/GlobalUsings.cs index 683a0f8..721401c 100644 --- a/build/LocalStack.Build/GlobalUsings.cs +++ b/build/LocalStack.Build/GlobalUsings.cs @@ -12,6 +12,8 @@ global using Cake.Docker; global using Cake.Frosting; +global using NuGet.Versioning; + global using Spectre.Console; global using LocalStack.Build; diff --git a/build/LocalStack.Build/LocalStack.Build.csproj b/build/LocalStack.Build/LocalStack.Build.csproj index 4b263e5..24082b7 100644 --- a/build/LocalStack.Build/LocalStack.Build.csproj +++ b/build/LocalStack.Build/LocalStack.Build.csproj @@ -11,5 +11,6 @@ + \ No newline at end of file diff --git a/global.json b/global.json index 910367f..8a1fbb6 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.302", + "version": "9.0.316", "rollForward": "latestFeature", "allowPrerelease": false } diff --git a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs index 4e1e9ab..5579227 100644 --- a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs +++ b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs @@ -12,36 +12,17 @@ namespace LocalStack.Client.Extensions.Tests; /// /// AWSSDK.Extensions.NETCore.Setup 4.0.4 changed the internal ClientFactory<T> constructor from /// (AWSOptions) to (AWSOptions, Action<ClientConfig, IServiceProvider> = null) and broke us -/// (issue #52). Tests that talk to the real AWS SDK can only ever see whichever version is pinned, so the shapes -/// are modelled with local stand-ins here. That covers both known shapes at once - and shapes AWS has not shipped -/// yet - without a second test project or a package-version dance. -/// -/// The companion real-SDK check is , -/// which is deliberately shape-agnostic so it passes on both -p:AwsSetupTrack=current and -/// -p:AwsSetupTrack=legacy. -/// +/// (issue #52). We pin a floor, but a NuGet floor is a minimum - consumers still float upwards, so the next +/// such change reaches them before it reaches our pinned build. These tests therefore cover shapes AWS has +/// not shipped yet, using local stand-ins, because a test against the real SDK can only ever see +/// whichever version is currently pinned. /// public class AwsClientFactoryWrapperResolutionTests { - private const string CategoryTrait = "Category"; - private const string SdkCompatCategory = "SdkCompat"; - - [Fact] - [Trait(CategoryTrait, SdkCompatCategory)] - public void SelectFactoryConstructor_Should_Select_Ctor_On_Pre_4_0_4_Shape() - { - ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); - - Assert.NotNull(selected); - Assert.Single(selected.GetParameters()); - Assert.Equal(typeof(AWSOptions), selected.GetParameters()[0].ParameterType); - } - [Fact] - [Trait(CategoryTrait, SdkCompatCategory)] - public void SelectFactoryConstructor_Should_Select_Ctor_On_Post_4_0_4_Shape() + public void SelectFactoryConstructor_Should_Select_Ctor_On_The_Current_Shape() { - ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); Assert.NotNull(selected); Assert.Equal(2, selected.GetParameters().Length); @@ -49,7 +30,6 @@ public void SelectFactoryConstructor_Should_Select_Ctor_On_Post_4_0_4_Shape() } [Fact] - [Trait(CategoryTrait, SdkCompatCategory)] public void SelectFactoryConstructor_Should_Select_Ctor_On_Unknown_Future_Shape() { ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); @@ -62,7 +42,7 @@ public void SelectFactoryConstructor_Should_Select_Ctor_On_Unknown_Future_Shape( [Fact] public void SelectFactoryConstructor_Should_Ignore_The_Parameterless_Ctor() { - ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); Assert.NotNull(selected); Assert.NotEmpty(selected.GetParameters()); @@ -86,10 +66,9 @@ public void SelectFactoryConstructor_Should_Return_Null_When_No_Ctor_Takes_AwsOp } [Fact] - [Trait(CategoryTrait, SdkCompatCategory)] public void BuildConstructorArguments_Should_Default_The_Trailing_Parameters() { - ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; var awsOptions = new AWSOptions(); object?[] arguments = AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions); @@ -112,12 +91,11 @@ public void BuildConstructorArguments_Should_Default_Value_Type_Parameters_Witho } [Fact] - [Trait(CategoryTrait, SdkCompatCategory)] public void BuildConstructorArguments_Should_Allow_Null_AwsOptions() { // AddAwsService() without explicit options passes null, and the AWS factory then resolves // AWSOptions from the IServiceProvider or IConfiguration. Null must survive the call. - ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; object?[] arguments = AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions: null); @@ -127,10 +105,10 @@ public void BuildConstructorArguments_Should_Allow_Null_AwsOptions() [Fact] public void BuildConstructorArguments_Should_Produce_Invokable_Arguments() { - ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; + ConstructorInfo constructor = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf())!; var awsOptions = new AWSOptions(); - var created = (PostV404Factory)constructor.Invoke(AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions)); + var created = (CurrentShapeFactory)constructor.Invoke(AwsClientFactoryWrapper.BuildConstructorArguments(constructor, awsOptions)); Assert.Same(awsOptions, created.AwsOptions); Assert.Null(created.ConfigAction); @@ -139,7 +117,7 @@ public void BuildConstructorArguments_Should_Produce_Invokable_Arguments() [Fact] public void DescribeConstructors_Should_Render_Discovered_Signatures() { - string described = AwsClientFactoryWrapper.DescribeConstructors(CtorsOf()); + string described = AwsClientFactoryWrapper.DescribeConstructors(CtorsOf()); Assert.Contains("AWSOptions", described, StringComparison.Ordinal); Assert.Contains(".ctor(", described, StringComparison.Ordinal); @@ -152,11 +130,10 @@ public void DescribeConstructors_Should_Report_None_When_Empty() } [Fact] - [Trait(CategoryTrait, SdkCompatCategory)] public void SelectFactoryConstructor_Should_Resolve_Against_The_Real_Aws_Sdk() { - // Shape-agnostic on purpose: pre-4.0.4 exposes one parameter, 4.0.4+ exposes two. - // Both must resolve, so this passes on either AwsSetupTrack. + // Deliberately shape-agnostic: this also runs on the floating canary track, where it must go red + // only when we genuinely cannot resolve - not merely because AWS appended another parameter. Type factoryType = typeof(ConfigurationException).Assembly.GetType("Amazon.Extensions.NETCore.Setup.ClientFactory`1")! .MakeGenericType(typeof(IAmazonS3)); @@ -166,8 +143,8 @@ public void SelectFactoryConstructor_Should_Resolve_Against_The_Real_Aws_Sdk() Assert.NotNull(selected); ParameterInfo[] parameters = selected.GetParameters(); + Assert.NotEmpty(parameters); Assert.Equal(typeof(AWSOptions), parameters[0].ParameterType); - Assert.InRange(parameters.Length, 1, 2); } private static ConstructorInfo[] CtorsOf() @@ -175,29 +152,14 @@ private static ConstructorInfo[] CtorsOf() return typeof(T).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); } - /// Mirrors AWSSDK.Extensions.NETCore.Setup <= 4.0.3.40. - private sealed class PreV404Factory - { - private PreV404Factory() - { - } - - internal PreV404Factory(AWSOptions awsOptions) - { - AwsOptions = awsOptions; - } - - public AWSOptions? AwsOptions { get; } - } - - /// Mirrors AWSSDK.Extensions.NETCore.Setup >= 4.0.4. - private sealed class PostV404Factory + /// Mirrors AWSSDK.Extensions.NETCore.Setup >= 4.0.4, the shape we pin against. + private sealed class CurrentShapeFactory { - private PostV404Factory() + private CurrentShapeFactory() { } - internal PostV404Factory(AWSOptions awsOptions, Action? configAction = null) + internal CurrentShapeFactory(AWSOptions awsOptions, Action? configAction = null) { AwsOptions = awsOptions; ConfigAction = configAction; diff --git a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs index a166137..936e416 100644 --- a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs +++ b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperTests.cs @@ -54,7 +54,6 @@ public void CreateServiceClient_Should_Throw_LocalStackClientConfigurationExcept } [Fact] - [Trait("Category", "SdkCompat")] public void CreateServiceClient_Should_Create_Client_When_UseLocalStack_False() { ConfigurationBuilder configurationBuilder = new(); From f539bf439cfccbff6992643c13d042da085a54b5 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 12:49:08 +0300 Subject: [PATCH 10/15] release: prepare LocalStack.Client.Extensions 2.0.1 PackageExtensionVersion 2.0.0 -> 2.0.1. PackageMainVersion stays at 2.0.0: LocalStack.Client has no source change, and the advisory on its AWSSDK.Core >= 4.0.0.15 floor is unreachable in practice because AWSSDK.Extensions.NETCore.Setup 4.0.100.5 declares AWSSDK.Core >= 4.0.100.6, so NuGet lifts it anyway. PackageValidationBaselineVersion moves off the stale v1 baselines (1.4.1 / 1.2.2) to 2.0.0 for both projects. That makes every entry in LocalStack.Client's CompatibilitySuppressions.xml dead - they compare lib/net461, lib/net6.0 and lib/net7.0, target frameworks that only existed in the v1.4.1 package - and package validation fails the build while they remain, so the file goes. The CHANGELOG entry is rewritten as terse bullets matching the v2.0.0 section. The previous draft claimed a Microsoft.Extensions.* bump that consumers never see: every such reference in src/ carries an explicit VersionOverride, so the only consumer-visible dependency change is the AWSSDK.Extensions.NETCore.Setup floor. --- CHANGELOG.md | 30 +++++++------- Directory.Build.props | 2 +- .../LocalStack.Client.Extensions.csproj | 2 +- .../CompatibilitySuppressions.xml | 39 ------------------- .../LocalStack.Client.csproj | 2 +- 5 files changed, 17 insertions(+), 58 deletions(-) delete mode 100644 src/LocalStack.Client/CompatibilitySuppressions.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index d511667..a8ba8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,30 +4,28 @@ This document outlines the changes, updates, and important notes for the LocalSt See v1.x change log for previous versions: [CHANGELOG.md](https://github.com/localstack-dotnet/localstack-dotnet-client/blob/sdkv3-lts/CHANGELOG.md) -## [Unreleased] +## [v2.0.1](https://github.com/localstack-dotnet/localstack-dotnet-client/releases/tag/v2.0.1) + +> Maintenance release for **`LocalStack.Client.Extensions` only**. `LocalStack.Client` stays at 2.0.0. ### 🐞 Fixes - **Fixed `LocalStackClientConfigurationException` when `UseLocalStack` is `false` ([#52](https://github.com/localstack-dotnet/localstack-dotnet-client/issues/52)).** - `AWSSDK.Extensions.NETCore.Setup` **4.0.4** (2026-05-20) changed the internal `ClientFactory` - constructor from `(AWSOptions)` to `(AWSOptions, Action = null)`. - Because the added parameter is optional this was source-compatible for AWS and shipped as a patch, - but it broke our exact-signature reflection lookup. `AwsClientFactoryWrapper` now matches on the - `AWSOptions` parameter instead of the full signature and defaults any trailing parameters, so both - the pre-4.0.4 and 4.0.4+ shapes work. Only the `UseLocalStack: false` path was affected. - - Failure messages now list the constructor signatures actually discovered, so future AWS SDK - changes can be diagnosed from a bug report without a version bisect. + `AWSSDK.Extensions.NETCore.Setup` 4.0.4 added an optional parameter to the internal + `ClientFactory` constructor, which broke our exact-signature reflection lookup. Resolution now + matches on the `AWSOptions` parameter and defaults any trailing ones. +- Failure messages now list the constructor signatures actually discovered. ### 🛠️ General -- **Dependency refresh:** `AWSSDK.Core` → 4.0.100.6, `AWSSDK.Extensions.NETCore.Setup` → 4.0.100.5, - 120 AWSSDK service packages, `Microsoft.Extensions.*` → 10.0.10, and the full analyzer/test - toolchain updated. Resolves the `NU1901` advisory (GHSA-9cvc-h2w8-phrp) on `AWSSDK.Core` 4.0.0.15. -- **AWS SDK compatibility testing:** the `AwsSetupTrack` MSBuild property - (`current` | `legacy` | `latest`) switches the whole package graph, so the same suite runs against - both the pre-4.0.4 and 4.0.4+ `ClientFactory` shapes. Exposed through Cake as - `--aws-setup-track`, wired into CI, plus a scheduled canary that floats to the newest AWS SDK. +- **Minimum `AWSSDK.Extensions.NETCore.Setup` is now 4.0.100.5** (was 4.0.2). No other dependency + floor changed. +- **Internal dependency refresh:** `AWSSDK.Core` → 4.0.100.6, 120 AWSSDK service packages, and the + analyzer/test toolchain. Clears the `NU1901` advisory (GHSA-9cvc-h2w8-phrp) on `AWSSDK.Core` + 4.0.0.15. +- **Added a scheduled AWS SDK canary** that builds against the newest `AWSSDK.Core` and + `AWSSDK.Extensions.NETCore.Setup`, so upstream changes surface before they reach users. - **Public API unchanged.** --- diff --git a/Directory.Build.props b/Directory.Build.props index 933902f..0297db9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/localstack-dotnet/localstack-dotnet-client localstack-dotnet-square.png 2.0.0 - 2.0.0 + 2.0.1 true snupkg 13.0 diff --git a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index 51030d6..907f3b5 100644 --- a/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj +++ b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj @@ -14,7 +14,7 @@ LICENSE.txt README.md true - 1.2.2 + 2.0.0 true true $(NoWarn);CA1510 diff --git a/src/LocalStack.Client/CompatibilitySuppressions.xml b/src/LocalStack.Client/CompatibilitySuppressions.xml deleted file mode 100644 index 5a62906..0000000 --- a/src/LocalStack.Client/CompatibilitySuppressions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - CP0008 - T:LocalStack.Client.Exceptions.LocalStackClientException - lib/net461/LocalStack.Client.dll - lib/netstandard2.0/LocalStack.Client.dll - true - - - CP0008 - T:LocalStack.Client.Exceptions.MisconfiguredClientException - lib/net461/LocalStack.Client.dll - lib/netstandard2.0/LocalStack.Client.dll - true - - - CP0008 - T:LocalStack.Client.Exceptions.NotSupportedClientException - lib/net461/LocalStack.Client.dll - lib/netstandard2.0/LocalStack.Client.dll - true - - - CP0008 - T:LocalStack.Client.Enums.AwsService - lib/net6.0/LocalStack.Client.dll - lib/netstandard2.0/LocalStack.Client.dll - true - - - CP0008 - T:LocalStack.Client.Enums.AwsService - lib/net7.0/LocalStack.Client.dll - lib/netstandard2.0/LocalStack.Client.dll - true - - \ No newline at end of file diff --git a/src/LocalStack.Client/LocalStack.Client.csproj b/src/LocalStack.Client/LocalStack.Client.csproj index 3a7e54c..04a0df5 100644 --- a/src/LocalStack.Client/LocalStack.Client.csproj +++ b/src/LocalStack.Client/LocalStack.Client.csproj @@ -14,7 +14,7 @@ LICENSE.txt README.md true - 1.4.1 + 2.0.0 true true From 4bb0fa7eeeb67f3e82df33716fff0c8185b8cbbe Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 13:09:40 +0300 Subject: [PATCH 11/15] build: retire LocalStack.sln in favour of the slnx The slnx was added in 7669a28 but BuildContext still pointed Cake at LocalStack.sln, so every build, test and pack ran through the old file while the new one was only used by hand. The two listed exactly the same twelve projects, but that was maintained by hand - nothing enforced it, and a project added to one and not the other would have silently dropped out of CI. Point $(SlnFilePath) at LocalStack.slnx and delete the .sln. LocalStack.sln.DotSettings is renamed to LocalStack.slnx.DotSettings. Rider resolves shared settings by solution file name, so with the .slnx open the 24 KB of shared naming, formatting and inspection-severity settings were not being applied at all. The file has no internal reference to the solution name, so the rename is sufficient. --- LocalStack.sln | 123 ------------------ ...DotSettings => LocalStack.slnx.DotSettings | 0 build/LocalStack.Build/BuildContext.cs | 2 +- 3 files changed, 1 insertion(+), 124 deletions(-) delete mode 100644 LocalStack.sln rename LocalStack.sln.DotSettings => LocalStack.slnx.DotSettings (100%) diff --git a/LocalStack.sln b/LocalStack.sln deleted file mode 100644 index 51ed6dc..0000000 --- a/LocalStack.sln +++ /dev/null @@ -1,123 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client", "src\LocalStack.Client\LocalStack.Client.csproj", "{22C080D7-929C-44F1-909C-831EF9D2810F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sandboxes", "sandboxes", "{962A750E-8FE2-461F-B3FC-2B401309B5FD}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{3F0F4BAA-02EF-4008-9CF8-E73AA92D4664}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Sandbox", "tests\sandboxes\LocalStack.Client.Sandbox\LocalStack.Client.Sandbox.csproj", "{E7E16B66-EE23-4B49-89C5-4FF64F2ED95D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Tests", "tests\LocalStack.Client.Tests\LocalStack.Client.Tests.csproj", "{9FC6CABE-ED38-4048-B511-69D76870ABF8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Integration.Tests", "tests\LocalStack.Client.Integration.Tests\LocalStack.Client.Integration.Tests.csproj", "{691A4094-2074-474A-81A3-E33B728AE54E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Sandbox.DependencyInjection", "tests\sandboxes\LocalStack.Client.Sandbox.DependencyInjection\LocalStack.Client.Sandbox.DependencyInjection.csproj", "{4E90D3D1-D570-4205-9C6E-B917B5508912}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{06034ACF-97AD-4266-8E46-42B1804C89B6}" - ProjectSection(SolutionItems) = preProject - .github\workflows\build-macos.yml = .github\workflows\build-macos.yml - .github\workflows\build-ubuntu.yml = .github\workflows\build-ubuntu.yml - .github\workflows\build-windows.yml = .github\workflows\build-windows.yml - build.ps1 = build.ps1 - build.sh = build.sh - .github\workflows\publish-nuget.yml = .github\workflows\publish-nuget.yml - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Extensions", "src\LocalStack.Client.Extensions\LocalStack.Client.Extensions.csproj", "{74035094-A726-44E2-9B88-42D6425D8548}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Sandbox.WithGenericHost", "tests\sandboxes\LocalStack.Client.Sandbox.WithGenericHost\LocalStack.Client.Sandbox.WithGenericHost.csproj", "{350EF226-D0CE-4C8C-83D1-22E638F46862}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Extensions.Tests", "tests\LocalStack.Client.Extensions.Tests\LocalStack.Client.Extensions.Tests.csproj", "{0F24D1F8-DB6B-439E-BD6D-23E8DA88615A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Client.Functional.Tests", "tests\LocalStack.Client.Functional.Tests\LocalStack.Client.Functional.Tests.csproj", "{A697D9A2-4DF7-4B4D-A189-EEC7F64B5609}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Container", "tests\sandboxes\LocalStack.Container\LocalStack.Container.csproj", "{6CCFBCE0-C7C6-42A7-B39F-665B4C15C6FE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{152F3084-DC30-4A44-AEBC-E4C0EBFA0F4E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LocalStack.Build", "build\LocalStack.Build\LocalStack.Build.csproj", "{2CA18A71-CA83-4CC4-A777-AA4F56E4413F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{FF7B2686-CC4B-4B6C-B360-E487339DB210}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocalStack.Tests.Common", "tests\common\LocalStack.Tests.Common\LocalStack.Tests.Common.csproj", "{7B896BF0-E9E1-44B7-9268-78A6B45CFE0D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {22C080D7-929C-44F1-909C-831EF9D2810F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {22C080D7-929C-44F1-909C-831EF9D2810F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {22C080D7-929C-44F1-909C-831EF9D2810F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {22C080D7-929C-44F1-909C-831EF9D2810F}.Release|Any CPU.Build.0 = Release|Any CPU - {E7E16B66-EE23-4B49-89C5-4FF64F2ED95D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E7E16B66-EE23-4B49-89C5-4FF64F2ED95D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E7E16B66-EE23-4B49-89C5-4FF64F2ED95D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E7E16B66-EE23-4B49-89C5-4FF64F2ED95D}.Release|Any CPU.Build.0 = Release|Any CPU - {9FC6CABE-ED38-4048-B511-69D76870ABF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9FC6CABE-ED38-4048-B511-69D76870ABF8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9FC6CABE-ED38-4048-B511-69D76870ABF8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9FC6CABE-ED38-4048-B511-69D76870ABF8}.Release|Any CPU.Build.0 = Release|Any CPU - {691A4094-2074-474A-81A3-E33B728AE54E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {691A4094-2074-474A-81A3-E33B728AE54E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {691A4094-2074-474A-81A3-E33B728AE54E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {691A4094-2074-474A-81A3-E33B728AE54E}.Release|Any CPU.Build.0 = Release|Any CPU - {4E90D3D1-D570-4205-9C6E-B917B5508912}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4E90D3D1-D570-4205-9C6E-B917B5508912}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4E90D3D1-D570-4205-9C6E-B917B5508912}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4E90D3D1-D570-4205-9C6E-B917B5508912}.Release|Any CPU.Build.0 = Release|Any CPU - {74035094-A726-44E2-9B88-42D6425D8548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {74035094-A726-44E2-9B88-42D6425D8548}.Debug|Any CPU.Build.0 = Debug|Any CPU - {74035094-A726-44E2-9B88-42D6425D8548}.Release|Any CPU.ActiveCfg = Release|Any CPU - {74035094-A726-44E2-9B88-42D6425D8548}.Release|Any CPU.Build.0 = Release|Any CPU - {350EF226-D0CE-4C8C-83D1-22E638F46862}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {350EF226-D0CE-4C8C-83D1-22E638F46862}.Debug|Any CPU.Build.0 = Debug|Any CPU - {350EF226-D0CE-4C8C-83D1-22E638F46862}.Release|Any CPU.ActiveCfg = Release|Any CPU - {350EF226-D0CE-4C8C-83D1-22E638F46862}.Release|Any CPU.Build.0 = Release|Any CPU - {0F24D1F8-DB6B-439E-BD6D-23E8DA88615A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0F24D1F8-DB6B-439E-BD6D-23E8DA88615A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0F24D1F8-DB6B-439E-BD6D-23E8DA88615A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0F24D1F8-DB6B-439E-BD6D-23E8DA88615A}.Release|Any CPU.Build.0 = Release|Any CPU - {A697D9A2-4DF7-4B4D-A189-EEC7F64B5609}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A697D9A2-4DF7-4B4D-A189-EEC7F64B5609}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A697D9A2-4DF7-4B4D-A189-EEC7F64B5609}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A697D9A2-4DF7-4B4D-A189-EEC7F64B5609}.Release|Any CPU.Build.0 = Release|Any CPU - {6CCFBCE0-C7C6-42A7-B39F-665B4C15C6FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6CCFBCE0-C7C6-42A7-B39F-665B4C15C6FE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6CCFBCE0-C7C6-42A7-B39F-665B4C15C6FE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6CCFBCE0-C7C6-42A7-B39F-665B4C15C6FE}.Release|Any CPU.Build.0 = Release|Any CPU - {2CA18A71-CA83-4CC4-A777-AA4F56E4413F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2CA18A71-CA83-4CC4-A777-AA4F56E4413F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2CA18A71-CA83-4CC4-A777-AA4F56E4413F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2CA18A71-CA83-4CC4-A777-AA4F56E4413F}.Release|Any CPU.Build.0 = Release|Any CPU - {7B896BF0-E9E1-44B7-9268-78A6B45CFE0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B896BF0-E9E1-44B7-9268-78A6B45CFE0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B896BF0-E9E1-44B7-9268-78A6B45CFE0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B896BF0-E9E1-44B7-9268-78A6B45CFE0D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {962A750E-8FE2-461F-B3FC-2B401309B5FD} = {3F0F4BAA-02EF-4008-9CF8-E73AA92D4664} - {E7E16B66-EE23-4B49-89C5-4FF64F2ED95D} = {962A750E-8FE2-461F-B3FC-2B401309B5FD} - {9FC6CABE-ED38-4048-B511-69D76870ABF8} = {3F0F4BAA-02EF-4008-9CF8-E73AA92D4664} - {691A4094-2074-474A-81A3-E33B728AE54E} = {3F0F4BAA-02EF-4008-9CF8-E73AA92D4664} - {4E90D3D1-D570-4205-9C6E-B917B5508912} = {962A750E-8FE2-461F-B3FC-2B401309B5FD} - {350EF226-D0CE-4C8C-83D1-22E638F46862} = {962A750E-8FE2-461F-B3FC-2B401309B5FD} - {0F24D1F8-DB6B-439E-BD6D-23E8DA88615A} = {3F0F4BAA-02EF-4008-9CF8-E73AA92D4664} - {A697D9A2-4DF7-4B4D-A189-EEC7F64B5609} = {3F0F4BAA-02EF-4008-9CF8-E73AA92D4664} - {6CCFBCE0-C7C6-42A7-B39F-665B4C15C6FE} = {962A750E-8FE2-461F-B3FC-2B401309B5FD} - {2CA18A71-CA83-4CC4-A777-AA4F56E4413F} = {152F3084-DC30-4A44-AEBC-E4C0EBFA0F4E} - {FF7B2686-CC4B-4B6C-B360-E487339DB210} = {3F0F4BAA-02EF-4008-9CF8-E73AA92D4664} - {7B896BF0-E9E1-44B7-9268-78A6B45CFE0D} = {FF7B2686-CC4B-4B6C-B360-E487339DB210} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E4925255-67AA-4095-816B-CC10A5490E71} - EndGlobalSection -EndGlobal diff --git a/LocalStack.sln.DotSettings b/LocalStack.slnx.DotSettings similarity index 100% rename from LocalStack.sln.DotSettings rename to LocalStack.slnx.DotSettings diff --git a/build/LocalStack.Build/BuildContext.cs b/build/LocalStack.Build/BuildContext.cs index 65d8d33..f205f4a 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -49,7 +49,7 @@ public BuildContext(ICakeContext context) : base(context) ArtifactOutput = SolutionRoot + context.Directory("artifacts"); LocalStackClientFolder = SrcPath + context.Directory(LocalStackClientProjName); LocalStackClientExtFolder = SrcPath + context.Directory(LocalStackClientExtensionsProjName); - SlnFilePath = SolutionRoot + context.File("LocalStack.sln"); + SlnFilePath = SolutionRoot + context.File("LocalStack.slnx"); LocalStackClientProjFile = LocalStackClientFolder + context.File($"{LocalStackClientProjName}.csproj"); LocalStackClientExtProjFile = LocalStackClientExtFolder + context.File($"{LocalStackClientExtensionsProjName}.csproj"); From d8c3de18aaddece3ac063b85f00e7fac6d85d347 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 13:09:40 +0300 Subject: [PATCH 12/15] docs(readme): record the AWS SDK 4.0.4 breakage as a known issue Anyone installing LocalStack.Client.Extensions 2.0.0 today still hits issue #52: its floor is [4.0.2, ), so NuGet floats them straight onto 4.0.4+ where the internal ClientFactory constructor changed. The exception text is what people will search for, so the README is where the answer belongs - with the upgrade to 2.0.1 as the fix and pinning AWSSDK.Extensions.NETCore.Setup to 4.0.3.40 as the workaround for anyone who cannot upgrade yet. Also record why ClientFactoryGenericTypeName and CreateServiceClientMethodName are 'static readonly' and not 'const'. CA1802 and Sonar S3962 both flag them, and following that advice compiles but breaks two tests: AwsClientFactoryWrapperTests overwrites those fields by reflection to simulate AWS renaming its internals, and a const is inlined at every use site, so the overwrite would be a no-op and the failure-path tests would silently stop testing anything. The suppression is load-bearing; the comment says so. The two README copies under src/ are refreshed by the PreBuild target and ship inside the packages, so they move with the root file. --- README.md | 8 ++++++++ .../AwsClientFactoryWrapper.cs | 6 +++++- src/LocalStack.Client.Extensions/README.md | 8 ++++++++ src/LocalStack.Client/README.md | 8 ++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8343b2b..c4fa022 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,14 @@ The `RegionName` is important as LocalStack creates resources based on the speci ## Known Issues +- **`LocalStackClientConfigurationException` on `LocalStack.Client.Extensions` v2.0.0 and earlier:** If `UseLocalStack` is `false` and `AWSSDK.Extensions.NETCore.Setup` resolves to **4.0.4 or newer**, client creation throws `LocalStackClientConfigurationException`. AWS changed an internal `ClientFactory` constructor in 4.0.4; the new parameter is optional, so it shipped as a patch release and is source-compatible on their side, but it is binary-breaking for the reflection lookup used here. Because v2.0.0 declares `AWSSDK.Extensions.NETCore.Setup >= 4.0.2`, NuGet floats new installs straight onto the broken versions. Only the `UseLocalStack: false` path is affected — LocalStack-only usage never reaches this code. **Fixed in v2.0.1**, so upgrading is the recommended fix. If you cannot upgrade yet, pin the AWS package to the last working version, which v2.0.0's range still allows: + + ```xml + + ``` + + See [issue #52](https://github.com/localstack-dotnet/localstack-dotnet-client/issues/52) for the full analysis. + - **SNS with LocalStack v3.7.2 and v3.8.0:** During development on the new version, it was discovered that SNS functional tests are not working in LocalStack versions v3.7.2 and v3.8.0. This issue was reported in LocalStack [issue #11652](https://github.com/localstack/localstack/issues/11652). The LocalStack team identified a bug related to handling SNS URIs and resolved it in [PR #11653](https://github.com/localstack/localstack/pull/11653). The fix will be included in an upcoming release of LocalStack. In the meantime, if you're using SNS, it is recommended to stick to version v3.7.1 of LocalStack until the fix is available. - **LocalStack Versions v2.0.1 - v2.2:** In versions v2.0.1 through v2.2 of LocalStack, the URL routing logic was changed, causing issues with SQS and S3 operations. Two issues were opened in LocalStack regarding this: [issue #8928](https://github.com/localstack/localstack/issues/8928) and [issue #8924](https://github.com/localstack/localstack/issues/8924). LocalStack addressed this problem with [PR #8962](https://github.com/localstack/localstack/pull/8962). Therefore, when using LocalStack.NET, either use version v2.0 of LocalStack (there are no issues with the v1 series as well) or the upcoming v2.3 version, or use the latest v3 series container from Docker Hub. diff --git a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs index 24f1e20..50599ea 100644 --- a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs +++ b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs @@ -1,10 +1,14 @@ #pragma warning disable S3011 // We need to use reflection to access private fields for service metadata #pragma warning disable CS8600,CS8603 // Not possible to get null value from this private field -#pragma warning disable CA1802 // We need to use reflection to access private fields for service metadata +#pragma warning disable CA1802 // Deliberately readonly, not const - see the note on the fields below namespace LocalStack.Client.Extensions; public sealed class AwsClientFactoryWrapper : IAwsClientFactoryWrapper { + // These MUST stay 'readonly' rather than 'const'. AwsClientFactoryWrapperTests overwrites them by + // reflection to simulate the AWS SDK renaming its internals, which is how the failure paths below are + // covered. A 'const' is inlined at every use site, so overwriting the field would change nothing and + // those tests would silently stop testing anything. CA1802/S3962 flag this; they cannot see the tests. private static readonly string ClientFactoryGenericTypeName = "Amazon.Extensions.NETCore.Setup.ClientFactory`1"; private static readonly string CreateServiceClientMethodName = "CreateServiceClient"; diff --git a/src/LocalStack.Client.Extensions/README.md b/src/LocalStack.Client.Extensions/README.md index 8343b2b..c4fa022 100644 --- a/src/LocalStack.Client.Extensions/README.md +++ b/src/LocalStack.Client.Extensions/README.md @@ -231,6 +231,14 @@ The `RegionName` is important as LocalStack creates resources based on the speci ## Known Issues +- **`LocalStackClientConfigurationException` on `LocalStack.Client.Extensions` v2.0.0 and earlier:** If `UseLocalStack` is `false` and `AWSSDK.Extensions.NETCore.Setup` resolves to **4.0.4 or newer**, client creation throws `LocalStackClientConfigurationException`. AWS changed an internal `ClientFactory` constructor in 4.0.4; the new parameter is optional, so it shipped as a patch release and is source-compatible on their side, but it is binary-breaking for the reflection lookup used here. Because v2.0.0 declares `AWSSDK.Extensions.NETCore.Setup >= 4.0.2`, NuGet floats new installs straight onto the broken versions. Only the `UseLocalStack: false` path is affected — LocalStack-only usage never reaches this code. **Fixed in v2.0.1**, so upgrading is the recommended fix. If you cannot upgrade yet, pin the AWS package to the last working version, which v2.0.0's range still allows: + + ```xml + + ``` + + See [issue #52](https://github.com/localstack-dotnet/localstack-dotnet-client/issues/52) for the full analysis. + - **SNS with LocalStack v3.7.2 and v3.8.0:** During development on the new version, it was discovered that SNS functional tests are not working in LocalStack versions v3.7.2 and v3.8.0. This issue was reported in LocalStack [issue #11652](https://github.com/localstack/localstack/issues/11652). The LocalStack team identified a bug related to handling SNS URIs and resolved it in [PR #11653](https://github.com/localstack/localstack/pull/11653). The fix will be included in an upcoming release of LocalStack. In the meantime, if you're using SNS, it is recommended to stick to version v3.7.1 of LocalStack until the fix is available. - **LocalStack Versions v2.0.1 - v2.2:** In versions v2.0.1 through v2.2 of LocalStack, the URL routing logic was changed, causing issues with SQS and S3 operations. Two issues were opened in LocalStack regarding this: [issue #8928](https://github.com/localstack/localstack/issues/8928) and [issue #8924](https://github.com/localstack/localstack/issues/8924). LocalStack addressed this problem with [PR #8962](https://github.com/localstack/localstack/pull/8962). Therefore, when using LocalStack.NET, either use version v2.0 of LocalStack (there are no issues with the v1 series as well) or the upcoming v2.3 version, or use the latest v3 series container from Docker Hub. diff --git a/src/LocalStack.Client/README.md b/src/LocalStack.Client/README.md index 8343b2b..c4fa022 100644 --- a/src/LocalStack.Client/README.md +++ b/src/LocalStack.Client/README.md @@ -231,6 +231,14 @@ The `RegionName` is important as LocalStack creates resources based on the speci ## Known Issues +- **`LocalStackClientConfigurationException` on `LocalStack.Client.Extensions` v2.0.0 and earlier:** If `UseLocalStack` is `false` and `AWSSDK.Extensions.NETCore.Setup` resolves to **4.0.4 or newer**, client creation throws `LocalStackClientConfigurationException`. AWS changed an internal `ClientFactory` constructor in 4.0.4; the new parameter is optional, so it shipped as a patch release and is source-compatible on their side, but it is binary-breaking for the reflection lookup used here. Because v2.0.0 declares `AWSSDK.Extensions.NETCore.Setup >= 4.0.2`, NuGet floats new installs straight onto the broken versions. Only the `UseLocalStack: false` path is affected — LocalStack-only usage never reaches this code. **Fixed in v2.0.1**, so upgrading is the recommended fix. If you cannot upgrade yet, pin the AWS package to the last working version, which v2.0.0's range still allows: + + ```xml + + ``` + + See [issue #52](https://github.com/localstack-dotnet/localstack-dotnet-client/issues/52) for the full analysis. + - **SNS with LocalStack v3.7.2 and v3.8.0:** During development on the new version, it was discovered that SNS functional tests are not working in LocalStack versions v3.7.2 and v3.8.0. This issue was reported in LocalStack [issue #11652](https://github.com/localstack/localstack/issues/11652). The LocalStack team identified a bug related to handling SNS URIs and resolved it in [PR #11653](https://github.com/localstack/localstack/pull/11653). The fix will be included in an upcoming release of LocalStack. In the meantime, if you're using SNS, it is recommended to stick to version v3.7.1 of LocalStack until the fix is available. - **LocalStack Versions v2.0.1 - v2.2:** In versions v2.0.1 through v2.2 of LocalStack, the URL routing logic was changed, causing issues with SQS and S3 operations. Two issues were opened in LocalStack regarding this: [issue #8928](https://github.com/localstack/localstack/issues/8928) and [issue #8924](https://github.com/localstack/localstack/issues/8924). LocalStack addressed this problem with [PR #8962](https://github.com/localstack/localstack/pull/8962). Therefore, when using LocalStack.NET, either use version v2.0 of LocalStack (there are no issues with the v1 series as well) or the upcoming v2.3 version, or use the latest v3 series container from Docker Hub. From cba73092040a0cea4167be95983ca67c0bdecba9 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 13:51:33 +0300 Subject: [PATCH 13/15] fix(functional): only treat a missing stack as successful teardown DeleteStackAndWaitAsync caught every AmazonCloudFormationException from DescribeStacks and returned as though deletion had completed. Throttling, credential and service errors would all have been read as success, leaving the stack - and the SNS topic and SQS queue its template owns - behind in the shared container for whichever test ran next. That is the exact cross-test pollution this teardown was written to eliminate, so the swallow defeated its own purpose. Narrow the catch to a ValidationError, which is what CloudFormation answers with once the stack is gone, and let anything else fail the cleanup loudly. Verified against real LocalStack containers: CloudFormation scenarios 2/2 and the full functional suite 50/50 on net8.0, confirming LocalStack does report ValidationError for a deleted stack rather than some other error code. Raised by Copilot review on PR #53. --- .../CloudFormation/BaseCloudFormationScenario.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs index 9f6bbd0..7982895 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs @@ -92,7 +92,7 @@ private async Task DeleteStackAndWaitAsync(string stackName) return; } } - catch (AmazonCloudFormationException) + catch (AmazonCloudFormationException exception) when (IsStackGone(exception)) { // Once the stack is fully gone CloudFormation refuses to describe it, which is the success signal. return; @@ -103,4 +103,17 @@ private async Task DeleteStackAndWaitAsync(string stackName) throw new TimeoutException($"CloudFormation stack '{stackName}' was not deleted within {StackDeletionTimeout.TotalSeconds:0} seconds."); } + + /// + /// Distinguishes "the stack is gone" from every other CloudFormation failure. + /// + /// + /// Describing a deleted stack fails with a ValidationError. Catching + /// indiscriminately would also swallow throttling, credential and service errors, reporting a clean teardown while the + /// stack - and the SNS topic and SQS queue its template owns - stayed behind in the shared container for the next test. + /// + private static bool IsStackGone(AmazonCloudFormationException exception) + { + return string.Equals(exception.ErrorCode, "ValidationError", StringComparison.Ordinal); + } } \ No newline at end of file From 47a6740d9020e2155712ca5fbb85c5dba828f313 Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 13:51:33 +0300 Subject: [PATCH 14/15] fix(extensions): expand generic arguments in the constructor diagnostic DescribeConstructors rendered parameter types with Type.Name, which collapses Action to "Action`2" - dropping precisely the part that identifies the parameter AWS added in 4.0.4. That text is what a bug report quotes, and diagnosing the next signature change without a version bisect is the whole reason the discovered signatures are included in the exception. Spell the arguments out, so the message reads .ctor(AWSOptions, Action). Uses Split rather than IndexOf because the StringComparison overload CA1307 asks for does not exist on netstandard2.0, which this project still targets. Covered by a test asserting the arguments survive and no backtick remains. Raised by Copilot review on PR #53. --- .../AwsClientFactoryWrapper.cs | 39 ++++++++++++++++++- .../AwsClientFactoryWrapperResolutionTests.cs | 11 ++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs index 50599ea..b70fe07 100644 --- a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs +++ b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs @@ -178,7 +178,7 @@ internal static string DescribeConstructors(ConstructorInfo[] candidates) builder.Append(", "); } - builder.Append(parameters[index].ParameterType.Name); + AppendTypeName(builder, parameters[index].ParameterType); } builder.Append(')'); @@ -186,4 +186,41 @@ internal static string DescribeConstructors(ConstructorInfo[] candidates) return builder.ToString(); } + + /// + /// Writes a readable type name, expanding generic arguments. + /// + /// + /// renders the parameter added in 4.0.4 as Action`2, dropping exactly the part + /// that identifies it. Since this text is what a bug report will quote, the arguments are spelled out so the signature + /// reads as Action<ClientConfig, IServiceProvider>. + /// + private static void AppendTypeName(StringBuilder builder, Type type) + { + if (!type.IsGenericType) + { + builder.Append(type.Name); + + return; + } + + // Split rather than IndexOf: the StringComparison overload CA1307 asks for does not exist on + // netstandard2.0, and this runs once, on a failure path, so the extra array is irrelevant. + builder.Append(type.Name.Split('`')[0]); + builder.Append('<'); + + Type[] arguments = type.GetGenericArguments(); + + for (var index = 0; index < arguments.Length; index++) + { + if (index > 0) + { + builder.Append(", "); + } + + AppendTypeName(builder, arguments[index]); + } + + builder.Append('>'); + } } diff --git a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs index 5579227..a913394 100644 --- a/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs +++ b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs @@ -123,6 +123,17 @@ public void DescribeConstructors_Should_Render_Discovered_Signatures() Assert.Contains(".ctor(", described, StringComparison.Ordinal); } + [Fact] + public void DescribeConstructors_Should_Expand_Generic_Arguments() + { + // Type.Name alone renders this parameter as "Action`2", dropping the very part that identifies it. + // This text is what a bug report quotes, so the arguments have to survive. + string described = AwsClientFactoryWrapper.DescribeConstructors(CtorsOf()); + + Assert.Contains("Action", described, StringComparison.Ordinal); + Assert.DoesNotContain("`", described, StringComparison.Ordinal); + } + [Fact] public void DescribeConstructors_Should_Report_None_When_Empty() { From 0aa4c9ba6be320977c1b0ff8167a64ebeebfa73c Mon Sep 17 00:00:00 2001 From: Blind-Striker Date: Wed, 29 Jul 2026 13:56:28 +0300 Subject: [PATCH 15/15] docs(functional): record the measured CloudFormation delete response The ValidationError filter added in cba7309 was justified with a claim the evidence did not actually support: the functional suite passing is also consistent with the catch never being entered, because the poll loop can exit through the DELETE_COMPLETE branch instead. A filter that always returned false would have passed the same tests. Measured it directly against both LocalStack versions these scenarios run on. Creating a stack, deleting it, then describing it by name gives, identically on 3.7.1 and 4.6.0: AmazonCloudFormationException ErrorCode = ValidationError StatusCode = 400 BadRequest Message = Stack with id does not exist The throw also arrives on the very first poll - LocalStack drops the stack outright rather than parking it in DELETE_COMPLETE - so this catch is the loop's real exit path rather than a fallback, which is the part the passing suite could not have told us. Comment only; the filter itself is unchanged and now carries its evidence. --- .../BaseCloudFormationScenario.cs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs b/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs index 7982895..8903395 100644 --- a/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs +++ b/tests/LocalStack.Client.Functional.Tests/Scenarios/CloudFormation/BaseCloudFormationScenario.cs @@ -108,9 +108,24 @@ private async Task DeleteStackAndWaitAsync(string stackName) /// Distinguishes "the stack is gone" from every other CloudFormation failure. /// /// - /// Describing a deleted stack fails with a ValidationError. Catching - /// indiscriminately would also swallow throttling, credential and service errors, reporting a clean teardown while the - /// stack - and the SNS topic and SQS queue its template owns - stayed behind in the shared container for the next test. + /// Catching indiscriminately would also swallow throttling, credential + /// and service errors, reporting a clean teardown while the stack - and the SNS topic and SQS queue its template + /// owns - stayed behind in the shared container for the next test. + /// + /// The error code below is measured, not assumed. Against both LocalStack versions these scenarios run on, creating + /// a stack, deleting it and then describing it by name gives: + /// + /// + /// AmazonCloudFormationException + /// ErrorCode = ValidationError + /// StatusCode = 400 BadRequest + /// Message = Stack with id <name> does not exist + /// + /// + /// Identical on 3.7.1 and 4.6.0, and the throw arrives on the first poll - LocalStack drops the stack immediately + /// rather than parking it in DELETE_COMPLETE, so this catch really is the loop's exit path, not a fallback. + /// If a future LocalStack starts reporting DELETE_COMPLETE instead, the status check above already covers it. + /// /// private static bool IsStackGone(AmazonCloudFormationException exception) {