Skip to content
185 changes: 185 additions & 0 deletions eng/pipelines/mssql-odbc-daily-validation-pipeline.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
name: mssql-odbc-daily-$(Date:yyyyMMdd)$(Rev:.r)

trigger: none
pr: none

schedules:
- cron: '0 4 * * *'
displayName: 'Daily mssql-odbc validation'
branches:
include:
- main
always: true

jobs:
- job: PytestOnMssqlOdbc
displayName: 'Complete mssql-python suite on mssql-odbc'
# Headroom over the serial step budgets (SQL setup 22m + pytest 130m + cleanup 3m)
# plus unbudgeted deps-install/build/install-core/verify/publish, so the always()
# publish and cleanup steps still run in the worst case.
timeoutInMinutes: 240
pool:
vmImage: 'ubuntu-latest'

variables:
testContainer: 'mssql-python-rust-odbc-$(Build.BuildId)'
sqlContainer: 'sqlserver-rust-odbc-$(Build.BuildId)'

steps:
- checkout: self
clean: true
fetchDepth: 1
displayName: 'Checkout latest GitHub main'

- script: |
docker run -d --name $(testContainer) \
-v $(Build.SourcesDirectory):/workspace \
-w /workspace \
--network bridge \
ubuntu:24.04 \
tail -f /dev/null
displayName: 'Create mssql-odbc test container'

- script: |
python3 eng/scripts/setup_sql_container.py \
--name "$(sqlContainer)" --owner "$(Build.BuildId).$(System.JobId)" \
--image "mcr.microsoft.com/mssql/server:2022-latest" --database TestDB
displayName: 'Start SQL Server for mssql-odbc tests'
timeoutInMinutes: 22
env:
DB_PASSWORD: $(DB_PASSWORD)

- script: |
docker exec $(testContainer) bash -c '
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive TZ=UTC
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
echo $TZ > /etc/timezone
apt-get update
apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv python3-full python3-dev \
cmake build-essential pybind11-dev unixodbc-dev curl ca-certificates \
libssl3 libgssapi-krb5-2 libkrb5-3
Comment thread
gargsaumya marked this conversation as resolved.
python3 -m venv /opt/venv
source /opt/venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
'
displayName: 'Install mssql-odbc test dependencies'

- script: |
docker exec $(testContainer) bash -c '
set -euo pipefail
source /opt/venv/bin/activate
cd mssql_python/pybind
chmod +x build.sh
./build.sh
'
displayName: 'Build mssql-python native bindings'

- template: steps/install-mssql-py-core.yml
parameters:
platform: container
containerName: $(testContainer)
venvActivate: 'source /opt/venv/bin/activate'
displaySuffix: ' for mssql-odbc tests'

- script: |
set -uo pipefail
SQLSERVER_IP=$(docker inspect $(sqlContainer) --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
export DB_CONNECTION_STRING="Server=$SQLSERVER_IP;Database=TestDB;Uid=SA;Pwd=$DB_PASSWORD;TrustServerCertificate=yes"
rc=0
output=$(docker exec \
-e MSSQL_PYTHON_NATIVE_PROVIDER=mssql-odbc \
-e DB_CONNECTION_STRING \
-e PYTHONPATH=/workspace \
$(testContainer) bash -c '
set -euo pipefail
source /opt/venv/bin/activate
python eng/scripts/verify_mssql_odbc_provider.py
' 2>&1) || rc=$?
printf '%s\n' "$output"
if ! grep -q 'MSSQL_ODBC_PREFLIGHT_OK' <<< "$output"; then
echo "##[error]mssql-odbc provider preflight failed (exit $rc)."
exit 1
fi
displayName: 'Verify packaged mssql-odbc selection, load, and query'
env:
DB_PASSWORD: $(DB_PASSWORD)

- script: |
set -uo pipefail
SQLSERVER_IP=$(docker inspect $(sqlContainer) --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
if [ -z "$SQLSERVER_IP" ]; then
echo "##[error]Could not resolve SQL Server container IP; SQL Server is unreachable (infrastructure failure)."
exit 1
fi
export DB_CONNECTION_STRING="Server=$SQLSERVER_IP;Database=TestDB;Uid=SA;Pwd=$DB_PASSWORD;TrustServerCertificate=yes"
status_file="$(Build.SourcesDirectory)/test-results/mssql-odbc/runner.status"
rm -f "$status_file"
rc=0
docker exec \
-e MSSQL_PYTHON_NATIVE_PROVIDER=mssql-odbc \
-e DB_CONNECTION_STRING \
-e TEST_RESULTS_DIR=/workspace/test-results/mssql-odbc \
-e TEST_STATUS_FILE=/workspace/test-results/mssql-odbc/runner.status \
-e PYTEST_FILE_TIMEOUT=10m \
-e PYTEST_TOTAL_BUDGET=110m \
-e PYTHONPATH=/workspace \
$(testContainer) bash -c '
source /opt/venv/bin/activate
chmod +x eng/scripts/run-mssql-odbc-tests.sh
eng/scripts/run-mssql-odbc-tests.sh
' || rc=$?

status=$(cat "$status_file" 2>/dev/null || true)
case "$rc" in
0)
if [ "$status" != success ]; then
echo "##vso[task.logissue type=error]The pytest process returned success without a valid runner status."
exit 1
fi
echo 'The complete pytest suite passed with mssql-odbc.'
;;
1)
if [ "$status" != advisory ]; then
echo '##vso[task.logissue type=error]The test container failed before the pytest runner reported an advisory result.'
exit 1
fi
echo '##vso[task.logissue type=warning]The complete pytest suite has expected compatibility failures with mssql-odbc; see published test results.'
echo '##vso[task.complete result=SucceededWithIssues;]'
;;
*)
echo "##vso[task.logissue type=error]The mssql-odbc pytest harness failed (exit $rc)."
exit 1
;;
esac
exit 0
displayName: 'Run complete pytest suite with mssql-odbc (advisory)'
timeoutInMinutes: 130
env:
DB_PASSWORD: $(DB_PASSWORD)

