diff --git a/.pipeline/OneBranch/PublishFeeds-Sandbox.yml b/.pipeline/OneBranch/PublishFeeds-Sandbox.yml new file mode 100644 index 00000000..919d39a9 --- /dev/null +++ b/.pipeline/OneBranch/PublishFeeds-Sandbox.yml @@ -0,0 +1,888 @@ +################################################################################# +# # +# ███ SANDBOX / TEST-ONLY PIPELINE — NOT FOR PRODUCTION ███ # +# # +# This pipeline publishes the **mock TDS** artifacts (a TEST helper used to # +# stand up a fake SQL Server for client tests) to the mssql-rs_Public Azure # +# Artifacts feed. It exists for fast, manual experimentation only. # +# # +# NOTHING produced by this pipeline is a production artifact: # +# * Python wheels are stamped with PEP 440 *.devN prerelease versions # +# UNLESS the releaseVersion parameter is set (then BASE is published). # +# * Crates are stamped with -dev.. prerelease versions # +# UNLESS the releaseVersion parameter is set (then BASE is published). # +# * Publishing is OPT-IN. With the default parameters this pipeline only # +# BUILDS and runs a DRY RUN — it does NOT upload anything. # +# # +# Do not point real consumers at versions produced here. Treat the feed # +# entries it creates as disposable test data. # +# # +# Documentation: https://aka.ms/obpipelines # +# Yaml Schema: https://aka.ms/obpipelines/yaml/schema # +################################################################################# + +name: 'Publish Feeds (Sandbox)' + +# Manual only. No CI trigger, no PR validation — this is a hand-queued sandbox. +trigger: none +pr: none + +parameters: +# Publishing is opt-in for safety. When false (the default) the corresponding +# stage still BUILDS everything but logs a clear DRY RUN message instead of +# uploading to the feed. +- name: publishPython + displayName: 'Upload mock Python wheels to feed (false = build + dry run only)' + type: boolean + default: false + +- name: publishCrate + displayName: 'Publish mssql-mock-tds crate to feed (false = cargo publish --dry-run)' + type: boolean + default: false + +- name: releaseVersion + displayName: 'Publish Python wheels as a RELEASE version (no .dev suffix, e.g. 1.0.0). Unchecked = .dev prerelease.' + type: boolean + default: false + +variables: + CDP_DEFINITION_BUILD_COUNT: $[counter('', 0)] # For onebranch.pipeline.version task + LinuxContainerImage: 'mcr.microsoft.com/onebranch/azurelinux/build:3.0' + WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' + DEBIAN_FRONTEND: noninteractive + toolchainFeed: 'https://sqlclientdrivers.pkgs.visualstudio.com/mssql-rs/_packaging/mssql-rs/nuget/v3/index.json' + # Twine repository name (matches the .pypirc entry created by TwineAuthenticate@1). + pythonFeedName: 'mssql-rs_Public' + # Project-scoped Azure Artifacts feed: /. + pythonArtifactFeed: 'public/mssql-rs_Public' + # Cargo registry name defined in .cargo/config.ci.toml. + cargoRegistry: 'mssql-rs_Public' + # Public (anonymous-read) index URLs for the release preflight checks. + pythonSimpleIndex: 'https://pkgs.dev.azure.com/sqlclientdrivers/public/_packaging/mssql-rs_Public/pypi/simple/' + cargoSparseIndex: 'https://pkgs.dev.azure.com/sqlclientdrivers/public/_packaging/mssql-rs_Public/Cargo/index/' + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates + parameters: + featureFlags: + EnableCDPxPAT: false + WindowsHostVersion: 1ESWindows2022 + globalSdl: + binskim: + scanOutputDirectoryOnly: true + clippy: + enabled: true + stages: + ########################################################################### + # Stage 1: PublishMockPython + # Build the abi3 wheel(s) for mssql-mock-tds-py (Linux x64 + Windows x64) + # and (opt-in) twine upload to the feed's PyPI index. + # + # abi3-py39 => ONE wheel per (OS, arch) covering Python >= 3.9. No loop over + # Python versions (unlike mssql-py-core). + # + # SANDBOX matrix: Linux x64/ARM64 (manylinux_2_34), musllinux x64/ARM64 + # (Alpine, vendored OpenSSL), Windows x64/ARM64, and macOS universal2. + ########################################################################### + - stage: PublishMockPython + displayName: 'Publish Mock Python (sandbox)' + jobs: + ##################################################################### + # Preflight — when publishing a RELEASE version (releaseVersion=true, + # publishPython=true) fail in seconds if that exact version is already + # on the feed, instead of building seven wheels first and only hitting + # the duplicate-version rejection at twine upload. All build jobs + # dependsOn this gate. For dev/dry-run combinations the job is a no-op. + ##################################################################### + - job: PreflightVersionCheck + displayName: 'Preflight release version' + pool: + type: windows + isCustom: true + name: RUST-1ES-POOL-WUS3 + demands: + - imageOverride -equals RUST-Win22-Sql25-1P + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + - ${{ if and(eq(parameters.releaseVersion, true), eq(parameters.publishPython, true)) }}: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + architecture: 'x64' + addToPath: true + - pwsh: | + $ErrorActionPreference = 'Stop' + python .pipeline/scripts/check-version-not-published.py ` + mssql-mock-tds-py/pyproject.toml mssql-mock-tds '$(pythonSimpleIndex)' + displayName: 'Fail if release version already on feed' + - ${{ if not(and(eq(parameters.releaseVersion, true), eq(parameters.publishPython, true))) }}: + - pwsh: | + Write-Host 'Preflight skipped: not a publishing release run (releaseVersion + publishPython both required).' + displayName: 'Preflight skipped' + + ##################################################################### + # Linux x64 — manylinux_2_34 container build + ##################################################################### + - job: BuildLinux_x64 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (Linux x64)' + pool: + type: linux + isCustom: true + name: RUST-1ES-POOL-WUS3 + demands: + - imageOverride -equals RUST-1ES-UBUSLIM + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_linux_x64' + CARGO_TARGET_DIR: $(Build.SourcesDirectory) + steps: + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Linux + - script: .pipeline/scripts/install-rustup.sh + displayName: 'Install Rustup' + - task: DockerInstaller@0 + inputs: + dockerVersion: '29.0.0' + displayName: 'Install Docker' + - template: /.pipeline/templates/install-ubuntu-dependency.yaml@self + parameters: + installDocker: true + # Stamp a PEP 440 prerelease version into BOTH the pyproject.toml and the + # Cargo.toml so maturin bakes the right version into the wheel. The mounted + # workspace is what the container builds, so we patch on the host first. + - script: | + set -euo pipefail + BASE=$(grep -m1 -E '^version\s*=' mssql-mock-tds-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/') + case "${RELEASE_VERSION}" in + True|true|TRUE) VER="${BASE}" ;; # release: publish BASE as-is (e.g. 1.0.0) + *) VER="${BASE}-dev$(date -u +%Y%m%d)${BUILD_BUILDID}" ;; # PEP 440 normalizes -dev -> .dev + esac + echo "Sandbox wheel version: ${VER}" + # pyproject.toml [project].version + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/pyproject.toml + # Cargo.toml [package].version (first version line) + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/Cargo.toml + echo "##vso[task.setvariable variable=mockWheelVersion]${VER}" + displayName: 'Stamp sandbox wheel version' + env: + BUILD_BUILDID: $(Build.BuildId) + RELEASE_VERSION: ${{ parameters.releaseVersion }} + - script: | + set -euo pipefail + CONTAINER_IMAGE="ghcr.io/microsoft/mssql-rs/python-build/manylinux_2_34_x86_64_rust:latest" + echo "Building mock wheel in container: $CONTAINER_IMAGE" + mkdir -p "$(ob_outputDirectory)/wheels" + bash .pipeline/scripts/docker-cargo-run.sh --rm \ + -v "$(Build.SourcesDirectory):/workspace" \ + -v "$(ob_outputDirectory)/wheels:/workspace/target/wheels" \ + -e "WORKSPACE_DIR=/workspace" \ + -e "OUTPUT_DIR=/workspace/target/wheels" \ + "$CONTAINER_IMAGE" \ + bash /workspace/scripts/build-mock-python-wheel-in-container.sh + sudo chown -R $(whoami):$(whoami) "$(Build.SourcesDirectory)/target" || true + WHEEL_COUNT=$(find "$(ob_outputDirectory)/wheels" -type f -name '*.whl' | wc -l) + if [ "$WHEEL_COUNT" -eq 0 ]; then + echo "ERROR: No mock wheels were produced!" + exit 1 + fi + echo "$WHEEL_COUNT wheel(s) built." + ls -lh "$(ob_outputDirectory)/wheels" + displayName: 'Build mock abi3 wheel (manylinux_2_34)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - task: PublishPipelineArtifact@1 + displayName: 'Publish Linux x64 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildLinux_x64_mock_linux_x64' + publishLocation: 'pipeline' + + ##################################################################### + # Linux ARM64 — manylinux_2_34 aarch64 container build + ##################################################################### + - job: BuildLinux_arm64 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (Linux ARM64)' + pool: + type: linux + isCustom: true + name: RUST-1ES-POOL-ARM-WUS3 + demands: + - imageOverride -equals RUST-UBUNTU-ARM64 + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_linux_arm64' + CARGO_TARGET_DIR: $(Build.SourcesDirectory) + steps: + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Linux + - script: .pipeline/scripts/install-rustup.sh + displayName: 'Install Rustup' + - task: DockerInstaller@0 + inputs: + dockerVersion: '29.0.0' + displayName: 'Install Docker' + - template: /.pipeline/templates/install-ubuntu-dependency.yaml@self + parameters: + installDocker: true + # Stamp a PEP 440 prerelease version into BOTH the pyproject.toml and the + # Cargo.toml so maturin bakes the right version into the wheel. The mounted + # workspace is what the container builds, so we patch on the host first. + - script: | + set -euo pipefail + BASE=$(grep -m1 -E '^version\s*=' mssql-mock-tds-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/') + case "${RELEASE_VERSION}" in + True|true|TRUE) VER="${BASE}" ;; # release: publish BASE as-is (e.g. 1.0.0) + *) VER="${BASE}-dev$(date -u +%Y%m%d)${BUILD_BUILDID}" ;; # PEP 440 normalizes -dev -> .dev + esac + echo "Sandbox wheel version: ${VER}" + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/pyproject.toml + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/Cargo.toml + echo "##vso[task.setvariable variable=mockWheelVersion]${VER}" + displayName: 'Stamp sandbox wheel version' + env: + BUILD_BUILDID: $(Build.BuildId) + RELEASE_VERSION: ${{ parameters.releaseVersion }} + - script: | + set -euo pipefail + CONTAINER_IMAGE="ghcr.io/microsoft/mssql-rs/python-build/manylinux_2_34_aarch64_rust:latest" + echo "Building mock wheel in container: $CONTAINER_IMAGE" + mkdir -p "$(ob_outputDirectory)/wheels" + bash .pipeline/scripts/docker-cargo-run.sh --rm \ + -v "$(Build.SourcesDirectory):/workspace" \ + -v "$(ob_outputDirectory)/wheels:/workspace/target/wheels" \ + -e "WORKSPACE_DIR=/workspace" \ + -e "OUTPUT_DIR=/workspace/target/wheels" \ + "$CONTAINER_IMAGE" \ + bash /workspace/scripts/build-mock-python-wheel-in-container.sh + sudo chown -R $(whoami):$(whoami) "$(Build.SourcesDirectory)/target" || true + WHEEL_COUNT=$(find "$(ob_outputDirectory)/wheels" -type f -name '*.whl' | wc -l) + if [ "$WHEEL_COUNT" -eq 0 ]; then + echo "ERROR: No mock wheels were produced!" + exit 1 + fi + echo "$WHEEL_COUNT wheel(s) built." + ls -lh "$(ob_outputDirectory)/wheels" + displayName: 'Build mock abi3 wheel (manylinux_2_34 aarch64)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - task: PublishPipelineArtifact@1 + displayName: 'Publish Linux ARM64 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildLinux_arm64_mock_linux_arm64' + publishLocation: 'pipeline' + + ##################################################################### + # musllinux x64 — musllinux_1_2 (Alpine) container build. + # Built with vendored (static) OpenSSL so the wheel is self-contained + # and does not depend on Alpine's libssl at the consumer side. + ##################################################################### + - job: BuildLinuxMusl_x64 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (musllinux x64)' + pool: + type: linux + isCustom: true + name: RUST-1ES-POOL-WUS3 + demands: + - imageOverride -equals RUST-1ES-UBUSLIM + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_linux_musl_x64' + CARGO_TARGET_DIR: $(Build.SourcesDirectory) + steps: + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Linux + - script: .pipeline/scripts/install-rustup.sh + displayName: 'Install Rustup' + - task: DockerInstaller@0 + inputs: + dockerVersion: '29.0.0' + displayName: 'Install Docker' + - template: /.pipeline/templates/install-ubuntu-dependency.yaml@self + parameters: + installDocker: true + - script: | + set -euo pipefail + BASE=$(grep -m1 -E '^version\s*=' mssql-mock-tds-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/') + case "${RELEASE_VERSION}" in + True|true|TRUE) VER="${BASE}" ;; # release: publish BASE as-is (e.g. 1.0.0) + *) VER="${BASE}-dev$(date -u +%Y%m%d)${BUILD_BUILDID}" ;; # PEP 440 normalizes -dev -> .dev + esac + echo "Sandbox wheel version: ${VER}" + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/pyproject.toml + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/Cargo.toml + echo "##vso[task.setvariable variable=mockWheelVersion]${VER}" + displayName: 'Stamp sandbox wheel version' + env: + BUILD_BUILDID: $(Build.BuildId) + RELEASE_VERSION: ${{ parameters.releaseVersion }} + - script: | + set -euo pipefail + CONTAINER_IMAGE="ghcr.io/microsoft/mssql-rs/python-build/musllinux_1_2_x86_64_rust:latest" + echo "Building mock wheel in container: $CONTAINER_IMAGE" + mkdir -p "$(ob_outputDirectory)/wheels" + bash .pipeline/scripts/docker-cargo-run.sh --rm \ + -v "$(Build.SourcesDirectory):/workspace" \ + -v "$(ob_outputDirectory)/wheels:/workspace/target/wheels" \ + -e "WORKSPACE_DIR=/workspace" \ + -e "OUTPUT_DIR=/workspace/target/wheels" \ + -e "MATURIN_FEATURES=vendored-openssl" \ + "$CONTAINER_IMAGE" \ + bash /workspace/scripts/build-mock-python-wheel-in-container.sh + sudo chown -R $(whoami):$(whoami) "$(Build.SourcesDirectory)/target" || true + WHEEL_COUNT=$(find "$(ob_outputDirectory)/wheels" -type f -name '*.whl' | wc -l) + if [ "$WHEEL_COUNT" -eq 0 ]; then + echo "ERROR: No mock wheels were produced!" + exit 1 + fi + echo "$WHEEL_COUNT wheel(s) built." + ls -lh "$(ob_outputDirectory)/wheels" + displayName: 'Build mock abi3 wheel (musllinux_1_2 x86_64)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - task: PublishPipelineArtifact@1 + displayName: 'Publish musllinux x64 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildLinuxMusl_x64_mock_linux_musl_x64' + publishLocation: 'pipeline' + + ##################################################################### + # musllinux ARM64 — musllinux_1_2 aarch64 (Alpine) container build. + # Vendored (static) OpenSSL, same rationale as musllinux x64. + ##################################################################### + - job: BuildLinuxMusl_arm64 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (musllinux ARM64)' + pool: + type: linux + isCustom: true + name: RUST-1ES-POOL-ARM-WUS3 + demands: + - imageOverride -equals RUST-UBUNTU-ARM64 + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_linux_musl_arm64' + CARGO_TARGET_DIR: $(Build.SourcesDirectory) + steps: + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Linux + - script: .pipeline/scripts/install-rustup.sh + displayName: 'Install Rustup' + - task: DockerInstaller@0 + inputs: + dockerVersion: '29.0.0' + displayName: 'Install Docker' + - template: /.pipeline/templates/install-ubuntu-dependency.yaml@self + parameters: + installDocker: true + - script: | + set -euo pipefail + BASE=$(grep -m1 -E '^version\s*=' mssql-mock-tds-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/') + case "${RELEASE_VERSION}" in + True|true|TRUE) VER="${BASE}" ;; # release: publish BASE as-is (e.g. 1.0.0) + *) VER="${BASE}-dev$(date -u +%Y%m%d)${BUILD_BUILDID}" ;; # PEP 440 normalizes -dev -> .dev + esac + echo "Sandbox wheel version: ${VER}" + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/pyproject.toml + sed -i -E "0,/^version\s*=.*/s//version = \"${VER}\"/" mssql-mock-tds-py/Cargo.toml + echo "##vso[task.setvariable variable=mockWheelVersion]${VER}" + displayName: 'Stamp sandbox wheel version' + env: + BUILD_BUILDID: $(Build.BuildId) + RELEASE_VERSION: ${{ parameters.releaseVersion }} + - script: | + set -euo pipefail + CONTAINER_IMAGE="ghcr.io/microsoft/mssql-rs/python-build/musllinux_1_2_aarch64_rust:latest" + echo "Building mock wheel in container: $CONTAINER_IMAGE" + mkdir -p "$(ob_outputDirectory)/wheels" + bash .pipeline/scripts/docker-cargo-run.sh --rm \ + -v "$(Build.SourcesDirectory):/workspace" \ + -v "$(ob_outputDirectory)/wheels:/workspace/target/wheels" \ + -e "WORKSPACE_DIR=/workspace" \ + -e "OUTPUT_DIR=/workspace/target/wheels" \ + -e "MATURIN_FEATURES=vendored-openssl" \ + "$CONTAINER_IMAGE" \ + bash /workspace/scripts/build-mock-python-wheel-in-container.sh + sudo chown -R $(whoami):$(whoami) "$(Build.SourcesDirectory)/target" || true + WHEEL_COUNT=$(find "$(ob_outputDirectory)/wheels" -type f -name '*.whl' | wc -l) + if [ "$WHEEL_COUNT" -eq 0 ]; then + echo "ERROR: No mock wheels were produced!" + exit 1 + fi + echo "$WHEEL_COUNT wheel(s) built." + ls -lh "$(ob_outputDirectory)/wheels" + displayName: 'Build mock abi3 wheel (musllinux_1_2 aarch64)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - task: PublishPipelineArtifact@1 + displayName: 'Publish musllinux ARM64 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildLinuxMusl_arm64_mock_linux_musl_arm64' + publishLocation: 'pipeline' + + ##################################################################### + # Windows x64 — native maturin build + ##################################################################### + - job: BuildWindows_x64 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (Windows x64)' + pool: + type: windows + isCustom: true + name: RUST-1ES-POOL-WUS3 + demands: + - imageOverride -equals RUST-Win22-Sql25-1P + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_windows_x64' + CARGO_TARGET_DIR: C:\cargo_target_dir + steps: + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Windows + - template: /.pipeline/templates/install-dependencies.yml@self + parameters: + osType: Windows + skipPythonSetup: true + skipRustupInstall: false + enableJsDeps: false + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + architecture: 'x64' + addToPath: true + - pwsh: | + $ErrorActionPreference = 'Stop' + $py = Get-Content 'mssql-mock-tds-py/pyproject.toml' -Raw + if ($py -match '(?m)^version\s*=\s*"([^"]+)"') { $base = $Matches[1] } + else { Write-Error 'Could not read version from pyproject.toml'; exit 1 } + if ('${{ parameters.releaseVersion }}' -eq 'True') { + $ver = $base # release: publish base version as-is (e.g. 1.0.0) + } else { + $dev = "$(Get-Date -Format 'yyyyMMdd')$(Build.BuildId)" + $ver = "$base-dev$dev" # PEP 440 normalizes -dev -> .dev for the wheel + } + Write-Host "Sandbox wheel version: $ver" + (Get-Content 'mssql-mock-tds-py/pyproject.toml' -Raw) ` + -replace '(?m)^(version\s*=\s*)"[^"]+"', "`$1`"$ver`"" | + Set-Content 'mssql-mock-tds-py/pyproject.toml' -NoNewline + (Get-Content 'mssql-mock-tds-py/Cargo.toml' -Raw) ` + -replace '(?m)^(version\s*=\s*)"[^"]+"', "`$1`"$ver`"" | + Set-Content 'mssql-mock-tds-py/Cargo.toml' -NoNewline + Write-Host "##vso[task.setvariable variable=mockWheelVersion]$ver" + displayName: 'Stamp sandbox wheel version' + - pwsh: | + $ErrorActionPreference = 'Stop' + python --version + pip install maturin + New-Item -ItemType Directory -Force -Path "$(ob_outputDirectory)\wheels" | Out-Null + # abi3 build: one wheel, no per-version loop. --manifest-path because the + # Cargo package is still mssql-mock-tds-py while the dist is mssql-mock-tds. + maturin build --release --auditwheel skip ` + --interpreter python ` + --manifest-path mssql-mock-tds-py\Cargo.toml ` + --out "$(ob_outputDirectory)\wheels" + Get-ChildItem "$(ob_outputDirectory)\wheels" | Format-Table Name, Length + displayName: 'Build mock abi3 wheel (Windows x64)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - task: PublishPipelineArtifact@1 + displayName: 'Publish Windows x64 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildWindows_x64_mock_windows_x64' + publishLocation: 'pipeline' + + ##################################################################### + # Windows ARM64 — native maturin build + ##################################################################### + - job: BuildWindows_arm64 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (Windows ARM64)' + pool: + type: windows + isCustom: true + name: RUST-1ES-POOL-ARM-WUS3 + demands: + - imageOverride -equals RUST-WINSRV-ARM + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_windows_arm64' + CARGO_TARGET_DIR: C:\cargo_target_dir + steps: + # Node.js workaround for ARM64 ADO tasks (UsePythonVersion etc. need Node). + - pwsh: ./scripts/setup-nodejs-path.ps1 + displayName: 'Add Node.js to PATH for ADO tasks' + - pwsh: ./scripts/install-nodejs-to-agent.ps1 + displayName: 'Install Node.js to agent externals folder' + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Windows + - template: /.pipeline/templates/install-dependencies.yml@self + parameters: + osType: Windows + skipPythonSetup: true + skipRustupInstall: false + enableJsDeps: false + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + architecture: 'arm64' + addToPath: true + - pwsh: | + $ErrorActionPreference = 'Stop' + $py = Get-Content 'mssql-mock-tds-py/pyproject.toml' -Raw + if ($py -match '(?m)^version\s*=\s*"([^"]+)"') { $base = $Matches[1] } + else { Write-Error 'Could not read version from pyproject.toml'; exit 1 } + if ('${{ parameters.releaseVersion }}' -eq 'True') { + $ver = $base # release: publish base version as-is (e.g. 1.0.0) + } else { + $dev = "$(Get-Date -Format 'yyyyMMdd')$(Build.BuildId)" + $ver = "$base-dev$dev" # PEP 440 normalizes -dev -> .dev for the wheel + } + Write-Host "Sandbox wheel version: $ver" + (Get-Content 'mssql-mock-tds-py/pyproject.toml' -Raw) ` + -replace '(?m)^(version\s*=\s*)"[^"]+"', "`$1`"$ver`"" | + Set-Content 'mssql-mock-tds-py/pyproject.toml' -NoNewline + (Get-Content 'mssql-mock-tds-py/Cargo.toml' -Raw) ` + -replace '(?m)^(version\s*=\s*)"[^"]+"', "`$1`"$ver`"" | + Set-Content 'mssql-mock-tds-py/Cargo.toml' -NoNewline + Write-Host "##vso[task.setvariable variable=mockWheelVersion]$ver" + displayName: 'Stamp sandbox wheel version' + - pwsh: | + $ErrorActionPreference = 'Stop' + python --version + pip install maturin + New-Item -ItemType Directory -Force -Path "$(ob_outputDirectory)\wheels" | Out-Null + maturin build --release --auditwheel skip ` + --interpreter python ` + --manifest-path mssql-mock-tds-py\Cargo.toml ` + --out "$(ob_outputDirectory)\wheels" + Get-ChildItem "$(ob_outputDirectory)\wheels" | Format-Table Name, Length + displayName: 'Build mock abi3 wheel (Windows ARM64)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - task: PublishPipelineArtifact@1 + displayName: 'Publish Windows ARM64 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildWindows_arm64_mock_windows_arm64' + publishLocation: 'pipeline' + + ##################################################################### + # macOS universal2 (x86_64 + ARM64) — native maturin build + # Governed templates don't support a macOS pool type natively, so we use + # type: linux + isCustom with the Azure Pipelines macOS image (same trick + # as stages.yml's MacOS_universal2 job). + # + # OpenSSL: mssql-mock-tds pulls the openssl crate on cfg(not(windows)) to + # build the PKCS#12 identity. To keep the wheel portable we build with + # --features vendored-openssl so OpenSSL is statically linked instead of + # dynamically linking Homebrew's libssl. + ##################################################################### + - job: BuildMacOS_universal2 + dependsOn: PreflightVersionCheck + displayName: 'Build mock wheel (macOS universal2)' + pool: + type: linux + isCustom: true + name: 'Azure Pipelines' + vmImage: macOS-14 + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + ob_artifactSuffix: '_mock_macos_universal2' + CARGO_TARGET_DIR: $(Build.SourcesDirectory) + steps: + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: MacOS + - template: /.pipeline/templates/install-dependencies.yml@self + parameters: + osType: MacOS + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + addToPath: true + - script: | + set -euo pipefail + # Target the UsePythonVersion-selected interpreter explicitly. It is a + # managed, writable, on-PATH Python, so there is no PEP 668 + # externally-managed-environment problem (no --break-system-packages) and + # the maturin/wheel console scripts land on PATH. abi3 needs only one + # interpreter regardless of the wheel's >=3.9 compatibility. + python -m pip install --upgrade pip + python -m pip install maturin wheel + echo "Installing Rust targets for universal2 build..." + rustup target add x86_64-apple-darwin + rustup target add aarch64-apple-darwin + displayName: 'Install maturin and Rust targets' + - script: | + set -euo pipefail + BASE=$(grep -m1 -E '^version\s*=' mssql-mock-tds-py/pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/') + case "${RELEASE_VERSION}" in + True|true|TRUE) VER="${BASE}" ;; # release: publish BASE as-is (e.g. 1.0.0) + *) VER="${BASE}-dev$(date -u +%Y%m%d)${BUILD_BUILDID}" ;; # PEP 440 normalizes -dev -> .dev + esac + echo "Sandbox wheel version: ${VER}" + # BSD sed (macOS) rejects the GNU empty-regex reuse `s//.../`, so use an + # explicit pattern. Each manifest has exactly one line-starting + # `version =` (the package version), so a plain substitution is safe. + sed -i '' -E "s/^version[[:space:]]*=.*/version = \"${VER}\"/" mssql-mock-tds-py/pyproject.toml + sed -i '' -E "s/^version[[:space:]]*=.*/version = \"${VER}\"/" mssql-mock-tds-py/Cargo.toml + echo "##vso[task.setvariable variable=mockWheelVersion]${VER}" + displayName: 'Stamp sandbox wheel version' + env: + BUILD_BUILDID: $(Build.BuildId) + RELEASE_VERSION: ${{ parameters.releaseVersion }} + - script: | + set -euo pipefail + export MACOSX_DEPLOYMENT_TARGET=15.0 + mkdir -p "$(ob_outputDirectory)/wheels" + # ONE universal2 abi3 wheel. vendored-openssl statically links OpenSSL + # so the wheel doesn't depend on Homebrew's libssl. --manifest-path + # because the Cargo package is still mssql-mock-tds-py. + maturin build --release \ + --target universal2-apple-darwin \ + --auditwheel skip \ + --features vendored-openssl \ + --manifest-path mssql-mock-tds-py/Cargo.toml \ + --out "$(ob_outputDirectory)/wheels" + ls -lh "$(ob_outputDirectory)/wheels" + displayName: 'Build mock universal2 wheel (macOS)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - script: | + set -euo pipefail + # wheel was installed against the selected interpreter above; -m avoids + # any reliance on the console script's location on PATH. + for whl in "$(ob_outputDirectory)"/wheels/*.whl; do + echo "Re-tagging: $(basename "$whl")" + python -m wheel tags --platform-tag macosx_15_0_universal2 --remove "$whl" + done + echo "Re-tagged wheels:" + ls -lh "$(ob_outputDirectory)/wheels" + displayName: 'Re-tag macOS wheel to uniform universal2 platform tag' + - task: PublishPipelineArtifact@1 + displayName: 'Publish macOS universal2 mock wheel' + inputs: + targetPath: '$(ob_outputDirectory)/wheels' + artifact: 'drop_PublishMockPython_BuildMacOS_universal2_mock_macos_universal2' + publishLocation: 'pipeline' + + ##################################################################### + # Upload — collect wheels and (opt-in) twine upload to the feed. + ##################################################################### + - job: UploadPython + displayName: 'Upload mock wheels to feed' + dependsOn: + - BuildLinux_x64 + - BuildLinux_arm64 + - BuildLinuxMusl_x64 + - BuildLinuxMusl_arm64 + - BuildWindows_x64 + - BuildWindows_arm64 + - BuildMacOS_universal2 + # Never upload from PR runs (defence in depth; this pipeline has pr: none). + condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) + pool: + type: windows + isCustom: true + name: RUST-1ES-POOL-WUS3 + demands: + - imageOverride -equals RUST-Win22-Sql25-1P + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + - download: current + displayName: 'Download mock wheel artifacts' + - task: UsePythonVersion@0 + displayName: 'Use Python 3.12' + inputs: + versionSpec: '3.12' + architecture: 'x64' + addToPath: true + - pwsh: | + $ErrorActionPreference = 'Stop' + python -m pip install --upgrade twine + $dist = "$(Build.StagingDirectory)\dist" + New-Item -ItemType Directory -Force -Path $dist | Out-Null + Get-ChildItem "$(Pipeline.Workspace)" -Recurse -Filter *.whl | + Copy-Item -Destination $dist + Write-Host "=== Collected wheels ===" + Get-ChildItem $dist -Filter *.whl | ForEach-Object { + Write-Host " $($_.Name) ($([math]::Round($_.Length/1KB,1)) KB)" + } + Write-Host "##vso[task.setvariable variable=distDir]$dist" + displayName: 'Collect wheels' + - ${{ if eq(parameters.publishPython, true) }}: + - task: TwineAuthenticate@1 + displayName: 'Authenticate twine to feed' + inputs: + artifactFeed: '$(pythonArtifactFeed)' + - pwsh: | + $ErrorActionPreference = 'Stop' + Write-Host "Uploading SANDBOX wheels to $(pythonFeedName) ..." + # Azure Artifacts' PyPI endpoint rejects --skip-existing + # (UnsupportedConfiguration). Each run stamps a unique + # .dev version, so there is no collision to skip. + twine upload -r $(pythonFeedName) --config-file "$(PYPIRC_PATH)" ` + "$(distDir)\*.whl" + displayName: 'twine upload (sandbox)' + - ${{ if ne(parameters.publishPython, true) }}: + - pwsh: | + Write-Host "================ DRY RUN ================" + Write-Host "publishPython=false -> NOT uploading." + Write-Host "Re-run with publishPython=true to push these" + Write-Host "SANDBOX wheels to the $(pythonFeedName) feed." + Write-Host "========================================" + displayName: 'DRY RUN — skip twine upload' + + ########################################################################### + # Stage 2: PublishMockCrate + # cargo publish the mssql-mock-tds crate to the feed's Cargo index. + # + # mssql-mock-tds path-depends on mssql-tds. cargo publish rewrites that path + # dep to a registry version dependency and verifies by BUILDING the crate, so + # mssql-tds@ must already exist in the feed. We therefore publish in + # dependency order: mssql-tds FIRST, then mssql-mock-tds. + # + # Runs on Windows so the verify build uses SChannel and never needs OpenSSL + # (mssql-mock-tds only pulls openssl on cfg(not(windows))). + # + # publishCrate=false (default) => cargo publish --dry-run only. No feed writes. + ########################################################################### + - stage: PublishMockCrate + displayName: 'Publish Mock Crate (sandbox)' + jobs: + - job: PublishCrate + displayName: 'cargo publish mssql-mock-tds (sandbox)' + pool: + type: windows + isCustom: true + name: RUST-1ES-POOL-WUS3 + demands: + - imageOverride -equals RUST-Win22-Sql25-1P + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + CARGO_TARGET_DIR: C:\cargo_target_dir + steps: + - ${{ if and(eq(parameters.releaseVersion, true), eq(parameters.publishCrate, true)) }}: + - pwsh: | + $ErrorActionPreference = 'Stop' + $baseVer = ([regex]'(?m)^version\s*=\s*"([^"]+)"').Match((Get-Content 'mssql-tds/Cargo.toml' -Raw)).Groups[1].Value + if ([string]::IsNullOrWhiteSpace($baseVer)) { Write-Error 'Could not read base version from mssql-tds/Cargo.toml'; exit 1 } + & .pipeline/scripts/check-crate-version-not-published.ps1 ` + -IndexBaseUrl '$(cargoSparseIndex)' -Version $baseVer ` + -Crates mssql-tds,mssql-mock-tds + displayName: 'Preflight: release crate versions not already published' + - template: /.pipeline/templates/cargo-authenticate-template.yml@self + parameters: + osType: Windows + - template: /.pipeline/templates/install-dependencies.yml@self + parameters: + osType: Windows + skipPythonSetup: true + skipRustupInstall: false + enableJsDeps: false + # Stamp a PEP 440 / SemVer prerelease into ONLY the [package].version of + # mssql-tds and mssql-mock-tds. We do this inline instead of the shared + # .pipeline/scripts/stamp-crate-versions.ps1 because that script's global + # `^version = "..."` regex also rewrites table-style dependency versions + # such as [dependencies.uuid] version = "1.19.0", which corrupts the + # dependency requirement and makes `cargo publish` fail. Replacing only + # the first ^version line per file targets the package version. + - pwsh: | + $ErrorActionPreference = 'Stop' + $date = Get-Date -Format 'yyyyMMdd' + $rx = [regex]'(?m)^(version\s*=\s*)"[^"]+"' + $baseVer = ([regex]'(?m)^version\s*=\s*"([^"]+)"').Match((Get-Content 'mssql-tds/Cargo.toml' -Raw)).Groups[1].Value + if ([string]::IsNullOrWhiteSpace($baseVer)) { Write-Error 'Could not read base version from mssql-tds/Cargo.toml'; exit 1 } + if ('${{ parameters.releaseVersion }}' -eq 'True') { + $ver = $baseVer # release: publish base version as-is (e.g. 1.0.0) + } else { + $ver = "$baseVer-dev.$date.$(Build.BuildId)" + } + Write-Host "Sandbox crate version: $ver" + foreach ($f in 'mssql-tds/Cargo.toml','mssql-mock-tds/Cargo.toml') { + $c = Get-Content $f -Raw + $c = $rx.Replace($c, ('${1}"' + $ver + '"'), 1) + Set-Content $f $c -NoNewline + Write-Host "Stamped $f -> version = `"$ver`"" + } + Write-Host "##vso[task.setvariable variable=crateVersion]$ver" + displayName: 'Stamp crate versions (sandbox, package-only)' + # The mssql-tds path dependency in mssql-mock-tds/Cargo.toml has no version, + # which cargo publish rejects ("all dependencies must have a version"). Add + # the stamped version while keeping the path (cargo uses the version on + # publish and the path for local verify builds). + - pwsh: | + $ErrorActionPreference = 'Stop' + $ver = "$(crateVersion)" + if ([string]::IsNullOrWhiteSpace($ver)) { Write-Error 'crateVersion not set'; exit 1 } + $path = 'mssql-mock-tds/Cargo.toml' + $c = Get-Content $path -Raw + $c = $c -replace 'mssql-tds\s*=\s*\{\s*path\s*=\s*"\.\./mssql-tds"', + "mssql-tds = { path = `"../mssql-tds`", version = `"$ver`"" + Set-Content $path $c -NoNewline + Write-Host "Pinned mssql-tds dependency to version $ver" + Select-String -Path $path -Pattern 'mssql-tds\s*=' | ForEach-Object { Write-Host $_.Line } + displayName: 'Pin mssql-tds dependency version' + - ${{ if eq(parameters.publishCrate, true) }}: + - pwsh: | + $ErrorActionPreference = 'Stop' + Write-Host "Publishing SANDBOX crates to $(cargoRegistry) (mssql-tds first)..." + cargo publish -p mssql-tds --registry $(cargoRegistry) --allow-dirty + # mssql-tds must be queryable in the feed index before mssql-mock-tds + # is verified/published. Sparse index propagation is usually quick, but + # allow a short settle window for the sandbox. + Start-Sleep -Seconds 30 + cargo publish -p mssql-mock-tds --registry $(cargoRegistry) --allow-dirty + displayName: 'cargo publish (sandbox)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) + - ${{ if ne(parameters.publishCrate, true) }}: + - pwsh: | + $ErrorActionPreference = 'Stop' + Write-Host "================ DRY RUN ================" + Write-Host "publishCrate=false -> cargo publish --dry-run only." + Write-Host "========================================" + cargo publish -p mssql-tds --registry $(cargoRegistry) --dry-run --allow-dirty + # A dry run of mssql-mock-tds would need mssql-tds@$(crateVersion) to + # already exist in the feed (cargo verifies by building against the + # registry version), so it is intentionally NOT run here. Set + # publishCrate=true to publish mssql-tds then mssql-mock-tds in order. + Write-Host "Skipping mssql-mock-tds dry run (depends on a published mssql-tds)." + displayName: 'cargo publish --dry-run (sandbox)' + env: + CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_PUBLIC_TOKEN) + CARGO_REGISTRIES_MSSQL_RS_TOKEN: $(CARGO_REGISTRIES_MSSQL_RS_TOKEN) diff --git a/.pipeline/scripts/check-crate-version-not-published.ps1 b/.pipeline/scripts/check-crate-version-not-published.ps1 new file mode 100644 index 00000000..1353f83e --- /dev/null +++ b/.pipeline/scripts/check-crate-version-not-published.ps1 @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Preflight guard for the sandbox crate release flow. + +.DESCRIPTION + Fails (exit 1) when a crate version about to be published is already present + in the Azure Artifacts Cargo registry, so the pipeline fails in seconds + instead of building and only hitting cargo's duplicate-version rejection at + `cargo publish`. + + The mssql-rs_Public feed allows anonymous reads, so the PEP-equivalent Cargo + sparse index is queried without credentials. Each crate's index file is + newline-delimited JSON, one object per published version. + + Best-effort: if the registry cannot be reached (auth/network), the script + WARNs and exits 0. cargo's duplicate-version rejection at publish time remains + the authoritative guard, so a transient lookup failure never blocks a release. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$IndexBaseUrl, + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string[]]$Crates +) + +$ErrorActionPreference = 'Stop' + +function Get-CargoIndexPath([string]$name) { + $n = $name.ToLower() + switch ($n.Length) { + 1 { return "1/$n" } + 2 { return "2/$n" } + 3 { return "3/$($n[0])/$n" } + default { return "$($n.Substring(0,2))/$($n.Substring(2,2))/$n" } + } +} + +$base = $IndexBaseUrl.TrimEnd('/') +$conflict = $false + +foreach ($crate in $Crates) { + $url = "$base/$(Get-CargoIndexPath $crate)" + Write-Host "Release preflight: checking registry for $crate@$Version" + Write-Host " GET $url" + + $content = $null + try { + $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + $content = $resp.Content + } + catch { + $code = $null + if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode } + if ($code -eq 404) { + Write-Host " OK: $crate has no published versions yet." + continue + } + Write-Host "##vso[task.logissue type=warning]Registry lookup for $crate failed (HTTP $code); skipping preflight for this crate." + continue + } + + $versions = @() + foreach ($line in ($content -split "`n")) { + $line = $line.Trim() + if (-not $line) { continue } + try { $versions += ($line | ConvertFrom-Json).vers } catch { } + } + + if ($versions -contains $Version) { + Write-Host "##vso[task.logissue type=error]$crate@$Version is already published to the registry." + $conflict = $true + } + else { + Write-Host " OK: $crate@$Version is not on the registry." + } +} + +if ($conflict) { + Write-Error "One or more crate versions are already published. Bump the [package].version in the crate Cargo.toml before running a release. Azure Artifacts rejects re-publishing an existing version." + exit 1 +} + +Write-Host "All crate versions are clear to publish." diff --git a/.pipeline/scripts/check-version-not-published.py b/.pipeline/scripts/check-version-not-published.py new file mode 100644 index 00000000..2725fdfa --- /dev/null +++ b/.pipeline/scripts/check-version-not-published.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Preflight guard for the sandbox release flow. + +Reads the base ``version`` from a pyproject.toml and fails (exit 1) when that +exact version is already published to the Azure Artifacts PyPI feed. This lets +the pipeline fail in seconds instead of building seven wheels and only hitting +the duplicate-version rejection at ``twine upload``. + +The ``mssql-rs_Public`` feed allows anonymous reads, so the simple index URL is +passed in directly (no credentials). For convenience an embedded-credential URL +(``https://user:token@.../pypi/simple/``) is still accepted, as is a fallback to +the ``PIP_INDEX_URL`` environment variable. + +Best-effort: if the feed cannot be reached or the index URL is missing, we WARN +and exit 0. The duplicate-version rejection at upload time remains the +authoritative guard, so we never block a release on a transient lookup failure. +""" + +import base64 +import re +import sys +import urllib.error +import urllib.request +from html.parser import HTMLParser +from urllib.parse import urlsplit, urlunsplit + + +def read_base_version(pyproject_path: str) -> str: + with open(pyproject_path, encoding="utf-8") as f: + text = f.read() + m = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text) + if not m: + sys.exit(f"ERROR: could not read version from {pyproject_path}") + return m.group(1).strip() + + +def normalize_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def normalize_version(version: str) -> str: + # Light PEP 440 normalization: lowercase, drop a leading 'v', collapse the + # SemVer '-dev' / '-rc' style separators maturin emits to PEP 440 form so a + # filename version compares equal to the manifest version. + v = version.strip().lower() + if v.startswith("v"): + v = v[1:] + v = v.replace("-dev", ".dev").replace("-rc", "rc").replace("-alpha", "a").replace("-beta", "b") + return v + + +class _AnchorParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.filenames: list[str] = [] + + def handle_data(self, data: str) -> None: + data = data.strip() + if data.endswith((".whl", ".tar.gz", ".zip")): + self.filenames.append(data) + + +def version_from_filename(filename: str) -> str | None: + if filename.endswith(".whl"): + parts = filename[:-4].split("-") + return parts[1] if len(parts) >= 2 else None + for ext in (".tar.gz", ".zip"): + if filename.endswith(ext): + stem = filename[: -len(ext)] + bits = stem.rsplit("-", 1) + return bits[1] if len(bits) == 2 else None + return None + + +def fetch_simple_page(index_url: str, package: str) -> str | None: + parts = urlsplit(index_url) + auth_header = None + netloc = parts.netloc + if "@" in netloc: + userinfo, host = netloc.rsplit("@", 1) + netloc = host + token = base64.b64encode(userinfo.encode("utf-8")).decode("ascii") + auth_header = f"Basic {token}" + + base = urlunsplit((parts.scheme, netloc, parts.path, "", "")) + if not base.endswith("/"): + base += "/" + url = f"{base}{normalize_name(package)}/" + + req = urllib.request.Request(url) + if auth_header: + req.add_header("Authorization", auth_header) + req.add_header("Accept", "text/html") + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as e: + if e.code == 404: + return "" # package not present at all -> no published versions + print(f"WARNING: feed lookup failed (HTTP {e.code}); skipping preflight.") + return None + except (urllib.error.URLError, TimeoutError) as e: + print(f"WARNING: feed unreachable ({e}); skipping preflight.") + return None + + +def main(argv: list[str]) -> int: + if len(argv) not in (3, 4): + sys.exit( + "usage: check-version-not-published.py [simple-index-url]" + ) + pyproject_path, package = argv[1], argv[2] + + base = read_base_version(pyproject_path) + target = normalize_version(base) + print(f"Release preflight: checking feed for {package}=={base} (normalized {target})") + + import os + + index_url = (argv[3] if len(argv) == 4 else os.environ.get("PIP_INDEX_URL", "")).strip() + if not index_url: + print("WARNING: no simple index URL provided; skipping preflight (upload still guards).") + return 0 + + page = fetch_simple_page(index_url, package) + if page is None: + return 0 # best-effort: could not determine + if page == "": + print(f"OK: {package} has no published versions yet.") + return 0 + + parser = _AnchorParser() + parser.feed(page) + published = set() + for fn in parser.filenames: + v = version_from_filename(fn) + if v: + published.add(normalize_version(v)) + + if target in published: + print( + f"ERROR: {package}=={base} is already published to the feed.\n" + f" Bump 'version' in {pyproject_path} before running a release.\n" + f" Azure Artifacts rejects re-uploading an existing version." + ) + return 1 + + print(f"OK: {package}=={base} is not on the feed; safe to release.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/mssql-mock-tds-py/Cargo.toml b/mssql-mock-tds-py/Cargo.toml index f8e262fc..4cb7750f 100644 --- a/mssql-mock-tds-py/Cargo.toml +++ b/mssql-mock-tds-py/Cargo.toml @@ -9,6 +9,13 @@ publish = false name = "mssql_mock_tds_py" crate-type = ["cdylib"] +[features] +default = [] +# Statically link OpenSSL into the extension. Used for portable macOS wheels so +# the build doesn't dynamically link Homebrew's libssl. Passes through to the +# mssql-mock-tds crate's own vendored-openssl feature (openssl/vendored). +vendored-openssl = ["mssql-mock-tds/vendored-openssl"] + [dependencies] pyo3 = { version = "0.29.0", features = ["extension-module", "abi3-py39"] } mssql-mock-tds = { path = "../mssql-mock-tds" } diff --git a/mssql-mock-tds-py/pyproject.toml b/mssql-mock-tds-py/pyproject.toml index 47728355..ec8957fd 100644 --- a/mssql-mock-tds-py/pyproject.toml +++ b/mssql-mock-tds-py/pyproject.toml @@ -3,7 +3,7 @@ requires = ["maturin>=1.4,<2.0"] build-backend = "maturin" [project] -name = "mssql-mock-tds-py" +name = "mssql-mock-tds" version = "0.1.0" description = "Python bindings for the mock TDS server for testing SQL Server clients" requires-python = ">=3.9" @@ -18,4 +18,5 @@ classifiers = [ ] [tool.maturin] +module-name = "mssql_mock_tds" features = ["pyo3/extension-module"] diff --git a/mssql-mock-tds-py/src/lib.rs b/mssql-mock-tds-py/src/lib.rs index b29e02d8..44f2918d 100644 --- a/mssql-mock-tds-py/src/lib.rs +++ b/mssql-mock-tds-py/src/lib.rs @@ -323,7 +323,8 @@ impl PyMockTdsServer { /// Python module for mock TDS server bindings #[pymodule] -fn mssql_mock_tds_py(m: &Bound<'_, PyModule>) -> PyResult<()> { +#[pyo3(name = "mssql_mock_tds")] +fn mssql_mock_tds_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; Ok(()) diff --git a/mssql-mock-tds/src/server.rs b/mssql-mock-tds/src/server.rs index 6ba695c7..bfe15db5 100644 --- a/mssql-mock-tds/src/server.rs +++ b/mssql-mock-tds/src/server.rs @@ -13,9 +13,10 @@ use crate::protocol::{ use crate::query_response::QueryRegistry; use bytes::BytesMut; use native_tls::Identity; -use std::collections::HashMap; +use std::collections::BTreeMap; use std::net::SocketAddr; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Mutex; @@ -51,6 +52,8 @@ impl RedirectionConfig { /// Each connection gets its own processor instance. /// FedAuth and username/password authentication are always supported. pub struct ConnectionProcessor { + /// Unique per-connection id assigned at accept time + conn_id: u64, /// Client socket address addr: SocketAddr, /// Whether the client has authenticated @@ -69,12 +72,20 @@ pub struct ConnectionProcessor { buffer: BytesMut, /// Optional redirection configuration redirection: Option, + /// Shared store used to record connection state as soon as it is known + connection_store: Option>>, } impl ConnectionProcessor { /// Create a new connection processor - pub fn new(addr: SocketAddr, query_registry: Arc>) -> Self { + pub fn new( + conn_id: u64, + addr: SocketAddr, + query_registry: Arc>, + connection_store: Option>>, + ) -> Self { Self { + conn_id, addr, is_authenticated: false, received_token: None, @@ -84,16 +95,20 @@ impl ConnectionProcessor { query_registry, buffer: BytesMut::with_capacity(4096), redirection: None, + connection_store, } } /// Create a new connection processor with redirection configuration pub fn new_with_redirection( + conn_id: u64, addr: SocketAddr, query_registry: Arc>, + connection_store: Option>>, redirection: Option, ) -> Self { Self { + conn_id, addr, is_authenticated: false, received_token: None, @@ -103,9 +118,15 @@ impl ConnectionProcessor { query_registry, buffer: BytesMut::with_capacity(4096), redirection, + connection_store, } } + /// Get the unique connection id + pub fn conn_id(&self) -> u64 { + self.conn_id + } + /// Get the client address pub fn addr(&self) -> SocketAddr { self.addr @@ -148,6 +169,15 @@ impl ConnectionProcessor { &mut self.buffer } + /// Upsert this connection's current state into the shared store. + /// Called eagerly during login so tokens are visible to callers the + /// moment the client's blocking LoginAck read returns. + async fn record_to_store(&self) { + if let Some(store) = &self.connection_store { + store.lock().await.store(self); + } + } + /// Process a single packet from the buffer and return the response pub async fn process_packet(&mut self) -> Result, ProtocolError> { if self.buffer.len() < PACKET_HEADER_SIZE { @@ -269,6 +299,7 @@ impl ConnectionProcessor { resp_header.write(&mut packet); packet.extend_from_slice(&response); + self.record_to_store().await; Some(packet) } } @@ -305,6 +336,7 @@ impl ConnectionProcessor { resp_header.write(&mut packet); packet.extend_from_slice(&response); + self.record_to_store().await; Some(packet) } Err(e) => { @@ -418,8 +450,9 @@ impl ConnectionProcessor { /// This allows tests to access per-connection state after connections complete. #[derive(Debug, Default)] pub struct ConnectionStore { - /// Completed connection processors keyed by client socket address - connections: HashMap, + /// Connection info keyed by unique connection id, ordered so that + /// iteration and `.values().last()` yield the most recent connection. + connections: BTreeMap, } /// Captured information from a completed connection @@ -461,11 +494,13 @@ impl ConnectionInfo { impl ConnectionStore { pub fn new() -> Self { Self { - connections: HashMap::new(), + connections: BTreeMap::new(), } } - /// Store connection info when a connection completes + /// Upsert connection info keyed by the connection's unique id. + /// Called both eagerly during login and again at connection teardown; + /// both updates target the same entry. pub fn store(&mut self, processor: &ConnectionProcessor) { let info = ConnectionInfo { addr: processor.addr(), @@ -474,16 +509,16 @@ impl ConnectionStore { user_agent: processor.user_agent.clone(), received_server_name: processor.received_server_name().map(|s| s.to_string()), }; - self.connections.insert(processor.addr(), info); + self.connections.insert(processor.conn_id(), info); } - /// Get connection info by address - pub fn get(&self, addr: &SocketAddr) -> Option<&ConnectionInfo> { - self.connections.get(addr) + /// Get connection info by connection id + pub fn get(&self, conn_id: u64) -> Option<&ConnectionInfo> { + self.connections.get(&conn_id) } /// Get all connection infos - pub fn all(&self) -> &HashMap { + pub fn all(&self) -> &BTreeMap { &self.connections } @@ -513,6 +548,8 @@ pub struct MockTdsServer { connection_store: Arc>, /// Optional redirection configuration for testing client redirection behavior redirection: Option, + /// Monotonic counter assigning a unique id to each accepted connection + connection_counter: Arc, } impl MockTdsServer { @@ -626,6 +663,7 @@ impl MockTdsServer { strict_mode, connection_store: Arc::new(Mutex::new(ConnectionStore::new())), redirection, + connection_counter: Arc::new(AtomicU64::new(0)), }) } @@ -653,11 +691,13 @@ impl MockTdsServer { let strict_mode = self.strict_mode; let connection_store = self.connection_store; let redirection = self.redirection.map(Arc::new); + let connection_counter = self.connection_counter; loop { let (socket, addr) = listener.accept().await?; info!("New connection from {}", addr); + let conn_id = connection_counter.fetch_add(1, Ordering::SeqCst); let registry_clone = Arc::clone(®istry); let tls_acceptor_clone = tls_acceptor.clone(); let store_clone = Arc::clone(&connection_store); @@ -668,6 +708,7 @@ impl MockTdsServer { if let Err(e) = handle_connection_with_tls( socket, addr, + conn_id, registry_clone, tls_acceptor_clone, strict_mode, @@ -693,6 +734,7 @@ impl MockTdsServer { let strict_mode = self.strict_mode; let connection_store = self.connection_store; let redirection = self.redirection.map(Arc::new); + let connection_counter = self.connection_counter; tokio::select! { result = async { @@ -703,13 +745,14 @@ impl MockTdsServer { info!("New connection from {}", addr); drop(listener); // Release lock before spawning + let conn_id = connection_counter.fetch_add(1, Ordering::SeqCst); let registry_clone = Arc::clone(®istry); let tls_acceptor_clone = tls_acceptor.clone(); let store_clone = Arc::clone(&connection_store); let redirection_clone = redirection.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection_with_tls(socket, addr, registry_clone, tls_acceptor_clone, strict_mode, store_clone, redirection_clone).await { + if let Err(e) = handle_connection_with_tls(socket, addr, conn_id, registry_clone, tls_acceptor_clone, strict_mode, store_clone, redirection_clone).await { error!("Error handling connection from {}: {}", addr, e); } }); @@ -731,9 +774,11 @@ impl MockTdsServer { /// Handle a connection with optional TLS support. /// FedAuth and username/password authentication are always supported. +#[allow(clippy::too_many_arguments)] async fn handle_connection_with_tls( socket: TcpStream, addr: SocketAddr, + conn_id: u64, query_registry: Arc>, tls_acceptor: Option>, strict_mode: bool, @@ -764,6 +809,7 @@ async fn handle_connection_with_tls( handle_strict_encrypted_connection( tls_stream, addr, + conn_id, query_registry, connection_store, redirection, @@ -801,6 +847,7 @@ async fn handle_connection_with_tls( handle_encrypted_tds_wrapped_connection( tls_stream, addr, + conn_id, query_registry, connection_store, redirection, @@ -811,6 +858,7 @@ async fn handle_connection_with_tls( handle_unencrypted_connection( prelogin_socket, addr, + conn_id, query_registry, connection_store, redirection, @@ -876,6 +924,7 @@ async fn handle_prelogin_negotiation( async fn handle_strict_encrypted_connection( mut socket: TlsStream, addr: SocketAddr, + conn_id: u64, query_registry: Arc>, connection_store: Arc>, redirection: Option>, @@ -883,8 +932,13 @@ async fn handle_strict_encrypted_connection( let redir_config = redirection .as_ref() .map(|r| RedirectionConfig::new(r.redirect_host.clone(), r.redirect_port)); - let mut processor = - ConnectionProcessor::new_with_redirection(addr, query_registry, redir_config); + let mut processor = ConnectionProcessor::new_with_redirection( + conn_id, + addr, + query_registry, + Some(Arc::clone(&connection_store)), + redir_config, + ); let mut prelogin_handled = false; loop { @@ -952,10 +1006,16 @@ async fn handle_strict_encrypted_connection( async fn handle_encrypted_connection( mut socket: TlsStream, addr: SocketAddr, + conn_id: u64, query_registry: Arc>, connection_store: Arc>, ) -> Result<(), ProtocolError> { - let mut processor = ConnectionProcessor::new(addr, query_registry); + let mut processor = ConnectionProcessor::new( + conn_id, + addr, + query_registry, + Some(Arc::clone(&connection_store)), + ); loop { // Read data from TLS socket @@ -989,6 +1049,7 @@ async fn handle_encrypted_connection( async fn handle_encrypted_tds_wrapped_connection( mut socket: TlsStream, addr: SocketAddr, + conn_id: u64, query_registry: Arc>, connection_store: Arc>, redirection: Option>, @@ -996,8 +1057,13 @@ async fn handle_encrypted_tds_wrapped_connection( let redir_config = redirection .as_ref() .map(|r| RedirectionConfig::new(r.redirect_host.clone(), r.redirect_port)); - let mut processor = - ConnectionProcessor::new_with_redirection(addr, query_registry, redir_config); + let mut processor = ConnectionProcessor::new_with_redirection( + conn_id, + addr, + query_registry, + Some(Arc::clone(&connection_store)), + redir_config, + ); loop { // Read data from TLS socket (which wraps TdsTlsWrapper) @@ -1032,6 +1098,7 @@ async fn handle_encrypted_tds_wrapped_connection( async fn handle_unencrypted_connection( mut socket: TcpStream, addr: SocketAddr, + conn_id: u64, query_registry: Arc>, connection_store: Arc>, redirection: Option>, @@ -1039,8 +1106,13 @@ async fn handle_unencrypted_connection( let redir_config = redirection .as_ref() .map(|r| RedirectionConfig::new(r.redirect_host.clone(), r.redirect_port)); - let mut processor = - ConnectionProcessor::new_with_redirection(addr, query_registry, redir_config); + let mut processor = ConnectionProcessor::new_with_redirection( + conn_id, + addr, + query_registry, + Some(Arc::clone(&connection_store)), + redir_config, + ); loop { // Read data from plain socket diff --git a/mssql-py-core/tests/rs-only-tests/test_mock_server_fedauth.py b/mssql-py-core/tests/rs-only-tests/test_mock_server_fedauth.py index 21743b0b..86bcca32 100644 --- a/mssql-py-core/tests/rs-only-tests/test_mock_server_fedauth.py +++ b/mssql-py-core/tests/rs-only-tests/test_mock_server_fedauth.py @@ -20,7 +20,7 @@ # Try to import the mock TDS server Python bindings try: - import mssql_mock_tds_py + import mssql_mock_tds MOCK_TDS_PY_AVAILABLE = True except ImportError: MOCK_TDS_PY_AVAILABLE = False @@ -52,7 +52,7 @@ def mock_server_port(): @pytest.mark.skipif( not MOCK_TDS_PY_AVAILABLE, - reason="mssql_mock_tds_py not available. Build it with: cd mssql-mock-tds-py && maturin develop", + reason="mssql_mock_tds not available. Build it with: cd mssql-mock-tds-py && maturin develop", ) class TestMockServerFedAuth: """Test FedAuth (access token) authentication with mock TDS server.""" @@ -75,7 +75,7 @@ def test_connect_with_access_token(self, mock_server_port): # Create and start the mock server using Python bindings # Use tls=True to enable TLS encryption for secure token transmission - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port, tls=True) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port, tls=True) with server: # Build client context for the mock server with access token auth @@ -98,11 +98,7 @@ def test_connect_with_access_token(self, mock_server_port): # Clean up conn.close() assert not conn.is_connected() - - # Give the server a moment to process the connection info - import time - time.sleep(0.1) - + # Verify the server received the correct token assert server.connection_count() >= 1, "Server should have recorded at least one connection" assert server.has_received_token(mock_token), \ @@ -121,12 +117,11 @@ def test_connect_with_unique_access_token(self, mock_server_port): properly sent through the TDS protocol and received by the server. """ import mssql_py_core - import time # Generate a unique token for this test unique_token = f"unique_token_{secrets.token_hex(16)}" - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port, tls=True) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port, tls=True) with server: client_context = { @@ -141,10 +136,7 @@ def test_connect_with_unique_access_token(self, mock_server_port): assert conn is not None assert conn.is_connected() conn.close() - - # Wait for connection info to be stored - time.sleep(0.1) - + # Verify the unique token was received received_token = server.get_last_access_token() assert received_token == unique_token, \ @@ -156,7 +148,7 @@ def test_connect_with_unique_access_token(self, mock_server_port): def test_mock_server_starts_successfully(self, mock_server_port): """Test that the mock TDS server starts and listens on the expected port.""" - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port) with server: # Verify we can connect at the TCP level @@ -174,11 +166,10 @@ def test_execute_query_with_access_token(self, mock_server_port): Verifies both the query result and that the token was received. """ import mssql_py_core - import time mock_token = "mock_token_for_query_execution" - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port, tls=True) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port, tls=True) with server: client_context = { @@ -209,10 +200,7 @@ def test_execute_query_with_access_token(self, mock_server_port): # Ensure references are dropped so TdsClient is fully released del cursor del conn - - # Wait for connection info to be stored - time.sleep(0.3) - + # Verify token was received assert server.has_received_token(mock_token), \ "Server should have received the access token used for query execution" @@ -220,11 +208,10 @@ def test_execute_query_with_access_token(self, mock_server_port): def test_get_all_connections(self, mock_server_port): """Test retrieving all connection info from the server.""" import mssql_py_core - import time token = "test_token_for_connection_list" - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port, tls=True) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port, tls=True) with server: client_context = { @@ -237,9 +224,7 @@ def test_get_all_connections(self, mock_server_port): conn = mssql_py_core.PyCoreConnection(client_context) conn.close() - - time.sleep(0.1) - + # Get all connections connections = server.get_connections() assert len(connections) >= 1, "Should have at least one connection" @@ -252,9 +237,8 @@ def test_get_all_connections(self, mock_server_port): def test_clear_connections(self, mock_server_port): """Test clearing stored connection info.""" import mssql_py_core - import time - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port, tls=True) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port, tls=True) with server: client_context = { @@ -267,9 +251,7 @@ def test_clear_connections(self, mock_server_port): conn = mssql_py_core.PyCoreConnection(client_context) conn.close() - - time.sleep(0.1) - + # Verify we have a connection assert server.connection_count() >= 1 @@ -282,9 +264,8 @@ def test_clear_connections(self, mock_server_port): def test_user_agent_format(self, mock_server_port): """Test that MS-PYTHON is correctly sent as the driver name in the user agent.""" import mssql_py_core - import time - server = mssql_mock_tds_py.PyMockTdsServer(port=mock_server_port, tls=True) + server = mssql_mock_tds.PyMockTdsServer(port=mock_server_port, tls=True) with server: client_context = { @@ -297,8 +278,6 @@ def test_user_agent_format(self, mock_server_port): conn = mssql_py_core.PyCoreConnection(client_context) conn.close() - time.sleep(0.1) - connections = server.get_connections() assert len(connections) >= 1, "Should have at least one connection" diff --git a/mssql-tds/tests/test_mock_server_fedauth.rs b/mssql-tds/tests/test_mock_server_fedauth.rs index 108e05fa..7d439723 100644 --- a/mssql-tds/tests/test_mock_server_fedauth.rs +++ b/mssql-tds/tests/test_mock_server_fedauth.rs @@ -469,4 +469,63 @@ mod mock_server_fedauth_tests { Ok(()) } + + /// Regression guard for eager token recording: the token must be visible in the + /// shared store WHILE the client connection is still open, i.e. without relying on + /// connection teardown. This mirrors the downstream ODBC pooled-connection scenario + /// where close() returns the socket to a pool without sending EOF, so a teardown-only + /// record would never fire. + #[tokio::test] + async fn test_token_recorded_before_connection_close() -> Result<(), Box> + { + init_tracing(); + + let server = MockTdsServer::new("127.0.0.1:0").await?; + let server_addr = server.local_addr(); + let connection_store = server.connection_store(); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let server_handle = + tokio::spawn(async move { server.run_with_shutdown(shutdown_rx).await }); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let access_token = "eager_record_guard_token_67890".to_string(); + + let datasource = format!("tcp:{},{}", server_addr.ip(), server_addr.port()); + let mut context = ClientContext::default(); + context.access_token = Some(access_token.clone()); + context.tds_authentication_method = TdsAuthenticationMethod::AccessToken; + context.database = "master".to_string(); + context.encryption_options = EncryptionOptions { + mode: EncryptionSetting::PreferOff, + trust_server_certificate: true, + host_name_in_cert: None, + server_certificate: None, + }; + + let provider = TdsConnectionProvider {}; + let client = provider.create_client(context, &datasource, None).await?; + + // Assert the token is recorded WITHOUT closing/dropping the connection first. + let recorded = { + let store = connection_store.lock().await; + store + .all() + .values() + .any(|c| c.received_token_as_string().as_deref() == Some(&access_token)) + }; + assert!( + recorded, + "FedAuth token must be recorded before the client connection is closed (eager-record guard)" + ); + + // Keep the connection alive until after the assertion. + drop(client); + + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(tokio::time::Duration::from_secs(2), server_handle).await; + + Ok(()) + } } diff --git a/scripts/build-mock-python-wheel-in-container.sh b/scripts/build-mock-python-wheel-in-container.sh new file mode 100644 index 00000000..36143cd6 --- /dev/null +++ b/scripts/build-mock-python-wheel-in-container.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -e + +# SANDBOX / TEST-ONLY build script. +# Builds a single abi3 (Python >= 3.9) wheel for the mssql-mock-tds-py crate +# inside a manylinux container. Unlike scripts/build-python-wheels-in-container.sh +# (mssql-py-core), there is NO per-Python-version loop: pyo3's abi3-py39 feature +# produces one forward-compatible wheel per (OS, arch). +# +# NOTE: the produced distribution is named "mssql-mock-tds" (pyproject [project].name +# + maturin module-name = mssql_mock_tds), but the Cargo package / folder is still +# mssql-mock-tds-py, so we always pass --manifest-path. + +WORKSPACE_DIR="${WORKSPACE_DIR:-/workspace}" +OUTPUT_DIR="${OUTPUT_DIR:-$WORKSPACE_DIR/target/wheels}" + +echo "==> Building mock TDS abi3 wheel in container" +echo "Workspace: $WORKSPACE_DIR" +echo "Output directory: $OUTPUT_DIR" + +mkdir -p "$OUTPUT_DIR" + +# Find a Python binary to drive maturin (abi3 only needs one interpreter). +FIRST_PYTHON="" +for py_path in /opt/python/cp312-cp312/bin/python /opt/python/cp3*/bin/python /usr/local/bin/python3 /usr/bin/python3; do + if [ -x "$py_path" ]; then + FIRST_PYTHON="$py_path" + break + fi +done + +if [ -z "$FIRST_PYTHON" ]; then + echo "Error: No Python installation found in container!" + exit 1 +fi + +echo "Using Python: $FIRST_PYTHON" +$FIRST_PYTHON --version + +# Rust + maturin are pre-installed in the *_rust container images. +export PATH="$HOME/.cargo/bin:$PATH" +if ! command -v cargo &> /dev/null; then + echo "Error: Rust not found! Ensure you're using a *_rust container image." + exit 1 +fi +rustc --version +cargo --version + +if ! $FIRST_PYTHON -m pip show maturin &> /dev/null; then + echo "Error: maturin not found! Ensure you're using a *_rust container image." + exit 1 +fi + +# Optional cargo features (e.g. vendored-openssl for musllinux, where Alpine's +# dynamic libssl is not a portable runtime dependency). manylinux builds leave +# MATURIN_FEATURES unset and link the OS libssl.so.3 at runtime instead. +FEATURE_ARGS=() +if [ -n "${MATURIN_FEATURES:-}" ]; then + echo "Building with cargo features: $MATURIN_FEATURES" + FEATURE_ARGS=(--features "$MATURIN_FEATURES") +fi + +# --auditwheel skip mirrors the mssql-py-core OpenSSL convention: do NOT let +# maturin repair the wheel. On manylinux the native extension links against the +# OS libssl.so.3 / libcrypto.so.3 at runtime (manylinux_2_34 / glibc 2.34); on +# musllinux we instead statically vendor OpenSSL via MATURIN_FEATURES so the +# wheel is self-contained. Passed on the CLI so we do not edit pyproject.toml. +$FIRST_PYTHON -m maturin build --release \ + --auditwheel skip \ + --interpreter "$FIRST_PYTHON" \ + "${FEATURE_ARGS[@]}" \ + --manifest-path "$WORKSPACE_DIR/mssql-mock-tds-py/Cargo.toml" \ + --out "$OUTPUT_DIR" + +echo "" +echo "==> Wheel(s) built:" +ls -lh "$OUTPUT_DIR"