diff --git a/OneBranchPipelines/conda-build-pipeline.yml b/OneBranchPipelines/conda-build-pipeline.yml new file mode 100644 index 000000000..8ad989a29 --- /dev/null +++ b/OneBranchPipelines/conda-build-pipeline.yml @@ -0,0 +1,354 @@ +# ========================================================================================= +# 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 + +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. DELIBERATELY a FIXED set (3.12-3.14), INDEPENDENT + # of the pythonVersions parameter: neither defaults nor conda-forge ships a + # win-arm64 python for 3.10/3.11, so following the param would queue an unbuildable + # 3.10/3.11 arm leg. 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)' + # The live Encrypt=yes TLS gate is NOT run in the build pipeline: it needs a + # reachable SQL Server + a secret connection string (release/integration infra), + # and the masking-immune static RUNPATH audit (audit_bundled_binaries.py) already + # guards the OpenSSL layout here. It moves to the release pipeline, where the + # secret is always present so the gate is unconditional -- no queue-time toggle. + # The minimal-base ldd reachability gate stays opt-in because it is only VALID on + # a curated minimal base (it fails closed on a full agent's system krb5/libltdl). + ${{ if parameters.enableMinimalReachabilityGate }}: + CONDA_ASSERT_PREFIX_REACHABLE: '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. continueOnError=true (best-effort) + # so a QEMU emulation flake can NEVER nuke the native linux-64 publish that SHARES + # this job -- PublishPipelineArtifact below is `condition: succeeded()`, so a failed + # aarch64 leg would otherwise DISCARD the already-validated linux-64 packages. + # Blast-radius isolation, matching win-arm64/osx-arm64. Enforcement does NOT weaken: + # the leg still runs a BLOCKING internal solve + aarch64 import + static RUNPATH + # audit (a broken package is never staged), and linux-aarch64 is in the release + # gate's requiredSubdirs, so a MISSING aarch64 package still blocks the RELEASE. + # 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 exec. + - bash: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y qemu-user-static binfmt-support libc6-arm64-cross + # qemu-user-static + binfmt-support register the aarch64 binfmt_misc handler + # on install (update-binfmts), so host-level qemu-user emulation works WITHOUT + # a `docker run --privileged multiarch/qemu-user-static` (an unpinned public + # image SDL would flag). Verify the handler is present and fail loudly if not, + # rather than silently emulating nothing. + test -e /proc/sys/fs/binfmt_misc/qemu-aarch64 || { echo "ERROR: aarch64 binfmt handler not registered after apt install" >&2; exit 1; } + displayName: 'Install libc6-arm64-cross + register QEMU binfmt (aarch64)' + # continueOnError so a transient apt/binfmt flake fails ONLY the best-effort + # aarch64 leg (which is itself continueOnError), never the shared Linux job -- a + # blocking setup step here would discard the already-validated linux-64 packages + # via the condition:succeeded() publish, contradicting the isolation documented above. + continueOnError: true + - template: /OneBranchPipelines/steps/conda-build-validate-step-posix.yml@self + parameters: + condaSubdir: 'linux-aarch64' + condaTargetSubdir: 'linux-aarch64' + continueOnError: true + 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..0aaf3113b --- /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 -euo pipefail + 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.py b/OneBranchPipelines/scripts/build_conda_packages.py new file mode 100644 index 000000000..00e9fe993 --- /dev/null +++ b/OneBranchPipelines/scripts/build_conda_packages.py @@ -0,0 +1,772 @@ +#!/usr/bin/env python3 +"""One cross-platform orchestrator for the conda build+validate leg. + +Replaces build-conda-packages.ps1 + build-conda-packages.sh (the same 7-step pipeline +written twice, which had already drifted). conda is Python and every agent has a bootstrap +interpreter, so ONE orchestrator runs on every leg; the platform differences (the Miniforge +installer, the win-arm64 Terms-of-Service auto-accept, the Linux-only reachability gate) are +a handful of branches, not a second 360-line script. Running as a NORMAL process also means +the caller reads the exit code directly -- so the PowerShell ErrorActionPreference flips, the +`2>$null` swallows, and the `cmd /c "exit 0"` reset all disappear. + +Pipeline: gather this leg's wheels into a find-links dir -> locate/install Miniforge -> +create a dedicated conda-build env -> build the self-contained mssql-python package (which +VENDORS the ODBC Driver 18 payload) per Python version -> masking-immune RUNPATH/PE arch +audit -> solve a fresh env from the freshly built local channel and import + driver-load + +(opt-in) reachability gate -> stage the packages onto the leg artifact. + +Cross-builds (CONDA_SUBDIR): osx-64 under Rosetta 2, linux-aarch64 under QEMU binfmt, and the +osx-arm64 / win-arm64 legs that cannot execute the target Python on the build host (their +arch is enforced statically by assert_pe_machine.py (Windows) / audit_bundled_binaries.py +(Linux) / the universal2 wheel tag (osx-arm64), and the runtime import auto-skips). +""" + +from __future__ import annotations + +import argparse +import glob +import hashlib +import os +import platform +import re +import shutil +import subprocess +import sys +import urllib.request +from typing import NoReturn + +_MINIFORGE_VERSION = os.environ.get("MINIFORGE_VERSION", "26.3.2-3") +_KNOWN_SUBDIRS = ("win-64", "win-arm64", "osx-64", "osx-arm64", "linux-64", "linux-aarch64") + + +def _log(msg: str) -> None: + print(msg, flush=True) + + +def _die(msg: str) -> NoReturn: + print(f"ERROR: {msg}", file=sys.stderr, flush=True) + sys.exit(1) + + +def run(cmd: list, *, env: dict | None = None, cwd: str | None = None, what: str = "") -> None: + """Run a command, streaming output; raise (exit 1) on a non-zero return -- the + Assert-LastExit / `set -e` equivalent, but the caller just reads our exit code.""" + _log("+ " + " ".join(str(c) for c in cmd)) + rc = subprocess.run(cmd, env=env, cwd=cwd).returncode + if rc != 0: + _die(f"{what or ' '.join(str(c) for c in cmd)} (exit {rc})") + + +def run_ok(cmd: list, *, env: dict | None = None, cwd: str | None = None) -> int: + """Run best-effort: return the exit code instead of dying (the `|| true` equivalent).""" + _log("+ " + " ".join(str(c) for c in cmd)) + return subprocess.run(cmd, env=env, cwd=cwd).returncode + + +def run_capture(cmd: list, *, env: dict | None = None) -> tuple: + """Run and capture (rc, combined-output). Best-effort paths print the output themselves + so a real failure is diagnosable rather than swallowed.""" + p = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + return p.returncode, p.stdout + + +# --------------------------------------------------------------------------- +# 0. Gather THIS leg's wheels into one find-links dir + derive the versions. +# --------------------------------------------------------------------------- +def gather_wheels(mssql_dir: str, mssql_glob: str, odbc_dir: str, odbc_filter: str, links: str): + """Copy this platform's mssql-python wheel(s) (excluding the odbc package, whose filename + also starts with mssql_python) + this platform's odbc wheel into ONE find-links dir. The + dir is CLEARED first so a stale artifact from a reused workdir can never be validated.""" + if os.path.isdir(links): + shutil.rmtree(links) + os.makedirs(links, exist_ok=True) + + mssql = [ + w + for w in glob.glob(os.path.join(mssql_dir, mssql_glob)) + if not os.path.basename(w).startswith("mssql_python_odbc-") + ] + if not mssql: + _die(f"no mssql-python wheel matching '{mssql_glob}' in {mssql_dir}") + for w in mssql: + shutil.copy2(w, links) + + odbc_matches = sorted(glob.glob(os.path.join(odbc_dir, "**", odbc_filter), recursive=True)) + if not odbc_matches: + _die(f"no wheel matching '{odbc_filter}' in {odbc_dir}") + odbc = odbc_matches[0] + shutil.copy2(odbc, links) + + _log("find-links wheels:") + for f in sorted(os.listdir(links)): + _log(f" - {f}") + + # Derive versions from the wheel FILENAMES (single source of truth: the ESRP-signed + # wheels), so the conda package version can NEVER drift from the wheel. + mssql_ver = _wheel_version(os.path.basename(mssql[0]), "mssql_python") + odbc_ver = _wheel_version(os.path.basename(odbc), "mssql_python_odbc") + if not mssql_ver or not odbc_ver: + _die("could not derive versions from the wheel filenames") + _log(f"Derived versions -> mssql-python={mssql_ver} mssql-python-odbc={odbc_ver}") + return mssql_ver, odbc_ver + + +def _wheel_version(name: str, dist: str): + m = re.match(rf"^{re.escape(dist)}-([^-]+)-", name) + return m.group(1) if m else None + + +# --------------------------------------------------------------------------- +# 1. Locate conda, or install a pinned + SHA256-verified Miniforge3 for THIS platform. +# --------------------------------------------------------------------------- +def find_or_install_conda(output_dir: str) -> str: + on_path = shutil.which("conda") + if on_path: + return on_path + + # Reuse an existing Miniforge from a prior run (macOS builds osx-64 AND osx-arm64 on the + # same agent, sharing output_dir; each run is a fresh shell so `which conda` is empty even + # though miniforge/ already exists -- reinstalling into it would fail). + forge = os.path.join(output_dir, "miniforge") + reuse = _conda_exe(forge) + if os.path.exists(reuse): + _log(f"=== reusing existing Miniforge3 at {forge} ===") + return reuse + + _log("=== conda not found on PATH; installing Miniforge3 ===") + is_win = sys.platform == "win32" + if is_win: + installer_name = f"Miniforge3-{_MINIFORGE_VERSION}-Windows-x86_64.exe" + else: + osname = "MacOSX" if sys.platform == "darwin" else "Linux" + arch = platform.machine() + arch = {"aarch64": "aarch64", "arm64": "arm64", "x86_64": "x86_64"}.get(arch, arch) + installer_name = f"Miniforge3-{_MINIFORGE_VERSION}-{osname}-{arch}.sh" + + installer = os.path.join(output_dir, installer_name) + url = ( + "https://github.com/conda-forge/miniforge/releases/download/" + f"{_MINIFORGE_VERSION}/{installer_name}" + ) + # Pin Miniforge to a specific release (never `latest`, which floats) and verify its SHA256 + # BEFORE executing. The expected hash is NOT hard-coded: prefer an explicit + # MINIFORGE_SHA256 pipeline variable (out-of-source, strongest), else the release's own + # published .sha256 sidecar. The installer is never executed unverified. + _log(f"Downloading pinned Miniforge {_MINIFORGE_VERSION}: {url}") + urllib.request.urlretrieve(url, installer) # noqa: S310 - pinned https conda-forge release + expected = os.environ.get("MINIFORGE_SHA256") + if not expected: + sidecar = installer + ".sha256" + urllib.request.urlretrieve(url + ".sha256", sidecar) # noqa: S310 - same pinned release + with open(sidecar, "r", encoding="utf-8") as fh: + m = re.search(r"[0-9a-fA-F]{64}", fh.read()) + expected = m.group(0) if m else None + if not expected: + _die(f"could not determine the expected SHA256 for {installer_name}") + actual = _sha256(installer) + if actual.lower() != expected.lower(): + _die(f"Miniforge installer SHA256 mismatch: expected '{expected}', got '{actual}'") + _log(f"Miniforge installer SHA256 verified ({actual}).") + + if is_win: + # NSIS silent install; /D (target dir) MUST be last and unquoted. + run( + [ + installer, + "/S", + "/InstallationType=JustMe", + "/AddToPath=0", + f"/D={forge}", + ], + what="Miniforge NSIS install", + ) + else: + # -b batch, -u update/reuse an existing target dir (in case a prior run left a partial). + run(["bash", installer, "-b", "-u", "-p", forge], what="Miniforge install") + + conda = _conda_exe(forge) + if not os.path.exists(conda): + _die(f"conda not available at '{conda}' after install attempt") + return conda + + +def _conda_exe(forge: str) -> str: + return ( + os.path.join(forge, "Scripts", "conda.exe") + if sys.platform == "win32" + else os.path.join(forge, "bin", "conda") + ) + + +def _sha256(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +# --------------------------------------------------------------------------- +# 2. A dedicated conda-build env (pinned conda-build<26 + zstandard for the audit). +# --------------------------------------------------------------------------- +def create_builder_env(conda: str) -> str: + """conda-build<26: 26.7.0 crashes in the local packaging phase; the 25.x series is stable. + A DEDICATED env (not `install -n base`) because a hosted runner's base may be pinned to a + python no conda-build<26 supports (e.g. 3.14), making a base install UNSOLVABLE; a fresh + env lets conda pick a supported python. conda-forge only (--override-channels) avoids the + defaults ToS; zstandard rides along so the RUNPATH audit reads .conda payloads here.""" + env_name = "conda_builder" + _log(f"=== creating dedicated conda-build env ({env_name}: conda-build<26) ===") + # Idempotent: a reused agent may already have this env; a pre-existing env makes + # `conda create` fail. Remove first (best-effort). + run_ok([conda, "env", "remove", "-y", "-n", env_name]) + run( + [ + conda, + "create", + "-y", + "-n", + env_name, + "-c", + "conda-forge", + "--override-channels", + "conda-build<26", + "zstandard", + ], + what=f"conda create {env_name}", + ) + return env_name + + +# --------------------------------------------------------------------------- +# 3. Which Python versions to build (auto-detect from the mssql-python wheels, or explicit). +# --------------------------------------------------------------------------- +def detect_pythons(links: str, python_versions: str) -> list: + if python_versions.strip(): + pyvers = [v.strip() for v in python_versions.split(",") if v.strip()] + else: + pyvers = sorted( + { + f"3.{m.group(1)}" + for w in glob.glob(os.path.join(links, "mssql_python-*.whl")) + if "mssql_python_odbc" not in os.path.basename(w) + for m in [re.search(r"-cp3(\d+)-", os.path.basename(w))] + if m + } + ) + if not pyvers: + _die(f"no mssql-python wheels in '{links}' to determine Python versions") + _log(f"Building conda packages for Python versions: {', '.join(pyvers)}") + return pyvers + + +def build_env(mssql_ver: str, odbc_ver: str, links: str, target_subdir: str) -> dict: + """The environment consumed by the recipe (jinja + build.sh/bld.bat) and by conda-build.""" + env = dict(os.environ) + env["WHEELS_DIR"] = links + env["MSSQL_PYTHON_VERSION"] = mssql_ver + env["MSSQL_ODBC_VERSION"] = odbc_ver + if target_subdir: + # conda-build AND the verify `conda create` honor CONDA_SUBDIR -> the packages are + # stamped for the target subdir and the import check runs the target Python where the + # host can execute it (natively / Rosetta 2 / QEMU binfmt). + env["CONDA_SUBDIR"] = target_subdir + _log(f"Cross-targeting conda subdir: CONDA_SUBDIR={target_subdir}") + if target_subdir == "win-arm64": + # win-arm64 deps (python 3.12-3.14, cryptography, vc14_runtime, pyodbc) live on + # Anaconda `defaults`, not conda-forge. Auto-accept the defaults ToS so the + # unattended host-env + verify solves never block on a prompt. + env["CONDA_PLUGINS_AUTO_ACCEPT_TOS"] = "yes" + _log("win-arm64: CONDA_PLUGINS_AUTO_ACCEPT_TOS=yes") + if target_subdir.endswith("aarch64") and os.path.isdir("/usr/aarch64-linux-gnu"): + # Emulated aarch64 verify runs under qemu-user; point it at the aarch64 glibc loader. + env.setdefault("QEMU_LD_PREFIX", "/usr/aarch64-linux-gnu") + _log(f"Set QEMU_LD_PREFIX={env['QEMU_LD_PREFIX']} for emulated aarch64 verify") + return env + + +# --------------------------------------------------------------------------- +# 4. Build + audit. +# --------------------------------------------------------------------------- +def build_packages(conda, builder, recipe_root, pyvers, bld, target_subdir, env): + recipe = os.path.join(recipe_root, "mssql-python") + for py in pyvers: + _log(f"=== [py {py}] build mssql-python (self-contained: vendors the ODBC payload) ===") + cmd = [ + conda, + "run", + "-n", + builder, + "conda-build", + recipe, + "--python", + py, + "--no-test", + "--no-anaconda-upload", + "--output-folder", + bld, + ] + if target_subdir == "win-arm64": + # Add Anaconda defaults ahead of conda-forge for the win-arm64 host-env solve. + cmd += ["-c", "defaults", "-c", "conda-forge"] + run(cmd, env=env, what=f"conda-build mssql-python (py {py})") + + # A local channel is only valid if it ALSO carries noarch/repodata.json (even empty) -- + # conda-build wrote it only for the built subdir. Create it directly (miniforge has no + # `conda index` -- it moved to the standalone conda-index package). + noarch = os.path.join(bld, "noarch") + os.makedirs(noarch, exist_ok=True) + repodata = os.path.join(noarch, "repodata.json") + if not os.path.exists(repodata): + with open(repodata, "w", encoding="ascii") as fh: + fh.write('{"info":{"subdir":"noarch"},"packages":{},"packages.conda":{}}') + + +def audit_packages(conda, builder, recipe_root, bld, target_subdir, env): + eng = os.path.join(os.path.dirname(os.path.abspath(recipe_root)), "eng", "scripts") + audit = os.path.join(eng, "audit_bundled_binaries.py") + if not os.path.isfile(audit): + _die(f"RUNPATH audit script not found at {audit}") + _log("=== RUNPATH self-containment audit (eng/scripts/audit_bundled_binaries.py) ===") + run( + [conda, "run", "-n", builder, "python", audit, "--root", bld], + env=env, + what="RUNPATH self-containment audit", + ) + # win-arm64 is cross-built on x64 where its runtime import is skipped, so its arch is + # trusted from the wheel filename UNLESS the PE machine assert reads it out of the payload. + if target_subdir == "win-arm64": + pe = os.path.join(eng, "assert_pe_machine.py") + if not os.path.isfile(pe): + _die(f"PE machine-type assert script not found at {pe}") + _log("=== win-arm64 PE machine-type assert (vendored .pyd/.dll must be ARM64) ===") + run( + [conda, "run", "-n", builder, "python", pe, "--root", bld, "--subdir", "win-arm64"], + env=env, + what="win-arm64 PE machine-type assert", + ) + + +# --------------------------------------------------------------------------- +# 5. Verify: solve a fresh env from the freshly built local channel and import + probe. +# --------------------------------------------------------------------------- +def make_verify_channel(output_dir: str, bld: str) -> str: + """conda's channel-URL parser STRIPS any path component equal to a known subdir; the + pipeline isolates each leg under a subdir-named dir, so copy the built channel (+ the + noarch stub) into a token-FREE per-leg dir conda parses verbatim.""" + leg = os.path.basename(os.path.normpath(output_dir)) + safe = re.sub(r"[^A-Za-z0-9]", "_", leg) + chan = os.path.join(os.path.dirname(os.path.normpath(output_dir)), f"verifychan_{safe}") + if os.path.isdir(chan): + shutil.rmtree(chan) + shutil.copytree(bld, chan) + _log(f"verify channel (token-free alias of {bld}): {chan}") + return chan + + +def _is_emulated_cross(target_subdir: str) -> bool: + if not target_subdir: + return False + host = platform.machine() + if target_subdir.endswith(("aarch64", "arm64")) and host not in ("aarch64", "arm64"): + _log( + f"NOTE: emulated CROSS leg (CONDA_SUBDIR={target_subdir} on {host}); runtime driver " + f"probes are best-effort under QEMU binfmt, build/audit/import remain blocking." + ) + return True + return False + + +def _import_probe(mod_name: str, ok_label: str) -> str: + """A `python -c` body that imports mod_name and FAIL-CLOSED asserts it loaded from under + sys.prefix (the conda env's own site-packages). os.chdir closes CWD shadowing; this also + catches a stray PYTHONPATH/.pth that could still load the repo source -- proving the + INSTALLED package, not the checkout. Uses abspath (NOT realpath) so conda's softlink install + mode -- where the site-packages entry symlinks into the pkgs/ cache OUTSIDE the prefix -- is + not false-failed: the import PATH stays under the prefix regardless of hard/soft link; only + the symlink TARGET would not. Then prints ok_label + the version.""" + return ( + f"import os,sys,{mod_name} as m;" + "f=os.path.normcase(os.path.abspath(m.__file__));" + "pref=os.path.normcase(os.path.abspath(sys.prefix));" + f"assert f.startswith(pref+os.sep),{mod_name!r}+' loaded from '+m.__file__+" + "', not under the conda env '+sys.prefix+' (stray PYTHONPATH/.pth?)';" + f"print({ok_label!r},m.__version__)" + ) + + +def verify(conda, chan, recipe_root, pyvers, mssql_ver, target_subdir, env, workdir): + """Run the whole verify phase from a NEUTRAL cwd (the per-leg build dir) so a + `python -c "import mssql_python"` binds the conda-INSTALLED package, not the repo source + tree that shadows it when the agent's cwd is the checkout root (for `python -c`, sys.path[0] + is '' = the cwd). This is the Python equivalent of the `cd` the two deleted shell scripts did + before their verify imports; os.chdir (not a per-call cwd=) so EVERY current and future verify + subprocess -- including _reachability_gate's -- inherits it, closing the shadow class.""" + old_cwd = os.getcwd() + os.chdir(workdir) + try: + _verify_impl(conda, chan, recipe_root, pyvers, mssql_ver, target_subdir, env) + finally: + os.chdir(old_cwd) + + +def _verify_impl(conda, chan, recipe_root, pyvers, mssql_ver, target_subdir, env): + emulated = _is_emulated_cross(target_subdir) + is_win = sys.platform == "win32" + for py in pyvers: + sub = (target_subdir or "native").replace("-", "_") + name = f"verify_{sub}_{py.replace('.', '')}" + run_ok([conda, "env", "remove", "-y", "-n", name]) + _log(f"=== [py {py}] create verify env from local channel ===") + + cross_best_effort = target_subdir in ("win-arm64", "osx-arm64") + if target_subdir == "win-arm64": + # BLOCKING solvability gate: --dry-run resolves the FULL win-arm64 graph on x64 + # (no link / post-link / arm64 exec) -- a pure "installable?" check. win-arm64 deps + # span microsoft (noarch azure-identity/msal) + Anaconda defaults, so no + # --strict-channel-priority. Pin the freshly built version so no channel can shadow it. + run( + [ + conda, + "create", + "--dry-run", + "-n", + name, + "-c", + chan, + "-c", + "microsoft", + "-c", + "defaults", + "--override-channels", + f"python={py}", + f"mssql-python={mssql_ver}", + ], + env=env, + what=f"win-arm64 --dry-run solve (py {py})", + ) + rc, out = run_capture( + [ + conda, + "create", + "-y", + "-n", + name, + "-c", + chan, + "-c", + "microsoft", + "-c", + "defaults", + "--override-channels", + f"python={py}", + f"mssql-python={mssql_ver}", + ], + env=env, + ) + if rc != 0: + # Best-effort: only a real arm64 host can create+run it. The dry-run already + # proved solvability and the PE assert + static audit enforce arch, so a + # create failure here can only be infra -- PRINT it (not swallow) and skip. + _log( + f"=== [py {py}] win-arm64: SOLVES (dry-run OK); real env not creatable on " + f"this x64 host -- arch enforced by the PE assert + static audit, skipping " + f"runtime import. ===" + ) + _log(out) + continue + else: + run( + [ + conda, + "create", + "-y", + "-n", + name, + "-c", + chan, + "-c", + "microsoft", + "-c", + "conda-forge", + "--strict-channel-priority", + "--override-channels", + f"python={py}", + f"mssql-python={mssql_ver}", + ], + env=env, + what=f"conda create verify env (py {py})", + ) + + # Can the freshly built package's Python EXECUTE on this host? + rc, out = run_capture([conda, "run", "-n", name, "python", "-c", "import sys"], env=env) + if rc != 0: + if cross_best_effort or (target_subdir == "osx-arm64" and sys.platform == "darwin"): + _log( + f"=== [py {py}] {target_subdir} cross: target Python not executable on this " + f"host; deps SOLVED (blocking), skipping runtime import (arch enforced by " + f"the PE/static audit; osx-arm64 trusted from the universal2 wheel tag). ===" + ) + _log(out) + continue + _die( + f"[py {py}] target Python for CONDA_SUBDIR={target_subdir or 'native'} is not " + f"executable on {sys.platform}/{platform.machine()}, and this is NOT an " + f"arm64 cross-build. Refusing to silently skip validation. Output: {out}" + ) + + _log(f"=== [py {py}] import mssql_python + prove the vendored ODBC payload is present ===") + run( + [ + conda, + "run", + "-n", + name, + "python", + "-c", + _import_probe("mssql_python", "BINDING_OK"), + ], + env=env, + what=f"import mssql_python (py {py})", + ) + run( + [ + conda, + "run", + "-n", + name, + "python", + "-c", + _import_probe("mssql_python_odbc", "ODBC_PAYLOAD_OK"), + ], + env=env, + what=f"import mssql_python_odbc (py {py})", + ) + + _log(f"=== [py {py}] DB-less driver-load proof (real ODBC driver must load) ===") + probe = os.path.join(recipe_root, "driver_load_probe.py") + if emulated: + if run_ok([conda, "run", "-n", name, "python", probe], env=env) != 0: + _log( + "SKIP (emulated cross under QEMU binfmt): qemu-user cannot initialize the " + "native ODBC environment; best-effort on the emulated leg (static RUNPATH " + "audit + native + full-arch-emulation legs validate the driver)." + ) + else: + run( + [conda, "run", "-n", name, "python", probe], + env=env, + what=f"driver-load proof (py {py})", + ) + + if not is_win: + _reachability_gate(conda, name, py, emulated, env) + + _log(f"=== [py {py}] confirm resolved dependencies ===") + rc, out = run_capture([conda, "list", "-n", name], env=env) + for line in out.splitlines(): + if re.search(r"azure-identity|mssql-python|openssl|krb5", line): + _log(line) + + +def _reachability_gate(conda, name, py, emulated, env): + """Linux, opt-in (CONDA_ASSERT_PREFIX_REACHABLE=1): prove the vendored driver binds the + env's OWN $CONDA_PREFIX/lib krb5/gssapi/libltdl via the $ORIGIN climb, not a system copy.""" + if env.get("CONDA_ASSERT_PREFIX_REACHABLE") != "1" or sys.platform != "linux": + return + if emulated: + _log( + f"=== [py {py}] reachability gate SKIPPED on the emulated cross leg (qemu-user " + f"cannot reliably run the aarch64 driver's ldd/env init); the static RUNPATH audit " + f"is the authoritative $ORIGIN-climb guard. ===" + ) + return + _log( + f"=== [py {py}] minimal-base ldd reachability gate (driver MUST bind CONDA_PREFIX/lib) ===" + ) + rc, prefix = run_capture( + [ + conda, + "run", + "-n", + name, + "python", + "-c", + "import os,sys; print(os.environ.get('CONDA_PREFIX') or sys.prefix)", + ], + env=env, + ) + prefix = prefix.strip().splitlines()[-1] if prefix.strip() else "" + # Select the SAME driver variant the loader binds (GetDriverPathCpp probes /etc/*-release); + # a blind glob would grab alphabetically-first 'alpine' (musl) and falsely fail on libltdl. + sel = ( + "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 '')" + ) + rc, drv = run_capture([conda, "run", "-n", name, "python", "-c", sel], env=env) + drv = drv.strip().splitlines()[-1] if drv.strip() else "" + if not drv: + _die(f"[py {py}] no libmsodbcsql driver found in the verify env; cannot prove reachability") + inst = os.path.join(os.path.dirname(drv), "libodbcinst.so.2") + + ldd_all = [] + for lib in (drv, inst): + _log(f"--- ldd {os.path.basename(lib)} ---") + # Clear inherited LD_LIBRARY_PATH so resolution proves the RUNPATH $ORIGIN climb ALONE + # reaches $CONDA_PREFIX/lib -- an ambient LD_LIBRARY_PATH could otherwise mask a bad RUNPATH. + rc, out = run_capture( + [conda, "run", "-n", name, "env", "-u", "LD_LIBRARY_PATH", "ldd", lib], env=env + ) + _log(out) + if rc != 0: + _die(f"[py {py}] ldd failed on {os.path.basename(lib)}; cannot verify reachability") + ldd_all.append(out) + combined = "\n".join(ldd_all) + + reach_fail = False + for want in ("libkrb5.so", "libgssapi_krb5.so", "libltdl.so"): + hits = [ln for ln in combined.splitlines() if want in ln] + if not hits: + print(f"MISS: required '{want}' absent from ldd output.", file=sys.stderr) + reach_fail = True + continue + n_prefix = n_bad = 0 + for ln in hits: + m = re.search(r"=>\s+(\S+)", ln) + resolved = m.group(1) if m else "" + if prefix and resolved.startswith(prefix + os.sep + "lib" + os.sep): + n_prefix += 1 + _log(f"OK {ln.strip()}") + elif resolved == "": + n_bad += 1 + print(f"NOTFOUND {ln.strip()}", file=sys.stderr) + else: + n_bad += 1 + print(f"SYSTEM {ln.strip()}", file=sys.stderr) + if n_bad or n_prefix < 1: + print( + f"ERROR: '{want}' did not resolve cleanly from {prefix}/lib " + f"(prefix={n_prefix}, system/absent={n_bad}).", + file=sys.stderr, + ) + reach_fail = True + if reach_fail: + _die( + f"[py {py}] reachability gate FAILED -- a required krb5/gssapi/libltdl bound to " + f"system or was absent instead of {prefix}/lib" + ) + _log(f"REACHABILITY_OK (krb5 + gssapi_krb5 + libltdl all bound from {prefix}/lib)") + + +# --------------------------------------------------------------------------- +# 6. Stage this leg's packages onto the artifact (metadata-matched subdir). +# --------------------------------------------------------------------------- +def stage(bld: str, stage_dir: str, target_subdir: str): + """Stage ONLY packages whose conda-build output subdir matches THIS leg's target (the + bld// folder name IS the authoritative subdir), so a shared agent (osx-arm64 + + osx-64) never bleeds one leg's packages into the other's artifact.""" + dest_root = os.path.join(stage_dir, target_subdir) + os.makedirs(dest_root, exist_ok=True) + staged = 0 + for ext in ("*.conda", "*.tar.bz2"): + for p in glob.glob(os.path.join(bld, "**", ext), recursive=True): + if not os.path.basename(p).startswith("mssql-python"): + continue + sub = os.path.basename(os.path.dirname(p)) + if sub != target_subdir: + _log(f" skip (subdir '{sub}' != target '{target_subdir}'): {os.path.basename(p)}") + continue + shutil.copy2(p, dest_root) + _log(f" staged {target_subdir}/{os.path.basename(p)}") + staged += 1 + if not staged: + _die(f"no conda packages matching target subdir '{target_subdir}' were produced in {bld}") + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--mssql-wheel-dir", required=True) + ap.add_argument("--mssql-wheel-glob", default="mssql_python-*.whl") + ap.add_argument("--odbc-wheel-dir", required=True) + ap.add_argument("--odbc-wheel-filter", required=True) + ap.add_argument("--recipe-root", required=True) + ap.add_argument("--output-dir", required=True) + ap.add_argument("--stage-dir", required=True) + ap.add_argument("--conda-subdir", required=True, help="This leg's subdir (staging + display).") + ap.add_argument("--conda-target-subdir", default="", help="Cross-target via CONDA_SUBDIR.") + ap.add_argument("--python-versions", default="") + args = ap.parse_args(argv) + + if args.conda_subdir not in _KNOWN_SUBDIRS: + _die(f"--conda-subdir '{args.conda_subdir}' is not a known conda subdir") + if args.conda_target_subdir and args.conda_target_subdir not in _KNOWN_SUBDIRS: + _die(f"--conda-target-subdir '{args.conda_target_subdir}' is not a known conda subdir") + if args.python_versions and not re.fullmatch( + r"\d+\.\d+(,\d+\.\d+)*", args.python_versions.replace(" ", "") + ): + _die(f"--python-versions '{args.python_versions}' must be comma-separated X.Y") + + # verify() os.chdir's to the per-leg build dir, so a RELATIVE --recipe-root would resolve the + # driver_load_probe against the wrong dir. CI passes an absolute path; abspath makes it robust. + args.recipe_root = os.path.abspath(args.recipe_root) + + # The subdir used for CONDA_SUBDIR cross-targeting + staging (target overrides the native). + target = args.conda_target_subdir or args.conda_subdir + + # Per-leg work dir keyed on THIS leg's subdir so two legs on a shared agent (osx-arm64 + + # osx-64) never collide (matches the old scripts' OUT=/). + output_dir = os.path.join(os.path.abspath(args.output_dir), args.conda_subdir) + os.makedirs(output_dir, exist_ok=True) + # Clear a reused bld tree so a stale package from a prior run can never be validated/staged. + bld = os.path.join(output_dir, "bld") + if os.path.isdir(bld): + shutil.rmtree(bld) + os.makedirs(bld, exist_ok=True) + links = os.path.join(output_dir, "wheels") + + _log("==================== conda build inputs ====================") + _log(f"mssqlWheelDir : {args.mssql_wheel_dir}") + _log(f"odbcWheelDir : {args.odbc_wheel_dir}") + _log(f"recipeRoot : {args.recipe_root}") + _log(f"outputDir : {output_dir}") + _log(f"stageDir : {args.stage_dir}") + _log(f"condaSubdir : {args.conda_subdir}") + _log(f"condaTargetSubdir : {args.conda_target_subdir or '(native)'}") + _log(f"pythonVersions : {args.python_versions or '(auto-detect)'}") + _log("============================================================") + + mssql_ver, odbc_ver = gather_wheels( + args.mssql_wheel_dir, + args.mssql_wheel_glob, + args.odbc_wheel_dir, + args.odbc_wheel_filter, + links, + ) + conda = find_or_install_conda(output_dir) + _log(f"Using conda: {conda}") + run([conda, "--version"], what="conda --version") + builder = create_builder_env(conda) + pyvers = detect_pythons(links, args.python_versions) + env = build_env(mssql_ver, odbc_ver, links, args.conda_target_subdir) + + build_packages(conda, builder, args.recipe_root, pyvers, bld, args.conda_target_subdir, env) + audit_packages(conda, builder, args.recipe_root, bld, args.conda_target_subdir, env) + chan = make_verify_channel(output_dir, bld) + verify( + conda, chan, args.recipe_root, pyvers, mssql_ver, args.conda_target_subdir, env, output_dir + ) + stage(bld, args.stage_dir, target) + + _log("CONDA_BUILD_OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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..9099e74d4 --- /dev/null +++ b/OneBranchPipelines/steps/conda-build-validate-step-posix.yml @@ -0,0 +1,100 @@ +# 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 this leg's condaSubdir 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 cross-platform build+validate orchestrator. + - name: scriptPath + type: string + default: '$(Build.SourcesDirectory)/OneBranchPipelines/scripts/build_conda_packages.py' + # 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 + + # One orchestrator (build_conda_packages.py) does gather + build + audit + verify + stage + # for EVERY leg (Windows/macOS/Linux) -- the two build scripts collapsed into one. The two + # caller value-inputs are surfaced via the step-level env: block below (NOT interpolated + # into the command), so they can't inject; the orchestrator validates their format itself. + python3 "${{ parameters.scriptPath }}" \ + --mssql-wheel-dir "${{ parameters.mssqlWheelDir }}" \ + --mssql-wheel-glob "${{ parameters.mssqlWheelGlob }}" \ + --odbc-wheel-dir "${{ parameters.odbcWheelDir }}" \ + --odbc-wheel-filter "${{ parameters.odbcWheelFilter }}" \ + --recipe-root "${{ parameters.recipeRoot }}" \ + --output-dir "${{ parameters.outputDir }}" \ + --stage-dir "$(ob_outputDirectory)/conda" \ + --conda-subdir "${{ parameters.condaSubdir }}" \ + --conda-target-subdir "${CONDA_TARGET_SUBDIR:-}" \ + --python-versions "${PYTHON_VERSIONS:-}" + env: + # Surface the caller's value inputs as env vars (validated in-script above) rather than + # interpolating them into the script text -- repo secret-handling convention. + PYTHON_VERSIONS: ${{ parameters.pythonVersions }} + CONDA_TARGET_SUBDIR: ${{ parameters.condaTargetSubdir }} + 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..84fa698dc --- /dev/null +++ b/OneBranchPipelines/steps/conda-build-validate-step.yml @@ -0,0 +1,111 @@ +# 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(s) to build, comma-separated X.Y (e.g. '3.13' or '3.12,3.13,3.14'); passed + # straight to the orchestrator's --python-versions. win-64 follows the pythonVersions + # parameter; win-arm64 uses a fixed 3.12-3.14 set (the singular name is kept for callers). + - name: pythonVersion + type: string + # Conda subdir for this leg's package metadata + staging: 'win-64' (native) or 'win-arm64' + # (CROSS-built on this x64 agent via condaTargetSubdir). Need NOT match the host arch. + - 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 cross-platform build+validate orchestrator (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.py' + # 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' + + # One orchestrator (build_conda_packages.py) does gather + build + audit + verify + stage + # for EVERY leg -- the two build scripts collapsed into one. The caller value-inputs come + # via the step env: block (NOT interpolated into the command); the orchestrator validates + # them itself. Run under the agent's bootstrap `python` (the orchestrator installs Miniforge). + # --conda-target-subdir / --python-versions use the `--flag=value` form ON PURPOSE: Windows + # PowerShell 5.1 DROPS an empty "" argument to a native command, so on a native leg (win-64, + # empty target-subdir) the space form `--conda-target-subdir ""` would vanish and argparse + # would fail with "expected one argument". The `=value` form stays one token even when empty. + python "${{ parameters.scriptPath }}" ` + --mssql-wheel-dir "${{ parameters.mssqlWheelDir }}" ` + --odbc-wheel-dir "${{ parameters.odbcWheelDir }}" ` + --odbc-wheel-filter "${{ parameters.odbcWheelFilter }}" ` + --recipe-root "${{ parameters.recipeRoot }}" ` + --output-dir "${{ parameters.outputDir }}" ` + --stage-dir "$(ob_outputDirectory)/conda" ` + --conda-subdir "${{ parameters.condaSubdir }}" ` + --conda-target-subdir="$env:CONDA_TARGET_SUBDIR" ` + --python-versions="$env:PYTHON_VERSION" + if ($LASTEXITCODE -ne 0) { Write-Error "conda build+validate failed (exit $LASTEXITCODE)"; exit 1 } + env: + # Surface the caller's value inputs as env vars (validated in-script above) rather than + # interpolating them into the script text -- repo secret-handling convention. + PYTHON_VERSION: ${{ parameters.pythonVersion }} + CONDA_TARGET_SUBDIR: ${{ parameters.condaTargetSubdir }} + 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/eng/scripts/_conda_pkg.py b/eng/scripts/_conda_pkg.py new file mode 100644 index 000000000..7d3cb59a1 --- /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 /