diff --git a/.github/workflows/aws-sdk-canary.yml b/.github/workflows/aws-sdk-canary.yml new file mode 100644 index 0000000..b2a1cb0 --- /dev/null +++ b/.github/workflows/aws-sdk-canary.yml @@ -0,0 +1,70 @@ +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 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: + # 05:00 UTC daily + - cron: "0 5 * * *" + workflow_dispatch: + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_NOLOGO: true + +permissions: + contents: read + +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 + + # 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 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 + + - 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..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: @@ -49,23 +59,32 @@ 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 + # 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@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') }} @@ -80,7 +99,7 @@ jobs: - 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 +127,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 }} @@ -120,7 +139,7 @@ jobs: continuous-deployment: name: "Continuous Deployment" runs-on: ubuntu-22.04 - needs: build-and-test + needs: [build-and-test] if: | github.repository == 'localstack-dotnet/localstack-dotnet-client' && ((github.event_name == 'push' && github.ref == 'refs/heads/master') || @@ -134,27 +153,32 @@ 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') }} 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" + 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: | @@ -207,9 +231,9 @@ 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 }}" + name: "packages-${{ env.SAFE_REF }}-${{ github.run_number }}" path: | artifacts/*.nupkg artifacts/*.snupkg 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..8ce393f 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -44,26 +44,24 @@ 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') }} 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 @@ -124,7 +122,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: | 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/CHANGELOG.md b/CHANGELOG.md index 92632c8..a8ba8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ 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) +## [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 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 + +- **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.** + +--- + ## [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/Directory.Build.props b/Directory.Build.props index 2c1d5d2..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 @@ -32,6 +32,13 @@ true + + current + + + true + diff --git a/Directory.Packages.props b/Directory.Packages.props index 2ebfc00..ca47eed 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,177 +1,189 @@ - + + + + + + + + + + - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + 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/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/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/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 e41badd..f205f4a 100644 --- a/build/LocalStack.Build/BuildContext.cs +++ b/build/LocalStack.Build/BuildContext.cs @@ -31,7 +31,10 @@ 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(); + // 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); 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"), @@ -46,11 +49,11 @@ 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"); - var packIdBuilder = ImmutableDictionary.CreateBuilder(); + var packIdBuilder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); packIdBuilder.AddRange( [ new KeyValuePair(LocalStackClientProjName, LocalStackClientProjFile), @@ -81,6 +84,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; } @@ -276,49 +285,68 @@ 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; } /// /// 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 ToPreReleaseIdentifier(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) @@ -327,7 +355,31 @@ private string GetGitCommitSha() } // Fallback to timestamp-based identifier - return DateTime.UtcNow.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); + return ToPreReleaseIdentifier(DateTime.UtcNow.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// Makes an arbitrary string safe to use as a single pre-release identifier. + /// + /// + /// 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 ToPreReleaseIdentifier(string identifier) + { + var builder = new StringBuilder(identifier.Length); + + foreach (char character in identifier) + { + builder.Append(char.IsAsciiLetterOrDigit(character) || character == '-' ? character : '-'); + } + + string sanitised = builder.ToString(); + + return NuGetVersion.TryParse($"0.0.0-{sanitised}", out NuGetVersion? _) ? sanitised : $"g{sanitised}"; } private string[] GetProjectTargetFrameworks(string csprojPath) 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/build/LocalStack.Build/GlobalUsings.cs b/build/LocalStack.Build/GlobalUsings.cs index 7ce57ab..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; @@ -20,6 +22,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; 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 2bb272e..8a1fbb6 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "9.0.200", + "version": "9.0.316", "rollForward": "latestFeature", "allowPrerelease": false } -} \ No newline at end of file +} diff --git a/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs b/src/LocalStack.Client.Extensions/AwsClientFactoryWrapper.cs index ffe3977..b70fe07 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 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"; @@ -23,22 +27,200 @@ 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(", "); + } + + AppendTypeName(builder, parameters[index].ParameterType); + } + + builder.Append(')'); + } + + 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/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/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/LocalStack.Client.Extensions.csproj b/src/LocalStack.Client.Extensions/LocalStack.Client.Extensions.csproj index d23ee89..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 @@ -28,6 +28,12 @@ + + + + + diff --git a/src/LocalStack.Client.Extensions/README.md b/src/LocalStack.Client.Extensions/README.md index 88d6fb0..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. @@ -285,6 +293,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/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/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/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 diff --git a/src/LocalStack.Client/README.md b/src/LocalStack.Client/README.md index 88d6fb0..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. @@ -285,6 +293,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/AwsClientFactoryWrapperResolutionTests.cs b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs new file mode 100644 index 0000000..a913394 --- /dev/null +++ b/tests/LocalStack.Client.Extensions.Tests/AwsClientFactoryWrapperResolutionTests.cs @@ -0,0 +1,229 @@ +// 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). 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 +{ + [Fact] + public void SelectFactoryConstructor_Should_Select_Ctor_On_The_Current_Shape() + { + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(CtorsOf()); + + Assert.NotNull(selected); + Assert.Equal(2, selected.GetParameters().Length); + Assert.Equal(typeof(AWSOptions), selected.GetParameters()[0].ParameterType); + } + + [Fact] + 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] + 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] + 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 = (CurrentShapeFactory)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_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() + { + Assert.Equal("", AwsClientFactoryWrapper.DescribeConstructors([])); + } + + [Fact] + public void SelectFactoryConstructor_Should_Resolve_Against_The_Real_Aws_Sdk() + { + // 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)); + + ConstructorInfo[] candidates = factoryType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + ConstructorInfo? selected = AwsClientFactoryWrapper.SelectFactoryConstructor(candidates); + + Assert.NotNull(selected); + + ParameterInfo[] parameters = selected.GetParameters(); + Assert.NotEmpty(parameters); + Assert.Equal(typeof(AWSOptions), parameters[0].ParameterType); + } + + private static ConstructorInfo[] CtorsOf() + { + return typeof(T).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + } + + /// Mirrors AWSSDK.Extensions.NETCore.Setup >= 4.0.4, the shape we pin against. + private sealed class CurrentShapeFactory + { + private CurrentShapeFactory() + { + } + + internal CurrentShapeFactory(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/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/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/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/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..8903395 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,72 @@ 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 exception) when (IsStackGone(exception)) + { + // 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."); + } + + /// + /// Distinguishes "the stack is gone" from every other CloudFormation failure. + /// + /// + /// 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) + { + return string.Equals(exception.ErrorCode, "ValidationError", StringComparison.Ordinal); + } } \ 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 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")