- task: PublishTestResults@2
displayName: 'Publish mssql-odbc compatibility results'
condition: always()
inputs:
testResultsFormat: JUnit
testResultsFiles: 'test-results/mssql-odbc/*.xml'
searchFolder: '$(Build.SourcesDirectory)'
testRunTitle: 'mssql-python complete suite on mssql-odbc'
mergeTestResults: true
failTaskOnFailedTests: false
publishRunAttachments: true

- script: |
cleanup_rc=0
python3 eng/scripts/setup_sql_container.py --cleanup \
--name "$(sqlContainer)" --owner "$(Build.BuildId).$(System.JobId)" || cleanup_rc=$?
docker rm -f $(testContainer) || true
exit "$cleanup_rc"
displayName: 'Clean up mssql-odbc test containers'
condition: always()
timeoutInMinutes: 3
env:
DB_PASSWORD: $(DB_PASSWORD)
199 changes: 199 additions & 0 deletions eng/scripts/run-mssql-odbc-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/bin/bash
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

# Run every mssql-python test file in an isolated process with mssql-odbc
# selected. Functional failures are advisory, but crashes and timeouts still
# produce JUnit entries so the pipeline reports the complete result.

set -uo pipefail

RESULTS_DIR="${TEST_RESULTS_DIR:-test-results/mssql-odbc}"
FILE_TIMEOUT="${PYTEST_FILE_TIMEOUT:-10m}"
TOTAL_BUDGET="${PYTEST_TOTAL_BUDGET:-110m}"
STATUS_FILE="${TEST_STATUS_FILE:-$RESULTS_DIR/runner.status}"
KILL_GRACE_SECONDS=10

