From ae9e4a642f652f4608048a7e8b70e29012f32072 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Thu, 3 Sep 2026 13:10:59 +0530 Subject: [PATCH] FEAT: standalone conda BUILD pipeline + validation tooling Second slice of the conda onboarding split (#720), after the recipe (#734, merged). Adds the OneBranch conda-build pipeline that runs conda-build against the recipe already on main and produces a consolidated conda/ artifact tree, plus the pure, no-DB validation tooling the pipeline gates on. Pipeline (OneBranchPipelines/): - conda-build-pipeline.yml + the per-platform build/validate step templates (conda-build-validate-step{,-posix}.yml) and the consolidate-artifacts job (win-arm64 py3.12-3.14 included in the accounting). - build-conda-packages.{ps1,sh}: drive conda-build per leg; idempotent builder env (set -e safe). The TLS-probe connection string is sourced from a SECRET variable (variable group / Key Vault) mapped into the step env, never a plaintext queue-time parameter that ADO would leave unmasked in logs. Validation tooling (conda/, eng/scripts/) + pure no-DB tests (tests/test_028-033): - tls_connect_probe.py / driver_load_probe.py: import-time TLS-handshake and driver-load probes. The connection-string splitter honors MS-ODBCSTR }} brace escaping so a braced password is never mis-split at an internal ';'; neutral failure labels for triage. - assert_pe_machine.py: Windows PE machine assert -- verifies the native binding (ddbc_bindings*.pyd) AND the vendored ODBC driver DLLs are both present and match the package arch (win-arm64 skips the runtime import, so this is its presence gate). - audit_bundled_binaries.py: Linux RUNPATH self-containment audit + ELF e_machine arch gate (linux-64==x86_64, linux-aarch64==aarch64). Scope: BUILD pipeline only. The release/publish steps, release-metadata validator, and re-solve drift gate move to a follow-up PR. The osx-arm64 slice is trusted from the universal2 wheel tag (no Mach-O audit is claimed); the enforced arch guards are the PE and ELF checks. The GitHub conda-audit workflow and the product-code changes are separate slices. All probe/audit unit tests pass; black clean. Validated on a NonOfficial ADO build run. --- OneBranchPipelines/conda-build-pipeline.yml | 350 +++++++++++++ .../jobs/consolidate-conda-artifacts-job.yml | 129 +++++ OneBranchPipelines/scripts/.gitattributes | 1 + .../scripts/build-conda-packages.ps1 | 360 +++++++++++++ .../scripts/build-conda-packages.sh | 434 ++++++++++++++++ .../steps/conda-build-validate-step-posix.yml | 146 ++++++ .../steps/conda-build-validate-step.yml | 145 ++++++ conda/driver_load_probe.py | 116 +++++ conda/tls_connect_probe.py | 275 ++++++++++ eng/scripts/_conda_pkg.py | 88 ++++ eng/scripts/assert_pe_machine.py | 187 +++++++ eng/scripts/audit_bundled_binaries.py | 475 ++++++++++++++++++ tests/test_028_tls_connect_probe.py | 260 ++++++++++ tests/test_029_bundled_binary_audit.py | 437 ++++++++++++++++ tests/test_030_pe_machine_assert.py | 222 ++++++++ tests/test_031_tls_probe_required.py | 108 ++++ tests/test_033_driver_load_probe.py | 144 ++++++ 17 files changed, 3877 insertions(+) create mode 100644 OneBranchPipelines/conda-build-pipeline.yml create mode 100644 OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml create mode 100644 OneBranchPipelines/scripts/.gitattributes create mode 100644 OneBranchPipelines/scripts/build-conda-packages.ps1 create mode 100644 OneBranchPipelines/scripts/build-conda-packages.sh create mode 100644 OneBranchPipelines/steps/conda-build-validate-step-posix.yml create mode 100644 OneBranchPipelines/steps/conda-build-validate-step.yml create mode 100644 conda/driver_load_probe.py create mode 100644 conda/tls_connect_probe.py create mode 100644 eng/scripts/_conda_pkg.py create mode 100644 eng/scripts/assert_pe_machine.py create mode 100644 eng/scripts/audit_bundled_binaries.py create mode 100644 tests/test_028_tls_connect_probe.py create mode 100644 tests/test_029_bundled_binary_audit.py create mode 100644 tests/test_030_pe_machine_assert.py create mode 100644 tests/test_031_tls_probe_required.py create mode 100644 tests/test_033_driver_load_probe.py diff --git a/OneBranchPipelines/conda-build-pipeline.yml b/OneBranchPipelines/conda-build-pipeline.yml new file mode 100644 index 000000000..d1403d08c --- /dev/null +++ b/OneBranchPipelines/conda-build-pipeline.yml @@ -0,0 +1,350 @@ +# ========================================================================================= +# OneBranch STANDALONE Conda Build Pipeline (mssql-python) +# ========================================================================================= +# Builds the SELF-CONTAINED mssql-python conda package (which vendors the ODBC Driver 18 +# payload -- there is NO separate companion package) for every conda subdir, WITHOUT +# rebuilding the wheels. It consumes the wheels already produced by the wheel build +# pipeline (definition 2199): the mssql-python wheels (drop_Consolidate_ConsolidateArtifacts) +# and the mssql-python-odbc wheels (drop_ConsolidateOdbc_ConsolidateArtifacts), then +# repackages + validates them into conda packages on the matching platform agent. +# +# WHY PER-OS (not one job for all OSs): unlike the odbc wheels (a pure data re-tag), +# conda-build provisions a REAL per-subdir host env and `pip install`s the matching wheel +# (see conda/mssql-python/build.sh|bld.bat). A win-64 / osx-* / linux-* host env cannot be +# created on a foreign OS, so each conda package must be built on its matching platform -- +# exactly like the wheels and the conda-forge feedstocks. The subdirs that CAN be +# cross-targeted on one agent are done via CONDA_SUBDIR (osx-64 under Rosetta 2 on the +# Intel mac agent; linux-aarch64 under QEMU on the x86_64 linux agent). +# +# This pipeline ONLY builds + validates + consolidates the conda packages as an artifact +# (drop_ConsolidateConda_ConsolidateArtifacts). Publishing is the companion +# conda-release-pipeline.yml. Validated locally that the recipe builds + imports; the +# per-OS legs + SDL settings need an actual ADO run to confirm. +# ========================================================================================= +name: $(Year:YY)$(DayOfYear)$(Rev:.r)-CondaBuild + +trigger: none +pr: none + +parameters: + - name: oneBranchType + displayName: 'OneBranch Template Type' + type: string + values: + - 'Official' + - 'NonOfficial' + default: 'NonOfficial' + # Python versions to build conda packages for (Windows loops these; the POSIX legs + # auto-detect the same set from the downloaded wheels). + - name: pythonVersions + displayName: 'Python versions (comma-separated)' + type: string + default: '3.10,3.11,3.12,3.13,3.14' + # H1: enable the minimal-base ldd reachability gate (CONDA_ASSERT_PREFIX_REACHABLE). + # It fails CLOSED if the driver binds a system (or absent) krb5/gssapi/libltdl, so it + # is only valid on a leg with NO system copies of those libs -- set true ONLY when + # the Linux agent/container is a curated minimal base, else it will (correctly) fail + # on a full agent's system libs. + - name: enableMinimalReachabilityGate + displayName: 'Enable minimal-base ldd reachability gate (needs a minimal Linux base)' + type: boolean + default: false + # Make the live Encrypt=yes TLS gate MANDATORY on the Linux leg (CONDA_TLS_PROBE_REQUIRED=1): + # a missing/malformed CONDA_TLS_PROBE_CONN then FAILS the leg instead of skipping. Enable + # ONLY together with a real `condaTlsProbeConn` secret on a minimal base with no system + # OpenSSL -- otherwise the gate is inconclusive (a system libssl masks the bug) or fails for + # lack of a connection string. Default off so the plumbing exists without blocking a build + # before the secret/infra is wired. + - name: enableMandatoryTlsGate + displayName: 'Make the Encrypt=yes TLS gate mandatory (needs condaTlsProbeConn + minimal base)' + type: boolean + default: false + +variables: + - name: effectiveOneBranchType + ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + value: 'Official' + ${{ else }}: + value: '${{ parameters.oneBranchType }}' + - template: /OneBranchPipelines/variables/common-variables.yml@self + - template: /OneBranchPipelines/variables/onebranch-variables.yml@self + +resources: + repositories: + - repository: templates + type: git + name: 'OneBranch.Pipelines/GovernedTemplates' + ref: 'refs/heads/main' + # The wheel build pipeline whose consolidated wheel artifacts this pipeline repackages. + pipelines: + - pipeline: buildPipeline + source: 'Build-Release-Package-Pipeline' + trigger: none + +extends: + template: 'v2/OneBranch.${{ variables.effectiveOneBranchType }}.CrossPlat.yml@templates' + parameters: + featureFlags: + WindowsHostVersion: + Version: '2022' + # Minimal SDL: this pipeline compiles NOTHING (it repackages prebuilt, already-scanned + # wheels), so the heavy binary analyzers add no coverage. Keep the credential / inclusive + # -language / baseline guardrails and publish logs. + globalSdl: + baseline: + baselineFile: $(Build.SourcesDirectory)/.gdn/.gdnbaselines + suppressionSet: default + suppression: + suppressionFile: $(Build.SourcesDirectory)/.gdn/.gdnsuppress + suppressionSet: default + credscan: + enabled: true + policheck: + enabled: true + break: true + exclusionFile: '$(REPO_ROOT)/.config/PolicheckExclusions.xml' + publishLogs: + enabled: true + + stages: + # ========================= + # CONDA win-64 (native, per-Python) + # ========================= + - stage: CondaWin64 + displayName: 'Conda win-64' + jobs: + - job: BuildConda + displayName: 'Build + validate win-64 conda' + timeoutInMinutes: 120 + pool: + type: windows + isCustom: true + name: Python-1ES-pool + demands: + - imageOverride -equals PYTHON-1ES-MMS2022 + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + displayName: 'Download mssql-python wheels' + inputs: + buildType: 'specific' + project: '$(System.TeamProject)' + definition: 2199 + buildVersionToDownload: 'specific' + buildId: $(resources.pipeline.buildPipeline.runID) + artifactName: 'drop_Consolidate_ConsolidateArtifacts' + targetPath: '$(Build.SourcesDirectory)' + - task: DownloadPipelineArtifact@2 + displayName: 'Download mssql-python-odbc wheels' + inputs: + buildType: 'specific' + project: '$(System.TeamProject)' + definition: 2199 + buildVersionToDownload: 'specific' + buildId: $(resources.pipeline.buildPipeline.runID) + artifactName: 'drop_ConsolidateOdbc_ConsolidateArtifacts' + targetPath: '$(Pipeline.Workspace)/odbc_wheels' + # win-64 conda build (all Python versions in one pass via the ps1 loop). + - template: /OneBranchPipelines/steps/conda-build-validate-step.yml@self + parameters: + pythonVersion: '${{ parameters.pythonVersions }}' + condaSubdir: 'win-64' + odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' + odbcWheelFilter: 'mssql_python_odbc-*win_amd64.whl' + # win-arm64 conda build: CROSS-built on THIS x64 agent via CONDA_SUBDIR, in the + # SAME job as win-64. continueOnError=true so a win-arm64 hiccup (e.g. an Anaconda + # `defaults` outage that breaks its solve) can NEVER nuke the native win-64 publish + # that shares this job -- BLAST-RADIUS isolation. Enforcement does NOT weaken: the + # leg still runs a BLOCKING `conda create --dry-run` solve + a PE machine-type assert + # internally (so a broken/wrong-arch package is never staged), AND win-arm64 is in + # the release gate's requiredSubdirs -- so a MISSING win-arm64 blocks the RELEASE. + # Net: a win-arm64 failure isolates to win-arm64 (win-64 still ships) yet still + # blocks the publish via the gate. Scoped to 3.12-3.14: neither defaults nor + # conda-forge has a win-arm64 python for 3.10/3.11. Its own bld tree + subdir-scoped + # verify env keep it from colliding with win-64 on this shared agent. + - template: /OneBranchPipelines/steps/conda-build-validate-step.yml@self + parameters: + pythonVersion: '3.12,3.13,3.14' + condaSubdir: 'win-arm64' + condaTargetSubdir: 'win-arm64' + odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' + odbcWheelFilter: 'mssql_python_odbc-*win_arm64.whl' + outputDir: '$(Agent.TempDirectory)/conda-bld-winarm64' + continueOnError: true + - task: PublishPipelineArtifact@1 + displayName: 'Publish win-64 conda artifact' + inputs: + targetPath: '$(ob_outputDirectory)' + artifact: 'drop_CondaWin64_BuildConda' + publishLocation: 'pipeline' + + # ========================= + # CONDA osx-arm64 + osx-64 (Intel mac agent: arm64 cross, x86_64 native) + # ========================= + - stage: CondaMacOS + displayName: 'Conda macOS (osx-arm64 + osx-64)' + jobs: + - job: BuildConda + displayName: 'Build + validate macOS conda' + timeoutInMinutes: 120 + # macOS pools declare as type:linux (Azure Pipelines quirk). + pool: + type: linux + isCustom: true + name: Azure Pipelines + vmImage: 'macos-latest' + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + displayName: 'Download mssql-python wheels' + inputs: + buildType: 'specific' + project: '$(System.TeamProject)' + definition: 2199 + buildVersionToDownload: 'specific' + buildId: $(resources.pipeline.buildPipeline.runID) + artifactName: 'drop_Consolidate_ConsolidateArtifacts' + targetPath: '$(Build.SourcesDirectory)' + - task: DownloadPipelineArtifact@2 + displayName: 'Download mssql-python-odbc wheels' + inputs: + buildType: 'specific' + project: '$(System.TeamProject)' + definition: 2199 + buildVersionToDownload: 'specific' + buildId: $(resources.pipeline.buildPipeline.runID) + artifactName: 'drop_ConsolidateOdbc_ConsolidateArtifacts' + targetPath: '$(Pipeline.Workspace)/odbc_wheels' + # osx-arm64: CROSS-built on the Intel agent (BEST-EFFORT -- the runtime import + # auto-skips). NOTE: osx-arm64 arch is NOT independently verified here (no + # Mach-O arch check); it is trusted from the universal2 wheel tag, like PyPI. + - template: /OneBranchPipelines/steps/conda-build-validate-step-posix.yml@self + parameters: + condaSubdir: 'osx-arm64' + condaTargetSubdir: 'osx-arm64' + continueOnError: true + odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' + odbcWheelFilter: 'mssql_python_odbc-*macosx*universal2.whl' + pythonVersions: '${{ parameters.pythonVersions }}' + # osx-64: NATIVE on the Intel agent (BLOCKING -- real import + driver-load proof). + - template: /OneBranchPipelines/steps/conda-build-validate-step-posix.yml@self + parameters: + condaSubdir: 'osx-64' + condaTargetSubdir: 'osx-64' + odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' + odbcWheelFilter: 'mssql_python_odbc-*macosx*universal2.whl' + pythonVersions: '${{ parameters.pythonVersions }}' + - task: PublishPipelineArtifact@1 + displayName: 'Publish macOS conda artifact' + inputs: + targetPath: '$(ob_outputDirectory)' + artifact: 'drop_CondaMacOS_BuildConda' + publishLocation: 'pipeline' + + # ========================= + # CONDA linux-64 + linux-aarch64 (x86_64 agent: native + QEMU cross) + # ========================= + - stage: CondaLinux + displayName: 'Conda Linux (linux-64 + linux-aarch64)' + jobs: + - job: BuildConda + displayName: 'Build + validate Linux conda' + timeoutInMinutes: 120 + pool: + type: linux + isCustom: true + name: Azure Pipelines + vmImage: 'ubuntu-latest' + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + # H1: activate the runtime gates in build-conda-packages.sh. The TLS probe's + # Encrypt=yes connection string carries a password, so it is sourced from a + # SECRET variable -- NOT a queue-time parameter, which ADO leaves UNMASKED in + # the run UI/logs. Define `condaTlsProbeConn` as a secret in a variable group / + # Key Vault linked to this pipeline; unset = the probe SKIPs loudly. The ldd + # reachability gate and the MANDATORY-TLS gate (CONDA_TLS_PROBE_REQUIRED) run + # (fail-closed) only when explicitly enabled on a minimal base. + CONDA_TLS_PROBE_CONN: $(condaTlsProbeConn) + ${{ if parameters.enableMinimalReachabilityGate }}: + CONDA_ASSERT_PREFIX_REACHABLE: '1' + ${{ if parameters.enableMandatoryTlsGate }}: + CONDA_TLS_PROBE_REQUIRED: '1' + steps: + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + displayName: 'Download mssql-python wheels' + inputs: + buildType: 'specific' + project: '$(System.TeamProject)' + definition: 2199 + buildVersionToDownload: 'specific' + buildId: $(resources.pipeline.buildPipeline.runID) + artifactName: 'drop_Consolidate_ConsolidateArtifacts' + targetPath: '$(Build.SourcesDirectory)' + - task: DownloadPipelineArtifact@2 + displayName: 'Download mssql-python-odbc wheels' + inputs: + buildType: 'specific' + project: '$(System.TeamProject)' + definition: 2199 + buildVersionToDownload: 'specific' + buildId: $(resources.pipeline.buildPipeline.runID) + artifactName: 'drop_ConsolidateOdbc_ConsolidateArtifacts' + targetPath: '$(Pipeline.Workspace)/odbc_wheels' + # linux-64: NATIVE glibc x86_64 host. + - template: /OneBranchPipelines/steps/conda-build-validate-step-posix.yml@self + parameters: + condaSubdir: 'linux-64' + odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' + odbcWheelFilter: 'mssql_python_odbc-*manylinux_2_28_x86_64.whl' + pythonVersions: '${{ parameters.pythonVersions }}' + # linux-aarch64: CROSS-target via QEMU binfmt. MANDATORY (blocking): a + # leg that cannot run its own aarch64 import is a real failure, not a + # silent pass -- otherwise a broken aarch64 conda package would ship + # unvalidated. Install the aarch64 glibc loader/libs (libc6-arm64-cross) + # so the emulated aarch64 Python can find /lib/ld-linux-aarch64.so.1, and + # register QEMU binfmt for host execution; both must succeed. + - bash: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y qemu-user-static binfmt-support libc6-arm64-cross + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + displayName: 'Install libc6-arm64-cross + register QEMU binfmt (aarch64)' + - template: /OneBranchPipelines/steps/conda-build-validate-step-posix.yml@self + parameters: + condaSubdir: 'linux-aarch64' + condaTargetSubdir: 'linux-aarch64' + odbcWheelDir: '$(Pipeline.Workspace)/odbc_wheels' + odbcWheelFilter: 'mssql_python_odbc-*manylinux_2_28_aarch64.whl' + pythonVersions: '${{ parameters.pythonVersions }}' + - task: PublishPipelineArtifact@1 + displayName: 'Publish Linux conda artifact' + inputs: + targetPath: '$(ob_outputDirectory)' + artifact: 'drop_CondaLinux_BuildConda' + publishLocation: 'pipeline' + + # ========================= + # CONSOLIDATE all conda packages into one artifact + # ========================= + - stage: ConsolidateConda + displayName: 'Consolidate All Conda Packages' + dependsOn: + - CondaWin64 + - CondaMacOS + - CondaLinux + jobs: + - template: /OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml@self + parameters: + # This standalone pipeline's conda legs publish drop_Conda* artifacts. + downloadItemPattern: | + drop_CondaWin64_*/** + drop_CondaMacOS_*/** + drop_CondaLinux_*/** diff --git a/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml b/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml new file mode 100644 index 000000000..989156cee --- /dev/null +++ b/OneBranchPipelines/jobs/consolidate-conda-artifacts-job.yml @@ -0,0 +1,129 @@ +# Consolidate Conda Artifacts Job Template +# Collects the per-platform self-contained mssql-python conda packages (which vendor +# the ODBC payload) that each build leg staged under conda// and gathers +# them into a single conda/ tree for the release pipeline to publish. +# +# BEST-EFFORT (build pipeline): conda is a downstream repackage of the ESRP-signed +# wheels and must NEVER block the primary wheel deliverable, so a missing/short set +# only WARNS here. The HARD count gate lives in the release pipeline (which refuses +# to publish an incomplete conda set), symmetric with how the wheel/odbc drops are +# best-effort collected in the build and gated at release time. +# +# Expected packages (validated conda subdirs). The self-contained mssql-python +# package (which vendors the ODBC payload) is emitted per-Python by each build leg; +# there is NO separate companion package: +# win-64 : 5 py x mssql-python = 5 +# win-arm64 : 3 py x mssql-python (py3.12-3.14, cross-built on x64) = 3 +# osx-64 : 5 py x mssql-python (Intel Mac, cross-built via Rosetta) = 5 +# osx-arm64 : 5 py x mssql-python (Apple Silicon, native) = 5 +# linux-64 : 5 py x mssql-python (glibc x86_64 host, native) = 5 +# linux-aarch64 : 5 py x mssql-python (x86_64 host + QEMU, best-effort) = 5 +# ------------------------------------------------------------------------------ +# TOTAL (PyPI parity minus musllinux) = 28 +# win-arm64 is cross-built for py3.12-3.14 only (py3.10/3.11 have no win-arm64 deps +# on Anaconda defaults); its runtime import is skipped on the x64 host, so its arch +# is enforced by the PE-machine assert (assert_pe_machine.py). musllinux (no conda +# musl subdir) is intentionally NOT conda-built. This job is BEST-EFFORT and never +# hard-fails on a short set; the release pipeline's conda-release-step enforces the +# hard gate (required subdirs present + complete Python matrix) before publish. +parameters: + - name: oneBranchType + type: string + default: 'Official' + # Artifact item pattern the consolidate job downloads. Defaults to the integrated + # wheel-pipeline leg artifacts; the standalone conda-build pipeline overrides it with + # its drop_Conda* leg artifacts. + - name: downloadItemPattern + type: string + default: | + drop_Win_*/** + drop_MacOS_*/** + drop_Linux_*/** + drop_ODBC_BuildAll_*/** + +jobs: + - job: ConsolidateArtifacts + displayName: 'Consolidate All Conda Packages' + condition: succeeded() + + pool: + type: linux + isCustom: true + name: Azure Pipelines + vmImage: 'ubuntu-latest' + + variables: + # Consolidation only moves files; no binaries to scan. + - name: ob_sdl_binskim_enabled + value: false + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)' + + steps: + - checkout: self + fetchDepth: 1 + + # The conda packages are staged INSIDE the mssql-python build-leg artifacts + # (drop_Win_*, drop_MacOS_*, drop_Linux_*) under conda//. Scope the + # download to those stages so every leg's self-contained mssql-python conda is + # gathered in one place. (drop_ODBC_BuildAll_* is included only for its wheels, + # which ride along and are ignored below -- we pick only *.conda / *.tar.bz2.) + - task: DownloadPipelineArtifact@2 + displayName: 'Download All Platform Artifacts' + inputs: + buildType: 'current' + itemPattern: ${{ parameters.downloadItemPattern }} + targetPath: '$(Pipeline.Workspace)/all-artifacts' + + - bash: | + set -e + echo "Collecting conda packages (preserving / layout)..." + mkdir -p $(ob_outputDirectory)/conda + + # Copy every mssql-python* conda package into conda//. Each build + # leg wrote the self-contained mssql-python package under a conda// + # folder, so the parent dir name IS the target subdir. + found=0 + while IFS= read -r p; do + subdir=$(basename "$(dirname "$p")") + mkdir -p "$(ob_outputDirectory)/conda/$subdir" + cp -v "$p" "$(ob_outputDirectory)/conda/$subdir/" + found=1 + done < <(find $(Pipeline.Workspace)/all-artifacts -type f \( -name 'mssql-python*.conda' -o -name 'mssql-python*.tar.bz2' \)) + + echo "" + echo "Consolidated conda tree:" + find $(ob_outputDirectory)/conda -type f | sort + + PKG_COUNT=$(find $(ob_outputDirectory)/conda -type f \( -name '*.conda' -o -name '*.tar.bz2' \) | wc -l) + echo "" + echo "Per-subdir conda package counts:" + for d in $(ob_outputDirectory)/conda/*/; do + [ -d "$d" ] || continue + sub=$(basename "$d") + n=$(find "$d" -type f \( -name '*.conda' -o -name '*.tar.bz2' \) | wc -l) + printf ' %-14s %s\n' "$sub" "$n" + done + echo "Total conda package count: $PKG_COUNT (full PyPI-parity set = 28)" + + # BEST-EFFORT: warn only, never exit non-zero — a conda hiccup on any leg + # must not fail this build or block the wheel release. The release pipeline's + # conda-release-step enforces the hard gate (required subdirs + full Python + # matrix) before anything is published. + if [ "$found" != "1" ]; then + echo "##vso[task.logissue type=warning]No conda packages found in the build-leg artifacts." + else + echo "Collected $PKG_COUNT conda package(s) (best-effort; release-time gate enforces completeness)." + fi + displayName: 'Consolidate conda packages' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Consolidated Conda Artifacts' + inputs: + targetPath: '$(ob_outputDirectory)' + # Distinct name so it does not collide with the wheel consolidate artifact + # (drop_Consolidate_ConsolidateArtifacts) or the odbc one + # (drop_ConsolidateOdbc_ConsolidateArtifacts) in the same run. Matches the + # OneBranch auto-name for a stage named `ConsolidateConda`. + artifact: 'drop_ConsolidateConda_ConsolidateArtifacts' + publishLocation: 'pipeline' diff --git a/OneBranchPipelines/scripts/.gitattributes b/OneBranchPipelines/scripts/.gitattributes new file mode 100644 index 000000000..dfdb8b771 --- /dev/null +++ b/OneBranchPipelines/scripts/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/OneBranchPipelines/scripts/build-conda-packages.ps1 b/OneBranchPipelines/scripts/build-conda-packages.ps1 new file mode 100644 index 000000000..2ad78749f --- /dev/null +++ b/OneBranchPipelines/scripts/build-conda-packages.ps1 @@ -0,0 +1,360 @@ +<# +.SYNOPSIS + Build and validate the self-contained mssql-python conda package (which vendors the + ODBC Driver 18 payload) from prebuilt (ESRP-signed) wheels, fully offline. + +.DESCRIPTION + Repackages the wheels produced by build definition 2199 into conda packages using + conda-build, then proves the recipes are correct by solving a fresh environment + from the freshly built local channel and importing both packages. + + Runs on the OneBranch Windows 1ES pool (or locally). Builds the win_amd64 slice + for every Python version detected among the mssql_python wheels. Other platforms + (linux-*, osx-*, win_arm64) must be built on matching agents in a follow-up, the + same way the wheel build matrix fans out. + +.PARAMETER WheelsDir + Directory containing ALL downloaded wheels (both packages, all platforms/pythons). + +.PARAMETER RecipeRoot + Path to the repo's conda/ directory (contains mssql-python/ and mssql-python-odbc/). + +.PARAMETER OutputDir + Space-free working/output directory (conda croot, Miniforge install, built pkgs). + +.PARAMETER MssqlPythonVersion + Version to stamp on the mssql-python conda package (e.g. 1.13.0). + +.PARAMETER OdbcVersion + Version to stamp on the mssql-python-odbc conda package (e.g. 18.6.2.1). + +.PARAMETER PythonVersions + Optional comma-separated list (e.g. "3.11,3.12"). Empty = auto-detect from wheels. + +.PARAMETER CondaSubdir + Optional target subdir (e.g. win-arm64) to CROSS-target via CONDA_SUBDIR instead of + the host's native subdir. Empty = build the host's native subdir (win-64). Cross- + targeting only yields a VALIDATED package when the host can run the target Python for + the import check, so it is left unset for the native win-64 leg. + +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$WheelsDir, + [Parameter(Mandatory = $true)][string]$RecipeRoot, + [Parameter(Mandatory = $true)][string]$OutputDir, + [Parameter(Mandatory = $true)][string]$MssqlPythonVersion, + [Parameter(Mandatory = $true)][string]$OdbcVersion, + [string]$PythonVersions = "", + [string]$CondaSubdir = "" +) + +$ErrorActionPreference = 'Stop' + +function Assert-LastExit([string]$Message) { + if ($LASTEXITCODE -ne 0) { + Write-Error "FAILED (exit $LASTEXITCODE): $Message" + exit 1 + } +} + +Write-Host "==================== conda build inputs ====================" +Write-Host "WheelsDir : $WheelsDir" +Write-Host "RecipeRoot : $RecipeRoot" +Write-Host "OutputDir : $OutputDir" +Write-Host "MssqlPythonVersion : $MssqlPythonVersion" +Write-Host "OdbcVersion : $OdbcVersion" +Write-Host "PythonVersions : $(if ($PythonVersions) { $PythonVersions } else { '(auto-detect)' })" +Write-Host "CondaSubdir : $(if ($CondaSubdir) { $CondaSubdir } else { '(native)' })" +Write-Host "============================================================" + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +$bld = Join-Path $OutputDir 'bld' +New-Item -ItemType Directory -Force -Path $bld | Out-Null + +# --------------------------------------------------------------------------- +# 1. Locate conda, or install Miniforge3 (conda-forge defaults, no license issues) +# --------------------------------------------------------------------------- +$conda = (Get-Command conda -ErrorAction SilentlyContinue).Source +if (-not $conda) { + Write-Host "=== conda not found on PATH; installing Miniforge3 ===" + $installer = Join-Path $OutputDir 'Miniforge3-Windows-x86_64.exe' + $forgeDir = Join-Path $OutputDir 'miniforge' + # Pin Miniforge to a specific release (never 'latest', which floats) and verify its + # SHA256 BEFORE executing. The expected hash is NOT hard-coded in source: prefer an + # explicit MINIFORGE_SHA256 (a pipeline variable = strongest, out-of-source), else + # verify against the release's OWN published .sha256 sidecar. The installer + # is never executed unverified. + $mfver = if ($env:MINIFORGE_VERSION) { $env:MINIFORGE_VERSION } else { '26.3.2-3' } + $mfName = "Miniforge3-$mfver-Windows-x86_64.exe" + $url = "https://github.com/conda-forge/miniforge/releases/download/$mfver/$mfName" + Write-Host "Downloading pinned Miniforge ${mfver}: $url" + Invoke-WebRequest -Uri $url -OutFile $installer + if ($env:MINIFORGE_SHA256) { + $expected = $env:MINIFORGE_SHA256 + } + else { + $sumFile = "$installer.sha256" + Invoke-WebRequest -Uri "$url.sha256" -OutFile $sumFile + $expected = [regex]::Match((Get-Content -Raw $sumFile), '[0-9a-fA-F]{64}').Value + } + if (-not $expected) { + Write-Error "Could not determine the expected SHA256 for $mfName." + exit 1 + } + $actual = (Get-FileHash -Algorithm SHA256 -Path $installer).Hash + if ($actual -ne $expected) { + Write-Error "Miniforge installer SHA256 mismatch: expected '$expected', got '$actual'." + exit 1 + } + Write-Host "Miniforge installer SHA256 verified." + # NSIS silent install; /D (target dir) MUST be last and unquoted. + Start-Process -FilePath $installer -ArgumentList '/S', '/InstallationType=JustMe', '/AddToPath=0', "/D=$forgeDir" -Wait + $conda = Join-Path $forgeDir 'Scripts\conda.exe' +} +if (-not (Test-Path $conda)) { + Write-Error "conda not available at '$conda' after install attempt." + exit 1 +} +Write-Host "Using conda: $conda" +& $conda --version +Assert-LastExit "conda --version" + +# --------------------------------------------------------------------------- +# 2. Install conda-build (pinned to the stable pre-26 series) +# --------------------------------------------------------------------------- +# Pin conda-build<26: the bleeding-edge 26.7.0 crashes with an internal +# "An unexpected error has occurred" during the LOCAL packaging phase (right +# after "Fixing permissions"); 26.7.1 is not yet released. The mature 25.x +# series builds these recipes cleanly and supports every key we use. +# NOTE: anaconda-client is intentionally NOT installed here — this script only +# builds + validates (it never runs `anaconda upload`). Publishing installs its +# own anaconda-client in conda-publish-step.yml. Keeping it out of the build env +# also drops the anaconda-auth conda plugin, which the crash report fingered. +Write-Host "=== installing conda-build (<26) ===" +& $conda install -y -n base "conda-build<26" +Assert-LastExit "conda install conda-build<26" + +# --------------------------------------------------------------------------- +# 3. Determine which Python versions to build (win_amd64 mssql_python wheels) +# --------------------------------------------------------------------------- +if ([string]::IsNullOrWhiteSpace($PythonVersions)) { + $pyvers = Get-ChildItem -Path $WheelsDir -Filter 'mssql_python-*win_amd64.whl' | + ForEach-Object { if ($_.Name -match 'cp3(\d+)') { "3.$($Matches[1])" } } | + Sort-Object -Unique +} +else { + $pyvers = $PythonVersions.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } +} +if (-not $pyvers) { + Write-Error "No win_amd64 mssql_python wheels found in '$WheelsDir' to determine Python versions." + exit 1 +} +Write-Host "Building conda packages for Python versions: $($pyvers -join ', ')" + +# --------------------------------------------------------------------------- +# 4. Export the environment consumed by the recipes (jinja + build scripts) +# --------------------------------------------------------------------------- +$env:WHEELS_DIR = $WheelsDir +$env:MSSQL_PYTHON_VERSION = $MssqlPythonVersion +$env:MSSQL_ODBC_VERSION = $OdbcVersion + +# CROSS-target a non-native subdir when requested: conda-build and the verify env's +# `conda create` both honor CONDA_SUBDIR, so the packages are stamped for $CondaSubdir +# and the import check runs the target Python (via Rosetta 2 / QEMU on an emulating +# host). Empty = build the host's native subdir. +if ($CondaSubdir) { + $env:CONDA_SUBDIR = $CondaSubdir + Write-Host "Cross-targeting conda subdir: CONDA_SUBDIR=$($env:CONDA_SUBDIR)" + if ($CondaSubdir -eq 'win-arm64') { + # win-arm64 deps (python 3.12-3.14, cryptography, vc14_runtime, pyodbc) live on + # Anaconda `defaults`, NOT conda-forge (which only ships win-arm64 python 3.14 and + # no cryptography). Auto-accept the defaults Terms of Service so the unattended + # conda-build host-env solve and the verify solve never block on a ToS prompt. + $env:CONDA_PLUGINS_AUTO_ACCEPT_TOS = 'yes' + Write-Host "win-arm64: CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes (Anaconda defaults supplies the win-arm64 deps)" + } +} + +# --------------------------------------------------------------------------- +# 5. Build the self-contained mssql-python package (per Python). The recipe vendors +# the ODBC Driver 18 payload by extracting the mssql-python-odbc wheel into its +# own site-packages, so there is NO separate companion package to build. +# --------------------------------------------------------------------------- +$bindRecipe = Join-Path $RecipeRoot 'mssql-python' + +foreach ($py in $pyvers) { + Write-Host "=== [py $py] build mssql-python (self-contained: vendors the ODBC payload) ===" + if ($CondaSubdir -eq 'win-arm64') { + # The win-arm64 host env's python 3.12/3.13 only exists on Anaconda `defaults` + # (conda-forge ships win-arm64 python 3.14 only), so add defaults ahead of + # conda-forge for the host-env solve. + & $conda build $bindRecipe --python $py --no-test --no-anaconda-upload --output-folder $bld -c defaults -c conda-forge + } + else { + & $conda build $bindRecipe --python $py --no-test --no-anaconda-upload --output-folder $bld + } + Assert-LastExit "conda build mssql-python (py $py)" +} + +# --------------------------------------------------------------------------- +# 6. Make the local output folder a VALID conda channel. +# conda-build --output-folder already wrote $bld\\repodata.json for the +# platform we built, but a conda channel is only valid if it ALSO carries +# noarch\repodata.json (even empty) -- otherwise `conda create -c file://$bld` +# fails with "UnavailableInvalidChannel ... must contain noarch/repodata.json". +# Create it directly rather than via `conda index`, whose subcommand is absent +# from miniforge (it moved to the standalone conda-index package). +# --------------------------------------------------------------------------- +$noarchDir = Join-Path $bld 'noarch' +New-Item -ItemType Directory -Force -Path $noarchDir | Out-Null +$noarchRepo = Join-Path $noarchDir 'repodata.json' +if (-not (Test-Path $noarchRepo)) { + '{"info":{"subdir":"noarch"},"packages":{},"packages.conda":{}}' | Set-Content -NoNewline -Encoding ascii $noarchRepo +} + +# --------------------------------------------------------------------------- +# 6b. Masking-immune RUNPATH audit of the freshly built packages (#563). +# --------------------------------------------------------------------------- +# BLOCKING static gate that reads the ELF RUNPATH bytes of the vendored Linux ODBC +# binaries and requires the relative $ORIGIN climb. win-64 packages carry no ELF +# payload so this is a clean no-op here, but it is wired on EVERY leg so a Linux +# package can never reach publish without the #563 self-containment being proven. +$auditScript = Join-Path (Split-Path $RecipeRoot -Parent) 'eng/scripts/audit_bundled_binaries.py' +if (-not (Test-Path $auditScript)) { + Write-Error "RUNPATH audit script not found at $auditScript" + exit 1 +} +Write-Host "=== RUNPATH self-containment audit (eng/scripts/audit_bundled_binaries.py) ===" +& $conda run -n base python -m pip install --quiet --disable-pip-version-check zstandard +& $conda run -n base python $auditScript --root $bld +Assert-LastExit "RUNPATH self-containment audit" + +# --------------------------------------------------------------------------- +# 6c. PE machine-type assert for win-arm64 (the Windows twin of the 6b ELF audit). +# --------------------------------------------------------------------------- +# The arm64 runtime import is SKIPPED on the x64 cross host, so without this the +# package's architecture is trusted purely from the wheel filename. Read the PE COFF +# Machine field of every vendored .pyd/.dll in the freshly built win-arm64 package and +# fail if any is not ARM64 -- so a mislabeled/mis-built wheel can never ship x64 +# binaries inside a win-arm64 package. No-op on every non-win-arm64 leg. +if ($CondaSubdir -eq 'win-arm64') { + $peCheck = Join-Path (Split-Path $RecipeRoot -Parent) 'eng/scripts/assert_pe_machine.py' + if (-not (Test-Path $peCheck)) { + Write-Error "PE machine-type assert script not found at $peCheck" + exit 1 + } + Write-Host "=== win-arm64 PE machine-type assert (vendored .pyd/.dll must be ARM64) ===" + & $conda run -n base python $peCheck --root $bld --subdir win-arm64 + Assert-LastExit "win-arm64 PE machine-type assert" +} + +# --------------------------------------------------------------------------- +# 7. Validate: solve a fresh env from the local channel and import the package. +# Proves azure-identity + the folded-in openssl/krb5 deps resolve AND that the +# repackaged native binding imports with its vendored ODBC payload (driver loads +# at import). +# --------------------------------------------------------------------------- +$localChannel = "file:///" + ($bld -replace '\\', '/') +# Run the verify imports from a NEUTRAL dir: `python -c` prepends the cwd to +# sys.path, and the pipeline runs from the repo checkout whose in-tree +# mssql_python\ (source, no compiled .pyd) would shadow the conda-installed +# package -> "No ddbc_bindings module found". $OutputDir is outside the repo. +Set-Location $OutputDir +# The verify loop drives native `conda` commands (env remove / run) that legitimately +# write to stderr on their NON-FATAL paths: `conda env remove` on an absent env (fresh +# agent / first run), and `conda run` when the cross-built arm64 Python can't execute on +# this x64 host. Under the script-wide $ErrorActionPreference='Stop', PowerShell escalates +# ANY native stderr write to a terminating NativeCommandError, aborting the leg BEFORE the +# exit-code checks below (this is what failed both win-64 and win-arm64 at `conda env +# remove`). Switch to 'Continue' for the verify section and gate control flow on +# $LASTEXITCODE / Assert-LastExit instead -- the same intent as the bash port's `|| true`. +$ErrorActionPreference = 'Continue' +# A win-arm64 package is CROSS-built on the x64 agent. The dependency SOLVE runs on the +# x64 host and does NOT need the arm64 interpreter, so it stays BLOCKING on every leg (an +# unsolvable win-arm64 graph must fail the build, never slip through to publish and then +# break the user's `conda install`). ONLY the runtime import is best-effort here, because +# the arm64 Python cannot execute on x64 -- the same contract as the osx-arm64 cross-build +# in build.sh (a static arch audit stands in). Native legs stay fully blocking end-to-end. +$crossBestEffort = ($CondaSubdir -eq 'win-arm64') +foreach ($py in $pyvers) { + $sub = if ($CondaSubdir) { $CondaSubdir -replace '-', '_' } else { 'native' } + $envName = "verify_${sub}_" + ($py -replace '\.', '') + & $conda env remove -y -n $envName 2>$null + Write-Host "=== [py $py] create verify env from local channel ===" + if ($CondaSubdir -eq 'win-arm64') { + # BLOCKING solvability gate: --dry-run resolves the FULL win-arm64 dependency graph + # on the x64 host (CONDA_SUBDIR=win-arm64 pins the target subdir) WITHOUT linking, + # post-link scripts, or executing the arm64 interpreter -- a pure "is this + # installable?" check that runs anywhere. An unsolvable graph fails the build here + # instead of shipping a package that breaks the user's `conda install`. + # win-arm64 deps: azure-identity/msal are noarch on `microsoft`; cryptography + + # python/vc14_runtime/pyodbc are on Anaconda `defaults`, NOT conda-forge -- so no + # --strict-channel-priority (the graph legitimately splits across microsoft+defaults). + & $conda create --dry-run -n $envName -c $localChannel -c microsoft -c defaults --override-channels "python=$py" mssql-python + Assert-LastExit "win-arm64 --dry-run solve (py $py)" + # Real env is BEST-EFFORT: only a real arm64 host can create+run it. The pipeline + # cross-builds on x64 (arm64 Python cannot execute); there the PE-machine assert + # (step 6c) + the static arm64-slice audit enforce arch/correctness, so a real-create + # failure here is not fatal -- we skip the runtime import. + & $conda create -y -n $envName -c $localChannel -c microsoft -c defaults --override-channels "python=$py" mssql-python 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host "=== [py $py] win-arm64: SOLVES (dry-run OK); real env not creatable on this x64 host -- arch enforced by the PE assert + static audit, skipping runtime import. ===" + continue + } + } + else { + # -c microsoft (ahead of conda-forge) so azure-core/azure-identity/msal resolve from the + # lean `microsoft` channel, NOT conda-forge whose azure-core recipe over-declares flask/six + # -> celery/boto3/botocore (~9 MB); see conda-forge/azure-core-feedstock#71. + # --strict-channel-priority keeps the freshly built local package authoritative. + & $conda create -y -n $envName -c $localChannel -c microsoft -c conda-forge --strict-channel-priority --override-channels "python=$py" mssql-python + # The full create IS the solve on a native leg -- keep it BLOCKING. + Assert-LastExit "conda create verify env (py $py)" + } + + # Can the freshly built package's Python EXECUTE on this host? On the win-arm64 + # cross leg it cannot (arm64 on x64), so skip the runtime import -- exactly like + # the osx-arm64 cross-build. Any OTHER non-runnable target is a real failure. + & $conda run -n $envName python -c "import sys" 2>$null + if ($LASTEXITCODE -ne 0) { + if ($crossBestEffort) { + Write-Host "=== [py $py] win-arm64 cross on x64: target Python not executable; deps SOLVED (blocking) but skipping runtime import (static arm64-slice audit stands in). ===" + continue + } + Write-Error "target Python for CONDA_SUBDIR=$CondaSubdir is not executable on this host, and this is NOT the win-arm64 cross-build. Refusing to silently skip validation." + exit 1 + } + + Write-Host "=== [py $py] import mssql_python + prove the vendored ODBC payload is present ===" + & $conda run -n $envName python -c "import mssql_python; print('BINDING_OK', mssql_python.__version__)" + Assert-LastExit "import mssql_python (py $py)" + & $conda run -n $envName python -c "import mssql_python_odbc; print('ODBC_PAYLOAD_OK', mssql_python_odbc.__version__)" + Assert-LastExit "import mssql_python_odbc (py $py)" + + Write-Host "=== [py $py] DB-less driver-load proof (real ODBC driver must load, not just the shim) ===" + & $conda run -n $envName python (Join-Path $RecipeRoot 'driver_load_probe.py') + Assert-LastExit "driver-load proof (py $py)" + + Write-Host "=== [py $py] confirm resolved dependencies ===" + & $conda list -n $envName | Select-String -Pattern 'azure-identity|mssql-python|openssl|krb5' +} + +Write-Host "==================== built conda artifacts ====================" +Get-ChildItem -Path $bld -Recurse -Include *.conda, *.tar.bz2 | +Where-Object { $_.Name -like 'mssql-python*' } | +ForEach-Object { Write-Host " $($_.FullName)" } +Write-Host "CONDA_BUILD_OK" +# Reset the process exit code to 0 ONLY on the win-arm64 cross leg. There, the last native command +# in the verify loop is the runnable-check `conda run` that INTENTIONALLY fails -- the arm64 Python +# cannot launch on the x64 host (exit 216 = ERROR_EXE_MACHINE_TYPE_MISMATCH) -- so we skip the +# import and `continue`, leaving a non-zero $LASTEXITCODE that would otherwise fail the leg even +# though every package built and its deps SOLVED. Scoping the reset to win-arm64 means a future +# post-check on a native leg can never be silently masked. We must NOT use PowerShell `exit 0`: the +# .yml step calls this script with `& ...` in-session, so `exit` would terminate the whole step +# BEFORE it stages the packages. A trailing SUCCESSFUL native command resets $LASTEXITCODE and +# returns control to the caller (so staging runs). Any REAL failure already exited 1 via +# Assert-LastExit / the explicit `exit 1` paths above. +if ($CondaSubdir -eq 'win-arm64') { + cmd /c "exit 0" +} diff --git a/OneBranchPipelines/scripts/build-conda-packages.sh b/OneBranchPipelines/scripts/build-conda-packages.sh new file mode 100644 index 000000000..571fde3e0 --- /dev/null +++ b/OneBranchPipelines/scripts/build-conda-packages.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +# Build + validate the self-contained mssql-python conda package from prebuilt +# (ESRP-signed) wheels, fully offline via a local --find-links dir. The package +# VENDORS the ODBC Driver 18 payload -- the recipe extracts BOTH the code wheel and +# the mssql-python-odbc wheel into one site-packages -- so there is NO separate +# conda package (the v1.11.0 model). +# ============================================================================ +# Bash port of build-conda-packages.ps1 for the macOS and Linux build legs. +# conda-build provisions a per-subdir HOST env and installs the matching wheel +# (see conda/*/build.sh). It runs NATIVELY for linux-64 and osx-64, under QEMU +# binfmt for linux-aarch64, and as a CROSS-build for osx-arm64 on the Intel macOS +# agent (there is no reverse Rosetta, so the arm64 Python is never executed -- +# conda/*/build.sh extract the universal2 wheel without Python and the section-7 +# runtime import is skipped -- osx-arm64 arch is NOT independently verified here +# (trusted from the universal2 wheel tag, like the PyPI wheel; no Mach-O arch check)). +# +# Args: +# $1 WheelsDir find-links dir holding the mssql-python + mssql-python-odbc wheels +# $2 RecipeRoot repo conda/ dir (mssql-python/ + mssql-python-odbc/) +# $3 OutputDir space-free work/output dir (Miniforge + croot + built pkgs) +# $4 MssqlPythonVersion version to stamp on mssql-python +# $5 OdbcVersion version to stamp on mssql-python-odbc +# $6 PythonVersions optional comma-separated (e.g. "3.11,3.12"); empty = auto-detect +# $7 CondaSubdir optional target subdir (e.g. osx-64, osx-arm64, +# linux-aarch64) to CROSS-target via CONDA_SUBDIR; empty = +# build the host's native subdir. The section-7 runtime +# import validation requires the host to be able to RUN the +# target's Python -- true natively, under Rosetta 2 (osx-64 +# on Apple Silicon) and under QEMU binfmt (linux-aarch64 on +# x86_64). For osx-arm64 on the Intel agent it is NOT, so +# that leg auto-skips the import. NOTE: osx-arm64 arch is not +# independently verified here -- trusted from the universal2 +# wheel tag; there is no Mach-O arch check in this pipeline. +set -euo pipefail + +WheelsDir="${1:?WheelsDir required}" +RecipeRoot="${2:?RecipeRoot required}" +OutputDir="${3:?OutputDir required}" +MssqlPythonVersion="${4:?MssqlPythonVersion required}" +OdbcVersion="${5:?OdbcVersion required}" +PythonVersions="${6:-}" +CondaSubdir="${7:-}" + +echo "==================== conda build inputs ====================" +echo "WheelsDir : $WheelsDir" +echo "RecipeRoot : $RecipeRoot" +echo "OutputDir : $OutputDir" +echo "MssqlPythonVersion : $MssqlPythonVersion" +echo "OdbcVersion : $OdbcVersion" +echo "PythonVersions : ${PythonVersions:-(auto-detect)}" +echo "CondaSubdir : ${CondaSubdir:-(native)}" +echo "============================================================" + +mkdir -p "$OutputDir" +bld="$OutputDir/bld" +mkdir -p "$bld" + +# --------------------------------------------------------------------------- +# 1. Locate conda, or install Miniforge3 (conda-forge defaults) for THIS platform +# --------------------------------------------------------------------------- +conda="$(command -v conda || true)" +# Reuse an existing Miniforge install if a previous run already created one. On +# macOS the universal2 build invokes this script once per subdir (osx-64 AND +# osx-arm64) on the SAME agent, sharing $OutputDir; each run is a fresh shell so +# `command -v conda` is empty even though miniforge/ already exists. Without this +# guard the second run re-runs the installer into the existing dir and fails with +# "File or directory already exists: .../conda-bld/miniforge". +if [ -z "$conda" ] && [ -x "$OutputDir/miniforge/bin/conda" ]; then + echo "=== reusing existing Miniforge3 at $OutputDir/miniforge ===" + conda="$OutputDir/miniforge/bin/conda" +fi +if [ -z "$conda" ]; then + echo "=== conda not found on PATH; installing Miniforge3 ===" + os="$(uname -s)"; arch="$(uname -m)" + mfver="${MINIFORGE_VERSION:-26.3.2-3}" + case "$os-$arch" in + Darwin-arm64) mf="Miniforge3-${mfver}-MacOSX-arm64.sh" ;; + Darwin-x86_64) mf="Miniforge3-${mfver}-MacOSX-x86_64.sh" ;; + Linux-x86_64) mf="Miniforge3-${mfver}-Linux-x86_64.sh" ;; + Linux-aarch64) mf="Miniforge3-${mfver}-Linux-aarch64.sh" ;; + *) echo "ERROR: unsupported platform '$os-$arch' for Miniforge" >&2; exit 1 ;; + esac + forgeDir="$OutputDir/miniforge" + installer="$OutputDir/$mf" + # Pin Miniforge to a specific release (never `latest`, which floats) and verify its + # SHA256 BEFORE executing. The expected hash is NOT hard-coded in source: prefer an + # explicit MINIFORGE_SHA256 (a pipeline variable = strongest, out-of-source), else + # verify against the release's OWN published .sha256 sidecar. The installer + # is never executed unverified. + url="https://github.com/conda-forge/miniforge/releases/download/${mfver}/$mf" + echo "Downloading pinned Miniforge $mfver: $url" + curl -fL "$url" -o "$installer" + if [ -n "${MINIFORGE_SHA256:-}" ]; then + mfsha="$MINIFORGE_SHA256" + else + curl -fL "$url.sha256" -o "$installer.sha256" + mfsha="$(grep -oiE '[0-9a-f]{64}' "$installer.sha256" | head -n1)" + fi + if [ -z "$mfsha" ]; then + echo "ERROR: could not determine the expected SHA256 for $mf." >&2 + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$installer" | awk '{print $1}')" + else + actual="$(shasum -a 256 "$installer" | awk '{print $1}')" + fi + if [ "$actual" != "$mfsha" ]; then + echo "ERROR: Miniforge installer SHA256 mismatch: expected '$mfsha', got '$actual'." >&2 + exit 1 + fi + echo "Miniforge installer SHA256 verified ($mfsha)." + # -u = update/reuse an existing target dir instead of erroring, in case a prior + # run left a partial miniforge/ behind that failed the reuse check above. + bash "$installer" -b -u -p "$forgeDir" + conda="$forgeDir/bin/conda" +fi +if ! "$conda" --version >/dev/null 2>&1; then + echo "ERROR: conda not available at '$conda' after install attempt." >&2 + exit 1 +fi +echo "Using conda: $conda" +"$conda" --version + +# --------------------------------------------------------------------------- +# 2. Install conda-build (pinned to the stable pre-26 series) +# --------------------------------------------------------------------------- +# Pin conda-build<26: the bleeding-edge 26.7.0 crashes with an internal +# "An unexpected error has occurred" during the LOCAL packaging phase (right +# after "Fixing permissions"); 26.7.1 is not yet released. The mature 25.x +# series builds these recipes cleanly and supports every key we use. +# NOTE: anaconda-client is intentionally NOT installed here — this script only +# builds + validates (it never runs `anaconda upload`). Publishing installs its +# own anaconda-client in conda-publish-step.yml. Keeping it out of the build env +# also drops the anaconda-auth conda plugin, which the crash report fingered. +# +# Use a DEDICATED env instead of `install -n base`: a pre-installed conda whose base +# is pinned to a too-new python (the GitHub-hosted runner's Miniconda pins python +# 3.14, which no conda-build<26 supports) makes a base install UNSOLVABLE. A fresh env +# lets conda pick a python conda-build<26 supports, independent of the base pin. +# conda-forge only (--override-channels) avoids the defaults-channel ToS; zstandard +# rides along so the RUNPATH audit reads .conda metadata from this same env. +condaBuildEnv="conda_builder" +echo "=== creating dedicated conda-build env ($condaBuildEnv: conda-build<26) ===" +# Idempotent: a reused agent/workdir may already have this env, and a pre-existing +# env makes `conda create` fail under `set -e`. Remove it first (best-effort, like +# the verify envs below) so a rerun recreates cleanly. +"$conda" env remove -y -n "$condaBuildEnv" 2>/dev/null || true +"$conda" create -y -n "$condaBuildEnv" -c conda-forge --override-channels "conda-build<26" zstandard + +# --------------------------------------------------------------------------- +# 3. Determine which Python versions to build (auto-detect from mssql_python wheels) +# --------------------------------------------------------------------------- +if [ -z "$PythonVersions" ]; then + pyvers="$(ls "$WheelsDir"/mssql_python-*.whl 2>/dev/null \ + | grep -v 'mssql_python_odbc' \ + | sed -nE 's/.*-cp3([0-9]+)-.*/3.\1/p' | sort -u)" +else + pyvers="$(echo "$PythonVersions" | tr ',' '\n' | sed 's/[[:space:]]//g' | grep -v '^$')" +fi +if [ -z "$pyvers" ]; then + echo "ERROR: no mssql_python wheels in '$WheelsDir' to determine Python versions." >&2 + exit 1 +fi +echo "Building conda packages for Python versions: $(echo "$pyvers" | tr '\n' ' ')" + +# --------------------------------------------------------------------------- +# 4. Export the environment consumed by the recipes (jinja + build scripts) +# --------------------------------------------------------------------------- +export WHEELS_DIR="$WheelsDir" +export MSSQL_PYTHON_VERSION="$MssqlPythonVersion" +export MSSQL_ODBC_VERSION="$OdbcVersion" + +# Cross-subdir builds: force conda-build AND the verify `conda create` to target the +# requested subdir instead of the host's native one. Both honor CONDA_SUBDIR, so the +# packages are stamped for $CondaSubdir. The section-7 import validation solves that +# subdir and runs the target Python where the host can execute it (natively, under +# Rosetta 2 for osx-64, or under QEMU binfmt for linux-aarch64); on the osx-arm64 +# cross-build (Intel agent, no reverse Rosetta) section 7 auto-detects that the target +# Python can't run and skips the import. Left unset for a native build. +if [ -n "$CondaSubdir" ]; then + export CONDA_SUBDIR="$CondaSubdir" + echo "Cross-targeting conda subdir: CONDA_SUBDIR=$CONDA_SUBDIR" + # Emulated aarch64 cross-build: the verify env's target-arch Python (section 7) + # runs under qemu-user. Point qemu at the aarch64 glibc loader/libs (installed via + # libc6-arm64-cross on the leg) so it can find /lib/ld-linux-aarch64.so.1. Only the + # emulated aarch64 leg has this dir; elsewhere the var is a harmless no-op. + case "$CONDA_SUBDIR" in + *aarch64) + if [ -d /usr/aarch64-linux-gnu ]; then + export QEMU_LD_PREFIX="${QEMU_LD_PREFIX:-/usr/aarch64-linux-gnu}" + echo "Set QEMU_LD_PREFIX=$QEMU_LD_PREFIX for emulated aarch64 verify" + fi + ;; + esac +fi + +# --------------------------------------------------------------------------- +# 5. Build companion FIRST, then the binding, for each Python version +# --------------------------------------------------------------------------- +bindRecipe="$RecipeRoot/mssql-python" +for py in $pyvers; do + echo "=== [py $py] build mssql-python (self-contained: vendors the ODBC payload) ===" + "$conda" run -n "$condaBuildEnv" conda-build "$bindRecipe" --python "$py" --no-test --no-anaconda-upload --output-folder "$bld" +done + +# --------------------------------------------------------------------------- +# 6. Make the local output folder a VALID conda channel. +# conda-build --output-folder already wrote $bld//repodata.json for the +# platform we built, but a conda channel is only valid if it ALSO carries +# noarch/repodata.json (even empty) -- otherwise `conda create -c file://$bld` +# fails with "UnavailableInvalidChannel ... must contain noarch/repodata.json". +# Create it directly rather than via `conda index`, whose subcommand is absent +# from miniforge (it moved to the standalone conda-index package). +# --------------------------------------------------------------------------- +mkdir -p "$bld/noarch" +if [ ! -f "$bld/noarch/repodata.json" ]; then + printf '%s' '{"info":{"subdir":"noarch"},"packages":{},"packages.conda":{}}' > "$bld/noarch/repodata.json" +fi + +# --------------------------------------------------------------------------- +# 6b. Masking-immune RUNPATH audit of the freshly built packages (#563). +# --------------------------------------------------------------------------- +# BLOCKING static gate: read the ELF RUNPATH BYTES of the vendored Linux ODBC +# binaries in every built .conda and require the relative $ORIGIN climb (+ no +# vendored krb5/openssl/libltdl). Immune to the system-lib masking that hides an +# unreachable conda copy from a runtime ldd/import on a full agent; win/osx +# packages carry no ELF payload and are skipped. `set -e` makes a violation abort. +auditScript="$(cd "$(dirname "$RecipeRoot")" && pwd)/eng/scripts/audit_bundled_binaries.py" +if [ ! -f "$auditScript" ]; then + echo "ERROR: RUNPATH audit script not found at $auditScript" >&2 + exit 1 +fi +echo "=== RUNPATH self-containment audit (eng/scripts/audit_bundled_binaries.py) ===" +"$conda" run -n "$condaBuildEnv" python "$auditScript" --root "$bld" + +# --------------------------------------------------------------------------- +# 7. Validate: solve a fresh env from the local channel and import the package. +# Proves azure-identity + the folded-in openssl/krb5 deps resolve AND that the +# repackaged native binding imports with its vendored ODBC payload (driver loads +# at import). +# --------------------------------------------------------------------------- +# Run from a NEUTRAL dir: `python -c` prepends the cwd to sys.path, and the pipeline +# runs from the repo checkout whose in-tree mssql_python/ (source, no compiled +# extension) would shadow the conda-installed package -> "No ddbc_bindings module +# found". $OutputDir is outside the repo checkout. +cd "$OutputDir" +# conda's channel URL parser treats any path COMPONENT equal to a known conda subdir +# (osx-arm64, osx-64, linux-64, linux-aarch64, win-64, noarch) as the platform subdir +# and STRIPS it from the channel root. The pipeline isolates each leg under a +# subdir-NAMED dir (OutputDir=.../), so $bld's path contains that token and +# `conda create -c "$bld"` would resolve to the WRONG, token-stripped path (.../bld, +# which has no repodata) -> "UnavailableInvalidChannel ... must contain noarch/...". +# Copy the freshly built channel (platform subdir + the section-6 noarch stub) into a +# token-FREE, per-leg-unique dir so conda parses the channel path verbatim. +legName="${OutputDir##*/}" # e.g. osx-arm64 / linux-64 +localChannel="$(dirname "$OutputDir")/verifychan_${legName//[^A-Za-z0-9]/_}" +rm -rf "$localChannel"; mkdir -p "$localChannel" +cp -a "$bld"/. "$localChannel"/ +echo "verify channel (token-free alias of $bld): $localChannel" + +# Emulated CROSS leg: the target-arch conda subdir differs from the host arch, so the +# verify Python runs under QEMU binfmt (e.g. linux-aarch64 on an x86_64 agent). The +# Python binding imports fine under qemu-user, but qemu-user CANNOT reliably initialize +# the native unixODBC environment -- SQLAllocEnv does ltdl/pthread/locale init it +# mis-emulates, surfacing as "Failed to allocate environment handle". The SAME driver +# loads under full-arch emulation AND on the native same-arch leg, and the masking- +# immune static RUNPATH audit already proved self-containment. So on an emulated cross +# leg the RUNTIME driver probes (driver-load, ldd reachability, TLS) are BEST-EFFORT; +# build + audit + import stay blocking. +emulated_cross=0 +host_machine="$(uname -m)" +case "${CONDA_SUBDIR:-}" in + *aarch64 | *arm64) + if [ "$host_machine" != "aarch64" ] && [ "$host_machine" != "arm64" ]; then + emulated_cross=1 + echo "NOTE: emulated CROSS leg (CONDA_SUBDIR=$CONDA_SUBDIR on $host_machine host); runtime driver probes are best-effort under QEMU binfmt, build/audit/import remain blocking." + fi + ;; +esac + +for py in $pyvers; do + # Include the target subdir so the two macOS legs (osx-64 + osx-arm64) that run on + # the SAME agent never collide on the env name, and recreate cleanly so a re-run + # or a leftover env can't fail `conda create`. + sub="${CONDA_SUBDIR:-native}"; sub="${sub//-/_}" + envName="verify_${sub}_${py//./}" + "$conda" env remove -y -n "$envName" >/dev/null 2>&1 || true + echo "=== [py $py] create verify env from local channel ===" + # -c microsoft (ahead of conda-forge) so azure-core/azure-identity/msal resolve from the + # lean `microsoft` channel, NOT conda-forge whose azure-core recipe over-declares flask/six + # -> celery/boto3/botocore (~9 MB); see conda-forge/azure-core-feedstock#71. + # --strict-channel-priority keeps the freshly built local package authoritative. + "$conda" create -y -n "$envName" -c "$localChannel" -c microsoft -c conda-forge --strict-channel-priority --override-channels "python=$py" mssql-python + # Whether the freshly built package's Python can EXECUTE on this host. + target_runnable=1 + "$conda" run -n "$envName" python -c "import sys" >/dev/null 2>&1 || target_runnable=0 + if [ "$target_runnable" = "0" ]; then + # The ONLY leg allowed to skip the runtime proof is the osx-arm64 cross-build on + # an Intel agent (no reverse Rosetta): the arm64 Python genuinely cannot run here. + # CAVEAT: osx-arm64 arch is NOT independently verified in this pipeline. Unlike + # Windows (assert_pe_machine.py PE check) and Linux (audit_bundled_binaries.py ELF + # e_machine check), there is NO Mach-O arch audit, so the arm64 slice is trusted + # from the universal2 wheel tag -- exactly like the shipping PyPI universal2 wheel. + # Every OTHER target (linux-64/osx-64 native, linux-aarch64 under QEMU binfmt) MUST + # run its own import; a leg that cannot is a real failure, never a silent pass -- + # otherwise a broken linux-aarch64 package ships unvalidated. + if [ "${CONDA_SUBDIR:-}" = "osx-arm64" ] && [ "$(uname -s)" = "Darwin" ]; then + echo "=== [py $py] osx-arm64 cross on Intel: target Python not executable; skipping runtime import (osx-arm64 arch NOT independently verified -- trusted from the universal2 wheel tag). ===" + continue + fi + echo "ERROR: [py $py] target Python for CONDA_SUBDIR=${CONDA_SUBDIR:-native} is not executable on $(uname -s)/$(uname -m), and this is NOT the osx-arm64 cross-build. Refusing to silently skip validation (linux-aarch64 requires QEMU binfmt to be registered on this leg)." >&2 + exit 1 + fi + echo "=== [py $py] import mssql_python + prove the vendored ODBC payload is present ===" + "$conda" run -n "$envName" python -c "import mssql_python; print('BINDING_OK', mssql_python.__version__)" + "$conda" run -n "$envName" python -c "import mssql_python_odbc; print('ODBC_PAYLOAD_OK', mssql_python_odbc.__version__)" + echo "=== [py $py] DB-less driver-load proof (real ODBC driver must load, not just the shim) ===" + if [ "$emulated_cross" = "1" ]; then + "$conda" run -n "$envName" python "$RecipeRoot/driver_load_probe.py" \ + || echo "SKIP (emulated cross under QEMU binfmt): qemu-user cannot initialize the native ODBC environment (SQLAllocEnv). The masking-immune static RUNPATH audit + the native same-arch leg + full-arch emulation validate the driver; this runtime probe is best-effort on the emulated leg." + else + "$conda" run -n "$envName" python "$RecipeRoot/driver_load_probe.py" + fi + # Minimal-base reachability gate (#563): on a Linux leg with NO system + # krb5/libltdl (set CONDA_ASSERT_PREFIX_REACHABLE=1), prove the vendored driver + # binds the env's OWN $CONDA_PREFIX/lib copies via the $ORIGIN climb -- not a + # system fallthrough that would MASK an unreachable conda lib on a full agent. + # The $ORIGIN climb makes ldd resolve krb5/gssapi/libltdl from $CONDA_PREFIX/lib + # without LD_LIBRARY_PATH; a system or not-found binding fails the leg. + if [ "${CONDA_ASSERT_PREFIX_REACHABLE:-}" = "1" ] && [ "$(uname -s)" = "Linux" ] && [ "$emulated_cross" = "1" ]; then + echo "=== [py $py] reachability gate SKIPPED on the emulated cross leg (qemu-user cannot reliably run the aarch64 driver's ldd/env init); the masking-immune static RUNPATH audit is the authoritative \$ORIGIN-climb guard. ===" + elif [ "${CONDA_ASSERT_PREFIX_REACHABLE:-}" = "1" ] && [ "$(uname -s)" = "Linux" ]; then + echo "=== [py $py] minimal-base ldd reachability gate (driver MUST bind CONDA_PREFIX/lib) ===" + env_prefix="$("$conda" run -n "$envName" python -c 'import os,sys; print(os.environ.get("CONDA_PREFIX") or sys.prefix)')" + # Inspect the SAME driver variant the loader actually binds on THIS host. mssql_python + # (GetDriverPathCpp in ddbc_bindings.cpp) selects libs/linux// by probing + # /etc/*-release; a blind glob instead grabs the alphabetically-first 'alpine' (musl) + # variant, which needs libc.musl (absent on glibc) and whose libodbcinst does NOT link + # libltdl -> a false "libltdl absent" failure. Mirror that selection exactly. + drv="$("$conda" run -n "$envName" python -c 'import mssql_python,glob,os,platform; b=os.path.dirname(mssql_python.__file__); d=("alpine" if os.path.exists("/etc/alpine-release") else "rhel" if (os.path.exists("/etc/redhat-release") or os.path.exists("/etc/centos-release")) else "suse" if (os.path.exists("/etc/SuSE-release") or os.path.exists("/etc/SUSE-brand")) else "debian_ubuntu"); a=("arm64" if platform.machine() in ("aarch64","arm64") else "x86_64"); m=glob.glob(os.path.join(b,"..","mssql_python_odbc","libs","linux",d,a,"lib","libmsodbcsql*")); print(m[0] if m else "")')" + if [ -z "$drv" ]; then + echo "ERROR: [py $py] no libmsodbcsql driver found in the verify env; cannot prove reachability." >&2 + exit 1 + fi + inst="$(dirname "$drv")/libodbcinst.so.2" + # H2: the inspection itself MUST succeed (no `|| true`) -- a failed ldd cannot + # be read as "reachable". Collect the combined transitive ldd of both binaries. + ldd_all="" + for lib in "$drv" "$inst"; do + echo "--- ldd $(basename "$lib") ---" + # Clear any inherited LD_LIBRARY_PATH so resolution proves the RUNPATH $ORIGIN climb + # ALONE reaches $CONDA_PREFIX/lib -- an ambient LD_LIBRARY_PATH could otherwise satisfy + # the sonames and MASK a broken RUNPATH. + if ! out="$("$conda" run -n "$envName" env -u LD_LIBRARY_PATH ldd "$lib" 2>&1)"; then + echo "$out" + echo "ERROR: [py $py] ldd failed on $(basename "$lib"); cannot verify reachability." >&2 + exit 1 + fi + echo "$out" + ldd_all="$ldd_all +$out" + done + # H2: each required soname MUST be present AND resolve from $env_prefix -- a + # missing (not found) or system binding FAILS closed (never passes on no-match). + reach_fail=0 + for want in libkrb5.so libgssapi_krb5.so libltdl.so; do + hits="$(printf '%s\n' "$ldd_all" | grep -F "$want" || true)" + if [ -z "$hits" ]; then + echo "MISS: required '$want' absent from ldd output (driver stopped resolving it?)." >&2 + reach_fail=1 + continue + fi + n_prefix=0 + n_bad=0 + while IFS= read -r line; do + [ -n "$line" ] || continue + resolved="$(printf '%s' "$line" | sed -nE 's/.*=> +([^ ]+).*/\1/p')" + case "$resolved" in + "$env_prefix"/lib/*) n_prefix=$((n_prefix + 1)); echo "OK $line" ;; + "") n_bad=$((n_bad + 1)); echo "NOTFOUND $line" >&2 ;; + *) n_bad=$((n_bad + 1)); echo "SYSTEM $line" >&2 ;; + esac + done <&2 + reach_fail=1 + fi + done + [ "$reach_fail" = "0" ] || { echo "ERROR: [py $py] reachability gate FAILED -- a required krb5/gssapi/libltdl bound to system or was absent instead of $env_prefix/lib." >&2; exit 1; } + echo "REACHABILITY_OK (krb5 + gssapi_krb5 + libltdl all bound from $env_prefix/lib)" + fi + # Live Encrypt=yes TLS gate -- forces the driver to dlopen its OpenSSL backend + # (libssl/libcrypto), which the DB-less Encrypt=no probe above NEVER exercises. + # Runs (BLOCKING) only when CONDA_TLS_PROBE_CONN points at a reachable server; + # otherwise it SKIPS loudly (it never silently passes). CAVEAT: this is + # conclusive ONLY on a minimal base with NO system OpenSSL -- a system libssl + # lets the driver's dlopen fall through and MASK an unreachable conda + # /lib copy (exactly what full CI agents hide). The masking-IMMUNE guard + # is eng/scripts/audit_bundled_binaries.py, which reads the RUNPATH bytes and + # requires an $ORIGIN/.. climb regardless of any system libs; this gate is the + # complementary end-to-end backstop for a minimal-base leg. + # Decide whether to invoke the Encrypt=yes gate. Run it when a connection string is set, + # OR when CONDA_TLS_PROBE_REQUIRED is anything OTHER than an explicit off/empty value. The + # probe (tls_connect_probe.py) is the single source of truth for required-mode semantics -- + # it enforces on a truthy value and FAILS LOUD on an unrecognized one -- so the shell and + # Python agree on truthiness (1/true/yes/on) and an ambiguous typo can't silently skip here. + _tls_req="$(printf '%s' "${CONDA_TLS_PROBE_REQUIRED:-}" | tr '[:upper:]' '[:lower:]')" + case "$_tls_req" in + "" | 0 | false | no | off) _tls_req_active=0 ;; + *) _tls_req_active=1 ;; + esac + if [ -n "${CONDA_TLS_PROBE_CONN:-}" ] || [ "$_tls_req_active" = "1" ]; then + echo "=== [py $py] live Encrypt=yes TLS gate (OpenSSL backend must be reachable) ===" + if [ "$emulated_cross" = "1" ]; then + "$conda" run -n "$envName" python "$RecipeRoot/tls_connect_probe.py" \ + || echo "SKIP (emulated cross under QEMU binfmt): qemu-user cannot run the aarch64 driver's TLS/OpenSSL init; best-effort on the emulated leg (static RUNPATH audit covers OpenSSL layout)." + else + # Non-emulated: the probe is fail-closed. With a truthy CONDA_TLS_PROBE_REQUIRED a + # missing or malformed connection string FAILS here (a typo can't silently no-op it). + "$conda" run -n "$envName" python "$RecipeRoot/tls_connect_probe.py" + fi + else + echo "=== [py $py] Encrypt=yes TLS gate SKIPPED (set CONDA_TLS_PROBE_CONN on a minimal-base leg to enable) ===" + fi + echo "=== [py $py] confirm resolved dependencies ===" + "$conda" list -n "$envName" | grep -E 'azure-identity|mssql-python|openssl|krb5' || true +done + +echo "==================== built conda artifacts ====================" +find "$bld" -type f \( -name 'mssql-python*.conda' -o -name 'mssql-python*.tar.bz2' \) -print +echo "CONDA_BUILD_OK" diff --git a/OneBranchPipelines/steps/conda-build-validate-step-posix.yml b/OneBranchPipelines/steps/conda-build-validate-step-posix.yml new file mode 100644 index 000000000..e67b621e8 --- /dev/null +++ b/OneBranchPipelines/steps/conda-build-validate-step-posix.yml @@ -0,0 +1,146 @@ +# Conda Build + Validate Step Template (POSIX / bash) +# ============================================================================ +# Bash twin of conda-build-validate-step.yml for the macOS (osx-arm64) and Linux +# (linux-64) build legs. Repackages THIS leg's mssql-python wheel(s) + the +# external mssql-python-odbc wheel into conda packages and validates solve+import +# on the SAME native agent. conda-build provisions a real per-subdir host env, so +# this only runs on the matching native platform (no cross-build, no musl target, +# no aarch64 here — the aarch64 host is x86_64 + QEMU). +# +# This step ONLY builds + validates + stages conda packages as an artifact. It +# does NOT publish anything (publishing happens in the release pipeline), and it is +# BLOCKING by default (continueOnError=false): if conda cannot build/validate the +# packages the leg FAILS, so a broken conda package can never hide behind a green +# build. The one intentionally best-effort exception is the emulated linux-aarch64 +# leg (QEMU flakiness), which overrides continueOnError to true at its call site. +parameters: + # conda subdir this leg targets: 'osx-arm64' or 'linux-64' (display + staging). + - name: condaSubdir + type: string + # Directory holding the freshly built mssql-python wheel(s) for this platform. + - name: mssqlWheelDir + type: string + default: '$(Build.SourcesDirectory)/dist' + # Glob selecting this platform's mssql-python wheel(s) (odbc excluded in-script). + - name: mssqlWheelGlob + type: string + default: 'mssql_python-*.whl' + # Directory holding the downloaded external mssql-python-odbc wheel(s). + # macOS downloads to $(Pipeline.Workspace)/odbc_wheels; Linux flattens them into + # $(Build.SourcesDirectory)/odbc_wheels — pass the right one per leg. + - name: odbcWheelDir + type: string + default: '$(Pipeline.Workspace)/odbc_wheels' + # find -name filter selecting THIS platform's odbc wheel from the consolidated + # odbc drop (which contains ALL 7 platforms). MUST match the leg's OS/arch, + # else conda-build's pip install fails with DistributionNotFound. + - name: odbcWheelFilter + type: string + # Repo conda/ recipe root (contains mssql-python/ and mssql-python-odbc/). + - name: recipeRoot + type: string + default: '$(Build.SourcesDirectory)/conda' + # Space-free working dir for the conda croot + Miniforge + built packages. The + # step APPENDS the target subdir to this (see OUT below) so two legs sharing one + # agent (macOS builds osx-arm64 AND osx-64 on the same Intel agent) never share a + # bld tree -- otherwise the blocking osx-64 leg's staging `find` would also sweep + # up the best-effort osx-arm64 packages (cross-subdir bleed-through). + - name: outputDir + type: string + default: '$(Agent.TempDirectory)/conda-bld' + # Optional comma-separated Python versions; empty = auto-detect from the wheels. + - name: pythonVersions + type: string + default: '' + # Optional target subdir to CROSS-build via CONDA_SUBDIR (e.g. 'osx-64' on an + # Apple-Silicon agent, 'linux-aarch64' on an x86_64 host). Empty = build the + # host's native subdir (osx-arm64 / linux-64). Cross-targeting relies on the host + # being able to RUN the target's Python for the import validation (Rosetta 2 / + # QEMU binfmt); the caller is responsible for that being available on the leg. + - name: condaTargetSubdir + type: string + default: '' + # The shared bash build+validate script. + - name: scriptPath + type: string + default: '$(Build.SourcesDirectory)/OneBranchPipelines/scripts/build-conda-packages.sh' + # BLOCKING by default: if conda cannot build/validate the packages, FAIL the leg + # instead of letting a green build hide a broken conda package. Callers running an + # intentionally best-effort emulated leg (e.g. linux-aarch64 under QEMU) may override + # this to true. + - name: continueOnError + type: boolean + default: false + +steps: + - bash: | + set -euo pipefail + + MSSQL_WHEEL_DIR="${{ parameters.mssqlWheelDir }}" + ODBC_WHEEL_DIR="${{ parameters.odbcWheelDir }}" + # Append the target subdir so each leg on a SHARED agent (macOS osx-arm64 + + # osx-64) gets its OWN bld tree -- no cross-subdir bleed-through at staging. + OUT="${{ parameters.outputDir }}/${{ parameters.condaSubdir }}" + LINKS="$OUT/wheels" + rm -rf "$LINKS"; mkdir -p "$LINKS" + + # Gather this platform's mssql-python wheel(s) into ONE find-links dir, + # excluding the odbc package (its filename also starts with mssql_python). + shopt -s nullglob + mssql_found=0 + for w in "$MSSQL_WHEEL_DIR"/${{ parameters.mssqlWheelGlob }}; do + case "$(basename "$w")" in mssql_python_odbc-*) continue ;; esac + cp -f "$w" "$LINKS/"; mssql_found=1 + done + [ "$mssql_found" = "1" ] || { echo "ERROR: no mssql-python wheel in $MSSQL_WHEEL_DIR" >&2; exit 1; } + + # Gather THIS platform's odbc wheel (must match the leg's OS/arch). + odbc_whl="$(find "$ODBC_WHEEL_DIR" -name '${{ parameters.odbcWheelFilter }}' 2>/dev/null | head -1)" + [ -n "$odbc_whl" ] || { echo "ERROR: no wheel matching '${{ parameters.odbcWheelFilter }}' in $ODBC_WHEEL_DIR" >&2; exit 1; } + cp -f "$odbc_whl" "$LINKS/" + echo "find-links wheels:"; ls -1 "$LINKS" + + # Derive versions from the wheel filenames (single source of truth: the + # ESRP-signed wheels), so the conda package version can NEVER drift. + mssql_whl="$(ls "$LINKS"/mssql_python-*.whl | grep -v mssql_python_odbc | head -1)" + MSSQL_VER="$(basename "$mssql_whl" | sed -nE 's/^mssql_python-([^-]+)-.*/\1/p')" + ODBC_VER="$(basename "$odbc_whl" | sed -nE 's/^mssql_python_odbc-([^-]+)-.*/\1/p')" + [ -n "$MSSQL_VER" ] && [ -n "$ODBC_VER" ] || { echo "ERROR: could not derive versions from wheel filenames" >&2; exit 1; } + echo "Derived versions -> mssql-python=$MSSQL_VER mssql-python-odbc=$ODBC_VER" + + # Build + validate the conda packages for this leg's Python version(s). + chmod +x "${{ parameters.scriptPath }}" + bash "${{ parameters.scriptPath }}" \ + "$LINKS" \ + "${{ parameters.recipeRoot }}" \ + "$OUT" \ + "$MSSQL_VER" \ + "$ODBC_VER" \ + "${{ parameters.pythonVersions }}" \ + "${{ parameters.condaTargetSubdir }}" + + # Stage this leg's conda packages onto the artifact for the consolidate stage, + # preserving the conda subdir layout. Stage ONLY packages whose conda-build + # output subdir matches THIS leg's target (metadata-matched, not a blind tree + # sweep) so a shared agent (macOS osx-arm64 + osx-64) can never bleed one leg's + # packages into the other's artifact. conda-build writes each package into a + # bld// folder that equals its info/index.json subdir, so the folder + # name IS the authoritative subdir. + TARGET_SUBDIR="${{ parameters.condaTargetSubdir }}" + [ -n "$TARGET_SUBDIR" ] || TARGET_SUBDIR="${{ parameters.condaSubdir }}" + CONDA_OUT="$(ob_outputDirectory)/conda" + mkdir -p "$CONDA_OUT/$TARGET_SUBDIR" + staged=0 + while IFS= read -r p; do + subdir="$(basename "$(dirname "$p")")" + if [ "$subdir" != "$TARGET_SUBDIR" ]; then + echo " skip (subdir '$subdir' != target '$TARGET_SUBDIR'): $(basename "$p")" + continue + fi + cp -f "$p" "$CONDA_OUT/$TARGET_SUBDIR/" + echo " staged $TARGET_SUBDIR/$(basename "$p")" + staged=1 + done < <(find "$OUT/bld" -type f \( -name 'mssql-python*.conda' -o -name 'mssql-python*.tar.bz2' \)) + [ "$staged" = "1" ] || { echo "ERROR: no conda packages matching target subdir '$TARGET_SUBDIR' were produced in $OUT/bld" >&2; exit 1; } + displayName: 'Conda build + validate (${{ parameters.condaSubdir }})' + continueOnError: ${{ parameters.continueOnError }} diff --git a/OneBranchPipelines/steps/conda-build-validate-step.yml b/OneBranchPipelines/steps/conda-build-validate-step.yml new file mode 100644 index 000000000..06529159c --- /dev/null +++ b/OneBranchPipelines/steps/conda-build-validate-step.yml @@ -0,0 +1,145 @@ +# Conda Build + Validate Step Template +# ============================================================================ +# Repackages the prebuilt, ESRP-signed wheels produced by THIS build leg into +# conda packages, then validates them on the SAME native agent (which already +# has the matching wheel, the external mssql-python-odbc wheel, and a live +# SQL Server for pytest). Include this AFTER the wheel is built on a build leg. +# +# WHY THIS RUNS PER-PLATFORM (not on a single host like ODBC_BuildAll): +# `ODBC_BuildAll` cross-produces every wheel on one host because setup_odbc.py +# only RE-TAGS a data zip. conda-build is different: it provisions a real host +# environment for the target subdir and `pip install`s the matching wheel +# (see conda/*/bld.bat|build.sh). A linux-64 / osx-* host env cannot be created +# on a Windows agent, so — exactly like the wheels and like the conda-forge +# pyodbc-feedstock — each conda package must be built on its matching platform. +# +# SCOPE / LIMITATIONS (first cut, intentionally conservative): +# * Native legs are import-validated. A CROSS leg (condaTargetSubdir set, e.g. +# win-arm64 on x64) cannot execute the target Python, so its runtime import +# auto-skips -- but the recipe still enforces a BLOCKING --dry-run dependency +# solve and (win-arm64) a PE machine-type assert, so it is validated, not trusted. +# * musllinux has NO conda target (conda linux-* is glibc), so this step is +# never included on the musllinux legs. +# +# This step ONLY builds + validates + stages conda packages as an artifact. It +# does NOT publish anything — publishing (anaconda upload / ESRP) happens in the +# release pipeline, exactly like the wheels. +parameters: + # Python version this leg builds, X.Y (e.g. '3.13'). One conda build per leg. + - name: pythonVersion + type: string + # conda subdir to stamp on the packages (win-64, osx-64, osx-arm64, + # linux-64, linux-aarch64). Must match the platform of THIS agent. + - name: condaSubdir + type: string + # Optional target subdir to CROSS-build via CONDA_SUBDIR on this (x64) agent, e.g. + # 'win-arm64'. Empty = build the host's native subdir. On a cross leg the arm64 Python + # cannot execute here, so the runtime import auto-skips; the deps still solve (from + # Anaconda `defaults` + the microsoft noarch azure-identity/msal) and the package is + # built + staged. Pair with continueOnError=true. + - name: condaTargetSubdir + type: string + default: '' + # Directory holding the freshly built mssql-python wheel (setup.py bdist_wheel). + - name: mssqlWheelDir + type: string + default: '$(Build.SourcesDirectory)/dist' + # Directory holding the downloaded external mssql-python-odbc wheel(s) + # (populated by the leg's `installOdbcWheel` download step). + - name: odbcWheelDir + type: string + default: '$(Pipeline.Workspace)/odbc_wheels' + # Filename filter selecting THIS platform's mssql-python-odbc wheel from the + # consolidated odbc drop (which contains ALL 7 platforms). MUST match the leg's + # OS/arch, otherwise conda-build's `pip install` on this host fails with + # DistributionNotFound (a macOS/linux wheel is not installable on win-64, etc.). + - name: odbcWheelFilter + type: string + default: 'mssql_python_odbc-*win_amd64.whl' + # Repo conda/ recipe root (contains mssql-python/ and mssql-python-odbc/). + - name: recipeRoot + type: string + default: '$(Build.SourcesDirectory)/conda' + # Space-free working dir for the conda croot + Miniforge + built packages. + - name: outputDir + type: string + default: '$(Agent.TempDirectory)/conda-bld' + # The shared build+validate script (installs Miniforge/conda-build, builds the + # self-contained mssql-python package, indexes a local channel, solves + imports). + - name: scriptPath + type: string + default: '$(Build.SourcesDirectory)/OneBranchPipelines/scripts/build-conda-packages.ps1' + # BLOCKING by default: if conda cannot build/validate the packages, FAIL the leg + # instead of letting a green build hide a broken conda package. Callers running an + # intentionally best-effort emulated leg (e.g. linux-aarch64 under QEMU) may override + # this to true. + - name: continueOnError + type: boolean + default: false + +steps: + - powershell: | + $ErrorActionPreference = 'Stop' + + $mssqlWheelDir = "${{ parameters.mssqlWheelDir }}" + $odbcWheelDir = "${{ parameters.odbcWheelDir }}" + + # Gather both packages' wheels into ONE find-links dir the recipes install from. + $links = Join-Path "${{ parameters.outputDir }}" 'wheels' + New-Item -ItemType Directory -Force -Path $links | Out-Null + + $mssqlWheels = @(Get-ChildItem -Path $mssqlWheelDir -Filter 'mssql_python-*.whl' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike 'mssql_python_odbc-*' }) + if ($mssqlWheels.Count -eq 0) { Write-Error "No mssql_python-*.whl found in $mssqlWheelDir"; exit 1 } + + $odbcWheel = Get-ChildItem -Path $odbcWheelDir -Recurse -Filter '${{ parameters.odbcWheelFilter }}' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $odbcWheel) { Write-Error "No wheel matching '${{ parameters.odbcWheelFilter }}' found in $odbcWheelDir"; exit 1 } + + # Copy EVERY mssql-python wheel (a standalone conda build passes all Python versions + # at once; a per-Python wheel leg simply has one) plus this platform's odbc wheel. + $mssqlWheels | ForEach-Object { Copy-Item $_.FullName -Destination $links -Force } + Copy-Item $odbcWheel.FullName -Destination $links -Force + Write-Host "find-links wheels:" + Get-ChildItem $links | ForEach-Object { Write-Host " - $($_.Name)" } + + # Derive the versions from the wheel filenames (single source of truth: the + # ESRP-signed wheels themselves) so the conda package version can NEVER drift + # from the wheel. Filenames: mssql_python--cp3X-...whl and + # mssql_python_odbc--py3-none-...whl. + if ($mssqlWheels[0].Name -notmatch '^mssql_python-([^-]+)-') { Write-Error "Cannot parse version from $($mssqlWheels[0].Name)"; exit 1 } + $mssqlVer = $Matches[1] + if ($odbcWheel.Name -notmatch '^mssql_python_odbc-([^-]+)-') { Write-Error "Cannot parse version from $($odbcWheel.Name)"; exit 1 } + $odbcVer = $Matches[1] + Write-Host "Derived versions -> mssql-python=$mssqlVer mssql-python-odbc=$odbcVer" + + # Build + validate the self-contained mssql-python conda package for THIS leg's + # single Python version (it vendors the ODBC payload from the odbc wheel above). + & "${{ parameters.scriptPath }}" ` + -WheelsDir $links ` + -RecipeRoot "${{ parameters.recipeRoot }}" ` + -OutputDir "${{ parameters.outputDir }}" ` + -MssqlPythonVersion $mssqlVer ` + -OdbcVersion $odbcVer ` + -PythonVersions "${{ parameters.pythonVersion }}" ` + -CondaSubdir "${{ parameters.condaTargetSubdir }}" + if ($LASTEXITCODE -ne 0) { Write-Error "conda build+validate failed (exit $LASTEXITCODE)"; exit 1 } + + # Stage the built conda packages onto the leg's artifact so the consolidate + # stage can collect them (mirrors how the wheels ride the same artifact). + $condaOut = Join-Path "$(ob_outputDirectory)" 'conda' + New-Item -ItemType Directory -Force -Path $condaOut | Out-Null + $built = Get-ChildItem -Path (Join-Path "${{ parameters.outputDir }}" 'bld') -Recurse -Include *.conda, *.tar.bz2 | + Where-Object { $_.Name -like 'mssql-python*' } + if (-not $built) { Write-Error "No conda packages were produced under $($links)"; exit 1 } + foreach ($p in $built) { + # Preserve the conda subdir folder layout (e.g. win-64/) so the channel + # is valid when consolidated and indexed downstream. + $subdir = Split-Path -Leaf (Split-Path -Parent $p.FullName) + $dest = Join-Path $condaOut $subdir + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item $p.FullName -Destination $dest -Force + Write-Host " staged $subdir/$($p.Name)" + } + displayName: 'Conda build + validate (${{ parameters.condaSubdir }} py${{ parameters.pythonVersion }})' + continueOnError: ${{ parameters.continueOnError }} diff --git a/conda/driver_load_probe.py b/conda/driver_load_probe.py new file mode 100644 index 000000000..0cf62de6b --- /dev/null +++ b/conda/driver_load_probe.py @@ -0,0 +1,116 @@ +"""DB-less ODBC driver-load proof for the conda test-before-live gate. + +Importing ``mssql_python`` and issuing the first ``connect()`` triggers the +one-time native ODBC driver load (``std::call_once`` in the C++ binding). To +prove the driver payload is present AND architecture-correct WITHOUT a live SQL +Server, we attempt a connection to an unreachable local port and classify the +failure. + +FAIL-CLOSED classification (this is the whole point of the probe): + +* We treat the outcome as PASS **only** when there is positive proof the native + driver loaded -- either a clean connect, or a *connection-stage* diagnostic + that only the loaded ``msodbcsql`` driver can emit (its ``[Microsoft][ODBC + Driver 18 for SQL Server]`` branding, a SQL Server network provider error, a + TLS handshake error, or a login / auth outcome). See ``_DRIVER_LOADED_MARKERS``. +* Every other exception is treated as a load failure -> non-zero exit. This + includes the C++ ``LoadDriverOrThrowException`` family + ("Failed to load the driver...", "Failed to load library: ", + "Failed to load required function pointers...", "ODBC driver not found...", + the ``mssql-auth.dll`` errors) and the macOS ``dlopen`` / ``dlerror`` detail -- + none of which contain a loaded-driver marker, so a broken / missing / + mis-architecture driver can never report PASS. + +This gates on the actual DRIVER, not just the tiny ``mssql_python_odbc`` Python +shim, and needs no ``DB_CONNECTION_STRING`` secret. A real live ``SELECT 1`` still +runs separately whenever a server is wired. + +Exit code 0 = driver loaded; non-zero = driver did not load (blocks publish). +""" + +import sys + +# Positive signals: the native ODBC driver LOADED and reached the network / TLS +# / auth stage (or connected). These are the ONLY outcomes that count as PASS. +# All markers are matched case-insensitively. +_DRIVER_LOADED_MARKERS = ( + # The loaded msodbcsql driver brands every diagnostic it emits; a driver + # that failed to load / link / resolve its symbols never gets far enough to + # print this, so it is the strongest single proof of a successful load. + "odbc driver 18 for sql server", + "microsoft][odbc", + # SQL Server network / transport providers -- reached only after load. + "tcp provider", + "named pipes provider", + "shared memory provider", + "sql server network interfaces", + # Connection / login outcomes that prove the handshake was attempted. + "login timeout expired", + "a network-related or instance-specific error", + "server was not found", + "server is not found", + "actively refused", # Windows WSAECONNREFUSED (target port closed) + "connection refused", # posix ECONNREFUSED (target port closed) + "communication link failure", + "unable to establish", + "login failed for user", # authentication stage reached + "cannot open database", # server reached, database validation + # TLS handshake reached -> both the driver and its crypto backend loaded. + "ssl provider", + "ssl security error", + "certificate", +) + + +def driver_loaded(exc): + """FAIL-CLOSED classifier for the connect outcome. + + Returns ``True`` only when there is positive proof the native ODBC driver + loaded: a clean connect (``exc is None``) or a connection-stage diagnostic + that the loaded driver alone can emit. Every other exception -- including the + C++ "Failed to load the driver..." family and anything unrecognized -- + returns ``False`` so the probe exits non-zero. + """ + if exc is None: + return True + msg = str(exc).lower() + return any(marker in msg for marker in _DRIVER_LOADED_MARKERS) + + +def describe(exc): + """Short, human-readable reason string for the probe's stdout / exit line.""" + if exc is None: + return "clean connect" + return str(exc)[:300] + + +def main(): + # Deferred so this module can be imported (and ``driver_loaded`` unit-tested) + # WITHOUT triggering the native ``mssql_python`` import, which needs the + # compiled extension + driver payload. + import mssql_python + + # Unreachable endpoint (nothing listens on TCP port 1) -> the driver loads, + # attempts the socket, and fails fast at the network stage. The loopback:1 is a + # dummy DB-less probe target, never a live endpoint. + conn_str = "Server=127.0.0.1,1;Database=x;Uid=x;Pwd=x;Encrypt=no;TrustServerCertificate=yes;" # DevSkim: ignore DS162092 + outcome = None + try: + conn = mssql_python.connect(conn_str) + # Reaching a real server on 127.0.0.1:1 is not expected, but a successful + # connect still proves the driver loaded. Close it and pass. + try: + conn.close() + except Exception: # noqa: BLE001 - best-effort cleanup only + pass + except Exception as exc: # noqa: BLE001 - deliberately classified below + outcome = exc + + if driver_loaded(outcome): + print("DRIVER_LOADED (" + describe(outcome) + ")") + return + sys.exit("DRIVER DID NOT LOAD / wrong arch: " + describe(outcome)) + + +if __name__ == "__main__": + main() diff --git a/conda/tls_connect_probe.py b/conda/tls_connect_probe.py new file mode 100644 index 000000000..e6f7137b4 --- /dev/null +++ b/conda/tls_connect_probe.py @@ -0,0 +1,275 @@ +"""Live ``Encrypt=yes`` TLS gate: prove the driver's OpenSSL backend is REACHABLE. + +Why this exists (and why ``driver_load_probe.py`` is not enough): the Linux +``libmsodbcsql`` links ``libkrb5``/``libgssapi_krb5`` at load time but resolves +its OpenSSL backend (``libssl``/``libcrypto``) by **dlopen at TLS time** -- there +is no ``libssl``/``libcrypto`` ``DT_NEEDED`` or soname string in the binary, so +the crypto libraries are only touched when an actual encrypted handshake runs. +An ``Encrypt=no`` connect (what ``driver_load_probe.py`` does) NEVER exercises +that path, so it cannot reveal an unreachable OpenSSL -- e.g. a conda env where +the declared ``openssl`` lives in ``/lib`` that the vendored driver's +RUNPATH does not reach. Only a real ``Encrypt=yes`` handshake forces the dlopen. + +FAIL-CLOSED contract: + +* ``Encrypt`` is forced to ``yes`` (mandatory encryption), so the pre-login TLS + handshake MUST complete before any LOGIN7 packet is sent. Therefore ANY outcome + that reaches the authentication / database stage -- a clean connect, a + ``Login failed for user`` (18456), or a ``Cannot open database`` -- is POSITIVE + proof that OpenSSL loaded, negotiated, and established the encrypted channel. + These are the only PASS outcomes (see ``_TLS_COMPLETED_MARKERS``). +* Every other outcome fails closed (non-zero exit). In particular an OpenSSL that + could not be loaded surfaces BEFORE login as an ``SSL Provider`` / + ``libssl``/``libcrypto`` / ``cannot open shared object`` error -- it is not in + ``_TLS_COMPLETED_MARKERS`` so it fails closed, which is exactly the conda RUNPATH + bug this gate is meant to catch. + +IMPORTANT -- masking caveat: this gate is only CONCLUSIVE on a minimal base with +NO system OpenSSL on the default loader path. On a full agent (or any host with a +system ``libssl``) the driver's dlopen can fall through to the system copy and the +handshake succeeds even when the conda ``/lib`` copy is unreachable -- +masking the very bug, just like the hosted CI agents do today. Run it in a +minimal container (no system OpenSSL) against a reachable server to make it +meaningful. The masking-IMMUNE static guard is +``eng/scripts/audit_bundled_binaries.py`` (it reads the RUNPATH bytes and requires +an ``$ORIGIN/..`` climb regardless of what system libs exist); this live gate is +the complementary end-to-end backstop. + +Config: set ``CONDA_TLS_PROBE_CONN`` to a reachable SQL Server connection string +(creds may be wrong -- reaching ``Login failed`` still proves TLS). If it is not +set the gate SKIPS loudly (exit 0) -- it never silently passes. Set +``CONDA_TLS_PROBE_REQUIRED=1`` on the minimal-base leg to make the gate MANDATORY: +a missing OR malformed connection string then FAILS (exit non-zero) instead of +skipping, so a typo (a bare ``yes``) can never silently no-op it. + +Exit code 0 = TLS handshake completed (OpenSSL reachable) OR skipped; non-zero = +OpenSSL backend unreachable / handshake did not complete (blocks publish). +""" + +import os +import sys + +# Outcomes that can ONLY occur AFTER a mandatory (Encrypt=yes) TLS handshake has +# completed -- i.e. positive proof the dlopen'd OpenSSL backend loaded and +# negotiated the encrypted channel. Matched case-insensitively. +_TLS_COMPLETED_MARKERS = ( + "login failed for user", # LOGIN7 rejected -> handshake already done + "cannot open database", # authenticated, database validation stage + "changed database context", # connected successfully + "password did not match", +) + + +def tls_completed(exc): + """FAIL-CLOSED classifier: True only when the TLS handshake provably completed. + + ``exc is None`` (clean connect) or a post-handshake authentication/database + diagnostic returns True; every other outcome -- including an OpenSSL-load + failure or anything unrecognized -- returns False so the gate exits non-zero. + """ + if exc is None: + return True + msg = str(exc).lower() + if any(marker in msg for marker in _TLS_COMPLETED_MARKERS): + return True + # SQLSTATE 28000 (invalid authorization spec) is a locale-independent, post-handshake + # proof of a REJECTED login -- useful when the server's "login failed" text is localized + # and misses the English marker above. We deliberately do NOT also accept a bare native + # error number (e.g. '18456'): a pre-TLS 'Login timeout expired ... 18456' would contain + # both '18456' and 'login' and FALSE-PASS this fail-closed gate. + if "28000" in msg: + return True + return False + + +def describe(exc): + """Short, human-readable reason string for the gate's stdout / exit line.""" + if exc is None: + return "clean connect (TLS handshake completed)" + return str(exc)[:300] + + +def _split_top_level(conn): + """Split an ODBC connection string on TOP-LEVEL ``;`` only. + + An ODBC value wrapped in ``{...}`` may itself contain ``;`` (MS-ODBCSTR), so a + naive ``split(';')`` would shred braced values. Track brace depth and break only + at depth 0. Inside a braced value ``}}`` is an escaped literal ``}`` (MS-ODBCSTR), + NOT a close -- consume both and keep the depth so the value is not split early. + """ + segments = [] + buf = "" + depth = 0 + s = conn.strip() + i = 0 + while i < len(s): + ch = s[i] + if ch == "{": + depth += 1 + buf += ch + elif ch == "}": + if depth > 0 and i + 1 < len(s) and s[i + 1] == "}": + buf += "}}" # escaped literal '}' inside a braced value; not a close + i += 2 + continue + depth = max(0, depth - 1) + buf += ch + elif ch == ";" and depth == 0: + segments.append(buf) + buf = "" + else: + buf += ch + i += 1 + segments.append(buf) + return segments + + +def force_tls(conn): + """Force ``Encrypt=yes`` and ``TrustServerCertificate=yes`` on the string. + + Encrypt=yes makes the pre-login TLS handshake mandatory (the whole point of + the gate). TrustServerCertificate=yes lets it reach the auth stage against a + local dev server's self-signed cert -- this is a local connectivity gate, NOT + a security assertion, and must never be copied into a production connection. + + Rebuild-from-tokens (NOT regex substitution): brace-aware split on top-level + ``;``, DROP any existing Encrypt / TrustServerCertificate segment (case- + insensitive -- including a valueless ``Encrypt`` or a duplicate), then append the + canonical pair exactly once. A regex substitution can corrupt the string -- e.g. + ``Encrypt=;yes`` becomes ``Encrypt=yes;yes``, leaving a bare ``yes`` the parser + rejects with "keyword 'yes' has no value", and a duplicate ``Encrypt`` slips + through as a "Duplicate keyword" error; rebuilding from tokens cannot. + """ + kept = [] + for seg in _split_top_level(conn): + token = seg.strip() + if not token: + continue + key = token.split("=", 1)[0].strip().lower() + if key in ("encrypt", "trustservercertificate"): + continue # drop any existing (incl. valueless / duplicate); re-added below + kept.append(token) + kept.append("Encrypt=yes") + kept.append("TrustServerCertificate=yes") + return ";".join(kept) + + +def _redact(conn): + """Render the connection string's STRUCTURE with every value masked. + + Safe to log: shows the keys (and their order) so a malformed string is + diagnosable, but never a secret value. A segment with no ``=`` -- the exact shape + that trips the parser -- is surfaced verbatim so the failure explains itself. + """ + shown = [] + for seg in _split_top_level(conn): + token = seg.strip() + if not token: + continue + if "=" in token: + shown.append(token.split("=", 1)[0].strip() + "=***") + else: + shown.append("<>") + return ";".join(shown) + + +def _is_probe_connection_string(raw): + """True if ``raw`` looks like an ODBC connection string (has a key=value pair). + + Guards the common misconfiguration of treating CONDA_TLS_PROBE_CONN as a yes/no + toggle: a bare ``yes``/``true``/``1`` has no ``=``, so it cannot be a connection + string and must not be handed to the parser (which would fail on a bare keyword). + """ + return "=" in raw + + +# Recognized boolean spellings for CONDA_TLS_PROBE_REQUIRED (kept in sync with the shell +# truthiness in build-conda-packages.sh so the two never disagree). +_REQUIRED_TRUTHY = ("1", "true", "yes", "on") +_REQUIRED_FALSY = ("", "0", "false", "no", "off") + + +def _required(): + """True when the Encrypt=yes gate is MANDATORY on this leg. + + Set ``CONDA_TLS_PROBE_REQUIRED`` to a truthy value (1/true/yes/on) on the minimal-base + leg that MUST exercise the dlopen'd OpenSSL backend. In required mode a MISSING or + MALFORMED ``CONDA_TLS_PROBE_CONN`` FAILS the leg instead of skipping -- so a typo or an + unset secret can never silently no-op the one gate that actually tests OpenSSL + reachability. An UNRECOGNIZED value (e.g. the typo 'tru') FAILS LOUD rather than + silently disabling the gate. + """ + raw = os.environ.get("CONDA_TLS_PROBE_REQUIRED", "").strip().lower() + if raw in _REQUIRED_TRUTHY: + return True + if raw in _REQUIRED_FALSY: + return False + sys.exit( + f"CONDA_TLS_PROBE_REQUIRED={os.environ.get('CONDA_TLS_PROBE_REQUIRED')!r} is not a " + f"recognized boolean (use 1/true/yes/on or 0/false/no/off). Refusing to guess whether " + f"the mandatory TLS gate is on." + ) + + +def main(): + raw = os.environ.get("CONDA_TLS_PROBE_CONN", "").strip() + required = _required() + if not raw: + if required: + sys.exit( + "TLS_PROBE_REQUIRED_BUT_UNSET: CONDA_TLS_PROBE_REQUIRED is on but " + "CONDA_TLS_PROBE_CONN is empty. The Encrypt=yes OpenSSL-reachability gate is " + "MANDATORY on this leg -- provide a reachable (test/staging, least-privilege) " + "SQL Server connection string." + ) + print( + "TLS_PROBE_SKIPPED: set CONDA_TLS_PROBE_CONN to a reachable SQL Server " + "connection string (on a minimal base with no system OpenSSL) to run " + "this Encrypt=yes gate." + ) + return + + if not _is_probe_connection_string(raw): + # A bare word like "yes"/"true"/"1" is the "I thought it was a yes/no toggle" + # misconfiguration: it is NOT a connection string (no 'key=value' pair). + if required: + sys.exit( + "TLS_PROBE_MISCONFIGURED: CONDA_TLS_PROBE_CONN is set but is not a connection " + "string (no 'key=value' pair) -- a bare 'yes'/'true'/'1' is NOT a yes/no toggle. " + "The gate is MANDATORY on this leg; set it to a reachable SQL Server connection " + "string (Server, user, password)." + ) + # Not required: skip LOUDLY -- the static RUNPATH audit still guards OpenSSL layout. + print( + "TLS_PROBE_SKIPPED: CONDA_TLS_PROBE_CONN is set but is not a connection string " + "(no 'key=value' pair). It is NOT a yes/no toggle -- set it to a reachable SQL " + "Server connection string (with Server, user and password keywords) to run the " + "Encrypt=yes gate, or leave it empty to skip." + ) + return + + conn_str = force_tls(raw) + print("TLS_PROBE using (values redacted): " + _redact(conn_str)) + + # Deferred so this module can be imported (and the classifier unit-tested) + # WITHOUT the compiled extension / driver payload. + import mssql_python + + outcome = None + try: + conn = mssql_python.connect(conn_str) + try: + conn.close() + except Exception: # noqa: BLE001 - best-effort cleanup only + pass + except Exception as exc: # noqa: BLE001 - deliberately classified below + outcome = exc + + if tls_completed(outcome): + print("TLS_OK (OpenSSL backend reachable; " + describe(outcome) + ")") + return + sys.exit("TLS HANDSHAKE DID NOT COMPLETE: " + describe(outcome)) + + +if __name__ == "__main__": + main() diff --git a/eng/scripts/_conda_pkg.py b/eng/scripts/_conda_pkg.py new file mode 100644 index 000000000..085faa76b --- /dev/null +++ b/eng/scripts/_conda_pkg.py @@ -0,0 +1,88 @@ +"""Shared ``.conda`` / ``.tar.bz2`` payload readers for the conda binary-audit scripts. + +``audit_bundled_binaries.py`` (Linux ELF RUNPATH) and ``assert_pe_machine.py`` (Windows +PE machine) both need to (a) zstd-decompress a ``.conda`` member, (b) iterate the package +payload files, and (c) read ``info/index.json``. Keeping that extraction in ONE place stops +the two validators from drifting as they grow (pylint R0801). + +This is a plain sibling module: both scripts are invoked as ``python /