Skip to content

fix(extensions): tolerate ClientFactory<T> constructor change in AWS SDK 4.0.4+ (#52) - #53

Draft
Blind-Striker wants to merge 6 commits into
masterfrom
feature/issue-52-clientfactory-compat
Draft

fix(extensions): tolerate ClientFactory<T> constructor change in AWS SDK 4.0.4+ (#52)#53
Blind-Striker wants to merge 6 commits into
masterfrom
feature/issue-52-clientfactory-compat

Conversation

@Blind-Striker

Copy link
Copy Markdown
Member

📝 Description

What does this PR do?

Fixes the LocalStackClientConfigurationException thrown when UseLocalStack is false, refreshes the dependency graph, and adds the missing safety net that would have caught this class of problem.

Root cause. AWSSDK.Extensions.NETCore.Setup 4.0.4 (published 2026-05-20) changed the internal ClientFactory<T> constructor:

// <= 4.0.3.40  (2026-05-15)
internal ClientFactory(AWSOptions awsOptions)

// >= 4.0.4     (2026-05-20)   <-- break
internal ClientFactory(AWSOptions awsOptions, Action<ClientConfig, IServiceProvider> configAction = null)

The added parameter is optional, so from AWS's side this is source-compatible and shipped as a patch-level bump with nothing in the release notes. It is binary-breaking for our exact-signature reflection lookup in AwsClientFactoryWrapper, which returned null and threw.

Three things kept it quiet for ~70 days:

  1. Only the UseLocalStack: false path goes through this wrapper — LocalStack-only users never hit it.
  2. Our nuspec declares [4.0.2, ), so consumers float to the newest version and break.
  3. CI pinned 4.0.2 exactly, so the pipeline only ever saw the one version that still worked.

The existing test CreateServiceClient_Should_Create_Client_When_UseLocalStack_False was always correct — it goes red the moment the pin moves past 4.0.4. The suite was never the gap; the pin was.

The fix. 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 not a workaround — it is exactly what the SDK itself passes (= null, null-guarded before use). Requiring AWSOptions in first position also naturally 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 requiring a version bisect.

Related Issue(s):

🔄 Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🧹 Code cleanup/refactoring
  • ⚡ Performance improvement
  • 🧪 Test improvements

🎯 Target Version Track

  • v1.x (AWS SDK v3) - LTS Branch (sdkv3-lts)
  • v2.x (AWS SDK v4) - Current Branch (master)
  • Both versions (requires separate PRs)

v1.x is unaffected — it reflects over the non-generic ClientFactory with (Type, AWSOptions) in the SDK v3 Setup package, a different major line.

🧪 Testing

How has this been tested?

  • Unit tests added/updated
  • Integration tests added/updated
  • Functional tests added/updated
  • Manual testing performed
  • Tested with LocalStack container
  • Tested across multiple .NET versions

Functional/container tests were not run locally (Docker); they run on the Linux CI leg, which is why this is opened as a draft — letting the pipeline exercise everything.

Reproduced the original failure first using the reporter's own repro, then verified the fix against all three SDK shapes:

Track AWSSDK.Extensions.NETCore.Setup Result
current (default) 4.0.100.5 (post-4.0.4) ✅ 52/52
legacy 4.0.3.40 (pre-4.0.4) ✅ 52/52
latest (canary) 4.0.100.7 — the reporter's exact version ✅ 52/52

Full matrix on the default track: 1094 tests passing, 0 warnings, 0 errors.

New tests model both known constructor shapes — plus a shape AWS has not shipped — with local stand-ins, because a test bound to the real SDK can only ever see whichever version is pinned. The real-SDK check is deliberately shape-agnostic so it passes on either track.

Test Environment:

  • LocalStack Version: n/a locally (container tests deferred to CI); functional suite targets 3.7.1 and 4.6.0
  • .NET Versions Tested: net472, net8.0, net9.0 (SDK 10.0.302)
  • Operating Systems: Windows 11 locally; Windows/Linux/macOS in CI

📚 Documentation

  • Code is self-documenting with clear naming
  • XML documentation comments added/updated
  • README.md updated (if needed)
  • CHANGELOG.md entry added
  • Breaking changes documented

✅ Code Quality Checklist

  • Code follows project coding standards
  • No new analyzer warnings introduced
  • All tests pass locally
  • No merge conflicts
  • Branch is up to date with target branch
  • Commit messages follow Conventional Commits

🔍 Additional Notes

Breaking Changes:

None. Public API is unchanged; the new resolution helpers are internal, exposed to tests via InternalsVisibleTo.

Performance Impact:

Negligible. Constructor selection is a one-time reflection scan per service registration, replacing a single GetConstructor call with a short loop over the same candidate set.

Dependencies:

Refreshed the whole graph. AWSSDK.Core4.0.100.6 and AWSSDK.Extensions.NETCore.Setup4.0.100.5 (near-latest, well-adopted, both published 2026-07-17 — a coherent pair), plus 120 AWSSDK service packages, Microsoft.Extensions.* → 10.0.10, and the analyzer/test toolchain to latest.

This also 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.

Two upstream behaviour changes required source adaptations — none in src/:

  • Testcontainers 4.13.0 obsoleted the parameterless LocalStackBuilder() ctor (CS0618 → error).
  • 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.

Also: global.json → SDK 10.0.302, LICENSE copyright range → 2026.

Preventing recurrence — the AwsSetupTrack switch:

A new MSBuild property in Directory.Packages.props moves the entire package graph between SDK shapes:

current (default) - post-4.0.4 shape, what consumers realistically resolve
legacy            - pre-4.0.4 shape, proves backwards compatibility
latest            - floating, scheduled canary only

Exposed through Cake as --aws-setup-track, with a Linux CI leg on legacy and a scheduled canary that floats to the newest AWS SDK (deliberately without NuGet caching). A canary failure means "AWS moved", not "we broke something".

Two constraints worth knowing for review:

  • A per-project VersionOverride does not work here — src and tests must move together or NuGet reports NU1605 (package downgrade), which is an error under TreatWarningsAsErrors. Hence a central switch.
  • Floating versions are opt-in under CPM (NU1011), so CentralPackageFloatingVersionsEnabled is enabled only on the canary track. current and legacy stay strictly reproducible.

CI/CD modernisation:

Every action bumped: 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, while the explicit 8.0.x/9.0.x entries are kept on purpose — the .NET 10 SDK alone cannot execute net8.0/net9.0 test assemblies, so dropping them would break the multi-TFM run.

No setup-node pinning was added: nothing in the workflows uses it, 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.

🎯 Reviewer Focus Areas

Please pay special attention to:

  • Security implications
  • Performance impact
  • Breaking changes
  • Test coverage
  • Documentation completeness
  • Backward compatibility

Specifically worth a second pair of eyes:

  1. SelectFactoryConstructor tie-breaking — fewest-parameter-wins. Reasonable today, but it is the rule that decides behaviour if AWS ever ships two AWSOptions-first overloads.
  2. The S8969 fixes. SonarAnalyzer flagged 8 null-forgiving operators as redundant; 7 were not — they were masking CS8620 nullability variance. Following the analyzer literally broke the build. The correct fix was Dictionary<string, string?>. Worth confirming you agree with that reading.
  3. CA1873 was added to the existing CA1848/CA2254 logging-performance opt-out in the test/sandbox projects rather than restructuring demo logging calls. src/ has zero occurrences.

Known follow-ups (deliberately not in this PR):

  • PackageExtensionVersion bump 2.0.0 → 2.0.1 — a release decision.
  • Two High-severity transitive vulnerabilities reachable only from LocalStack.Client.Functional.Tests (AutoFixture 4.18.1FareNETStandard.Library 1.6.1System.Net.Http 4.3.0 / System.Text.RegularExpressions 4.3.0). Test-only, never shipped, and AutoFixture 4.18.1 is already latest so it cannot be fixed by upgrading. Note these are invisible to dotnet build because NuGetAuditMode defaults to direct.
  • The longer-term reflection-free path: AWSOptions.CreateServiceClient<T>() is public and has existed in every release since 3.2.8-rc (2016-09-08). It would remove this reflection entirely — and with it the [RequiresDynamicCode]/[RequiresUnreferencedCode] annotations and ILLink.Descriptors.xml — but it has no IServiceProvider overload, so the AWSOptions fallback chain and logger wiring would need reimplementing. Tracked for 2.1 alongside the AOT work.

📸 Screenshots/Examples

The failure this fixes, from the original report:

Unhandled exception. LocalStack.Client.Extensions.Exceptions.LocalStackClientConfigurationException:
    ClientFactory<T> missing constructor with AWSOptions parameter.
   at LocalStack.Client.Extensions.AwsClientFactoryWrapper.CreateServiceClient[TClient](IServiceProvider provider, AWSOptions awsOptions)
   at LocalStack.Client.Extensions.ServiceCollectionExtensions.<>c__DisplayClass12_0`1.<GetServiceFactoryDescriptor>b__0(IServiceProvider provider)

Unchanged user code — this now works on both old and new AWS SDK versions:

services.AddLocalStack(Configuration);
services.AddDefaultAWSOptions(Configuration.GetAWSOptions());
services.AddAwsService<IAmazonS3>();   // UseLocalStack: false no longer throws

Running the suite against the older SDK shape:

./build.sh --target tests --aws-setup-track legacy --force-restore true

By submitting this pull request, I confirm that:

  • I have read and agree to the project's Code of Conduct
  • I understand that this contribution may be subject to the .NET Foundation CLA
  • My contribution is licensed under the same terms as the project (MIT License)

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<T>(AWSOptions, Action<...>)
  legacy            - pre-4.0.4  ClientFactory<T>(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<string, string?>.

Also updates global.json to SDK 10.0.302 and the LICENSE copyright range to 2026.
….4+ (#52)

AWSSDK.Extensions.NETCore.Setup 4.0.4 (2026-05-20) changed the internal
ClientFactory<T> constructor:

  <= 4.0.3.40  ClientFactory(AWSOptions awsOptions)
  >= 4.0.4     ClientFactory(AWSOptions awsOptions,
                             Action<ClientConfig, IServiceProvider> 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.
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-actions

Copy link
Copy Markdown

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 2 package(s) with unknown licenses.
See the Details below.

License Issues

.github/workflows/dependency-review.yml

PackageVersionLicenseIssue Type
actions/checkout7.*.*NullUnknown License
actions/dependency-review-action5.*.*NullUnknown License

OpenSSF Scorecard

PackageVersionScoreDetails
actions/actions/checkout 7.*.* 🟢 6.9
Details
CheckScoreReason
Binary-Artifacts🟢 10no binaries found in the repo
Code-Review🟢 10all changesets reviewed
Maintained🟢 1025 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Fuzzing⚠️ 0project is not fuzzed
Pinned-Dependencies🟢 3dependency not pinned by hash detected -- score normalized to 3
License🟢 10license file detected
Packaging⚠️ -1packaging workflow not detected
Signed-Releases⚠️ -1no releases found
Security-Policy🟢 9security policy file detected
Branch-Protection🟢 5branch protection is not maximal on development and all release branches
SAST🟢 10SAST tool is run on all commits
actions/actions/dependency-review-action 5.*.* 🟢 7.7
Details
CheckScoreReason
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
Security-Policy🟢 9security policy file detected
Code-Review🟢 10all changesets reviewed
Packaging⚠️ -1packaging workflow not detected
Binary-Artifacts🟢 10no binaries found in the repo
Token-Permissions🟢 9detected GitHub workflow tokens with excessive permissions
Maintained🟢 1021 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies⚠️ 1dependency not pinned by hash detected -- score normalized to 1
Fuzzing⚠️ 0project is not fuzzed
License🟢 10license file detected
Signed-Releases⚠️ -1no releases found
Branch-Protection🟢 6branch protection is not maximal on development and all release branches
SAST🟢 9SAST tool detected but not run on all commits

Scanned Files

  • .github/workflows/dependency-review.yml

Comment on lines +25 to +65
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
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.
…ainers

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.
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-<branch>.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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Still having issues with AWS SDK v4

2 participants