if ! mkdir -p "$RESULTS_DIR"; then
echo "##[error]Could not create test results directory: $RESULTS_DIR"
exit 2
fi
rm -f "$RESULTS_DIR"/*.xml
rm -f "$STATUS_FILE"

finish() {
local code="$1" status="$2"
printf '%s\n' "$status" > "$STATUS_FILE" || exit 2
exit "$code"
}

on_exit() {
local code=$?
if [ ! -s "$STATUS_FILE" ]; then
printf 'harness\n' > "$STATUS_FILE" 2>/dev/null || true
fi
return "$code"
}
trap on_exit EXIT

if [ "${MSSQL_PYTHON_NATIVE_PROVIDER:-}" != "mssql-odbc" ]; then
echo "##[error]MSSQL_PYTHON_NATIVE_PROVIDER must be set to mssql-odbc"
finish 2 harness
fi

xml_escape() {
printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'
}

write_stub() {
local name="$1" kind="$2" reason="$3" report="$4"
local safe_name safe_reason
safe_name="$(xml_escape "$name")"
safe_reason="$(xml_escape "$reason")"
if [ "$kind" = "skipped" ]; then
cat > "$report" <<XML
<?xml version="1.0" encoding="utf-8"?>
<testsuites>
<testsuite name="$safe_name" tests="1" errors="0" failures="0" skipped="1" time="0">
<testcase classname="mssql_odbc.$safe_name" name="pytest_process" time="0">
<skipped type="pytest.skip" message="$safe_reason"/>
</testcase>
</testsuite>
</testsuites>
XML
else
cat > "$report" <<XML
<?xml version="1.0" encoding="utf-8"?>
<testsuites>
<testsuite name="$safe_name" tests="1" errors="1" failures="0" skipped="0" time="0">
<testcase classname="mssql_odbc.$safe_name" name="pytest_process" time="0">
<error type="ProcessTerminated" message="$safe_reason">The pytest process terminated before producing a report.</error>
</testcase>
</testsuite>
</testsuites>
XML
fi
}

to_seconds() {
local value="$1" number="${1%[smh]}"
case "$value" in
*h) echo $((number * 3600)) ;;
*m) echo $((number * 60)) ;;
*s) echo "$number" ;;
*) echo "$value" ;;
esac
}

report_is_valid() {
python - "$1" <<'PY'
import sys
from xml.etree import ElementTree

ElementTree.parse(sys.argv[1])
PY
}

mapfile -t TEST_FILES < <(find tests -name 'test_*.py' -type f | sort)
if [ "${#TEST_FILES[@]}" -eq 0 ] || ! python -m pytest --version >/dev/null 2>&1; then
echo "##[error]The pytest harness is not usable"
finish 2 harness
fi

FILE_BUDGET_SECONDS="$(to_seconds "$FILE_TIMEOUT")"
TOTAL_BUDGET_SECONDS="$(to_seconds "$TOTAL_BUDGET")"
passed=0
failed=0
crashed=0
timed_out=0
skipped=0

echo "Running ${#TEST_FILES[@]} test files with MSSQL_PYTHON_NATIVE_PROVIDER=${MSSQL_PYTHON_NATIVE_PROVIDER:-unset}"

Comment thread
gargsaumya marked this conversation as resolved.
for index in "${!TEST_FILES[@]}"; do
test_file="${TEST_FILES[$index]}"
name="${test_file#tests/}"
name="${name%.py}"
name="${name//\//_}"
report="$RESULTS_DIR/results-$name.xml"
remaining=$((TOTAL_BUDGET_SECONDS - SECONDS))

if [ "$remaining" -le "$KILL_GRACE_SECONDS" ]; then
remaining_files=$((${#TEST_FILES[@]} - index))
for rest_index in $(seq "$index" $((${#TEST_FILES[@]} - 1))); do
rest_file="${TEST_FILES[$rest_index]}"
rest_name="${rest_file#tests/}"
rest_name="${rest_name%.py}"
rest_name="${rest_name//\//_}"
write_stub "$rest_name" error "Total test budget of $TOTAL_BUDGET exhausted before this file ran" \
"$RESULTS_DIR/results-$rest_name.xml"
done
timed_out=$((timed_out + remaining_files))
break
fi

slice="$FILE_BUDGET_SECONDS"
if [ "$slice" -gt "$((remaining - KILL_GRACE_SECONDS))" ]; then
slice=$((remaining - KILL_GRACE_SECONDS))
fi

echo "##[group]$test_file"
timeout -k "${KILL_GRACE_SECONDS}s" "${slice}s" \
python -m pytest "$test_file" -v --junitxml="$report" \
--capture=tee-sys --cache-clear
rc=$?
echo "##[endgroup]"

case "$rc" in
0)
passed=$((passed + 1))
;;
1)
failed=$((failed + 1))
;;
2|3|4)
echo "##[error]The pytest harness failed on $test_file (exit $rc)"
finish 2 harness
;;
5)
write_stub "$name" skipped "No tests collected" "$report"
skipped=$((skipped + 1))
;;
124|137)
timed_out=$((timed_out + 1))
;;
125|126|127)
echo "##[error]The pytest harness could not execute $test_file (exit $rc)"
finish 2 harness
;;
*)
crashed=$((crashed + 1))
;;
esac

if [ ! -s "$report" ] || ! report_is_valid "$report" >/dev/null 2>&1; then
if [ "$rc" -eq 0 ]; then
echo "##[error]Pytest reported success for $test_file but produced no valid JUnit results (harness failure)"
finish 2 harness
fi
write_stub "$name" error "Pytest exited $rc without producing valid JUnit" "$report"
fi
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ] || [ "$rc" -gt 127 ]; then
write_stub "${name}_process" error "Pytest process exited $rc" \
"$RESULTS_DIR/results-$name-process.xml"
fi
Comment thread
Copilot marked this conversation as resolved.
done

echo "files: ${#TEST_FILES[@]} | passed: $passed | failed: $failed | crashed: $crashed | timed out: $timed_out | skipped: $skipped"

if [ "$((passed + failed + crashed + timed_out))" -eq 0 ]; then
echo "##[error]No test file executed any tests"
finish 2 harness
fi

# Intentional no-test files (pytest exit 5, e.g. stress files excluded by the
# "not stress" marker) count as skipped and must not mark the run SucceededWithIssues.
if [ "$failed" -gt 0 ] || [ "$crashed" -gt 0 ] || [ "$timed_out" -gt 0 ]; then
finish 1 advisory
fi
finish 0 success
Loading
Loading