diff --git a/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml b/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml new file mode 100644 index 00000000..6a6712a6 --- /dev/null +++ b/eng/pipelines/mssql-odbc-daily-validation-pipeline.yml @@ -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 + 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) \ No newline at end of file diff --git a/eng/scripts/run-mssql-odbc-tests.sh b/eng/scripts/run-mssql-odbc-tests.sh new file mode 100644 index 00000000..f59d6b4a --- /dev/null +++ b/eng/scripts/run-mssql-odbc-tests.sh @@ -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/&/\&/g' -e 's//\>/g' -e 's/"/\"/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 + else + cat > "$report" < + + + + The pytest process terminated before producing a report. + + + +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}" + +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 +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 diff --git a/eng/scripts/verify_mssql_odbc_provider.py b/eng/scripts/verify_mssql_odbc_provider.py new file mode 100644 index 00000000..f2d4ff0a --- /dev/null +++ b/eng/scripts/verify_mssql_odbc_provider.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from pathlib import Path + +import mssql_python + + +def require(condition, message, details): + if not condition: + raise RuntimeError(f"{message}: {details!r}") + + +def main(): + info = mssql_python.get_native_provider_info() + require(info.get("id") == "mssql-odbc", "Unexpected native provider", info) + require(info.get("package") == "mssql_py_core", "Unexpected provider package", info) + require(info.get("source") == "environment", "Unexpected provider source", info) + driver_path_value = info.get("driver_path") + require(driver_path_value, "Provider did not return a driver path", info) + driver_path = Path(driver_path_value) + require("mssqlodbc" in driver_path.name.lower(), "Unexpected driver filename", info) + require(driver_path.is_file(), "Driver path does not exist", info) + + with mssql_python.connect(os.environ["DB_CONNECTION_STRING"]) as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + row = cursor.fetchone() + require( + row is not None and row[0] == 1, "Provider query returned an unexpected row", row + ) + + loaded_info = mssql_python.get_native_provider_info() + require(loaded_info.get("id") == "mssql-odbc", "Loaded provider changed", loaded_info) + require(loaded_info.get("frozen") is True, "Loaded provider is not frozen", loaded_info) + print("MSSQL_ODBC_PREFLIGHT_OK", loaded_info, flush=True) + + +if __name__ == "__main__": + main() diff --git a/eng/versions/mssql-python-rs-nuget.version b/eng/versions/mssql-python-rs-nuget.version index eb603bd5..6c6aa7cb 100644 --- a/eng/versions/mssql-python-rs-nuget.version +++ b/eng/versions/mssql-python-rs-nuget.version @@ -1 +1 @@ -0.1.0-dev.20260914.174990 \ No newline at end of file +0.1.0 \ No newline at end of file diff --git a/tests/test_038_mssql_odbc_daily_validation.py b/tests/test_038_mssql_odbc_daily_validation.py new file mode 100644 index 00000000..b4a22b35 --- /dev/null +++ b/tests/test_038_mssql_odbc_daily_validation.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from xml.etree import ElementTree + +ROOT = Path(__file__).parents[1] +RUNNER = ROOT / "eng" / "scripts" / "run-mssql-odbc-tests.sh" +PIPELINE = ROOT / "eng" / "pipelines" / "mssql-odbc-daily-validation-pipeline.yml" +PREFLIGHT = ROOT / "eng" / "scripts" / "verify_mssql_odbc_provider.py" + + +@unittest.skipUnless(sys.platform.startswith("linux"), "runner requires Linux GNU timeout and bash") +class RunnerTests(unittest.TestCase): + def run_runner(self, behavior: str, total_budget: str = "60s", provider: str = "mssql-odbc"): + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + (work / "tests").mkdir() + (work / "tests" / "test_sample.py").write_text("pass\n", encoding="utf-8") + fake_bin = work / "bin" + fake_bin.mkdir() + fake_python = fake_bin / "python" + fake_python.write_text( + "#!/bin/sh\n" + f"real_python={sys.executable!r}\n" + 'if [ "$1" = "-" ]; then exec "$real_python" "$@"; fi\n' + "case \"$*\" in *'--version'*) exit 0 ;; esac\n" + "report=\n" + 'for arg in "$@"; do\n' + ' case "$arg" in --junitxml=*) report=${arg#--junitxml=} ;; esac\n' + "done\n" + f"case {behavior!r} in\n" + ' success) printf \'%s\\n\' \'\' > "$report"; exit 0 ;;\n' + " crash) printf '%s\\n' '' > \"$report\"; exit 139 ;;\n" + " missing) exit 0 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_python.chmod(0o755) + results = work / "results" + env = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ.get('PATH', '')}", + "TEST_RESULTS_DIR": str(results), + "PYTEST_FILE_TIMEOUT": "30s", + "PYTEST_TOTAL_BUDGET": total_budget, + "MSSQL_PYTHON_NATIVE_PROVIDER": provider, + } + proc = subprocess.run( + ["bash", str(RUNNER)], + cwd=work, + env=env, + capture_output=True, + text=True, + check=False, + ) + status = (results / "runner.status").read_text(encoding="utf-8").strip() + reports = { + path.name: ElementTree.parse(path).getroot() for path in results.glob("*.xml") + } + return proc, status, reports + + def test_success_writes_authenticated_status(self): + proc, status, reports = self.run_runner("success") + + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(status, "success") + self.assertEqual(set(reports), {"results-test_sample.xml"}) + + def test_wrong_provider_is_blocking_harness_failure(self): + proc, status, reports = self.run_runner("success", provider="msodbcsql18") + + self.assertEqual(proc.returncode, 2, proc.stderr) + self.assertEqual(status, "harness") + self.assertEqual(reports, {}) + + def test_budget_exhaustion_is_advisory_not_success(self): + proc, status, reports = self.run_runner("success", total_budget="1s") + + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertEqual(status, "advisory") + self.assertEqual( + reports["results-test_sample.xml"].find(".//error").attrib["type"], "ProcessTerminated" + ) + + def test_crash_replaces_malformed_report_and_adds_process_result(self): + proc, status, reports = self.run_runner("crash") + + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertEqual(status, "advisory") + self.assertEqual( + set(reports), + {"results-test_sample.xml", "results-test_sample-process.xml"}, + ) + + def test_success_without_junit_is_blocking_harness_failure(self): + proc, status, reports = self.run_runner("missing") + + self.assertEqual(proc.returncode, 2, proc.stderr) + self.assertEqual(status, "harness") + self.assertEqual(reports, {}) + + +class PipelineContractTests(unittest.TestCase): + def test_runner_uses_busybox_compatible_timeout_options(self): + runner = RUNNER.read_text(encoding="utf-8") + + self.assertIn('timeout -k "${KILL_GRACE_SECONDS}s"', runner) + self.assertNotIn("--kill-after", runner) + + def test_optimized_preflight_rejects_wrong_provider(self): + with tempfile.TemporaryDirectory() as directory: + fake_module = Path(directory) / "mssql_python.py" + fake_module.write_text( + "def get_native_provider_info():\n" + " return {'id': 'msodbcsql18', 'package': 'wrong', 'source': 'default'}\n", + encoding="utf-8", + ) + env = {**os.environ, "PYTHONPATH": directory, "DB_CONNECTION_STRING": "unused"} + + proc = subprocess.run( + [sys.executable, "-O", str(PREFLIGHT)], + env=env, + capture_output=True, + text=True, + check=False, + ) + + self.assertNotEqual(proc.returncode, 0) + self.assertNotIn("MSSQL_ODBC_PREFLIGHT_OK", proc.stdout) + + def test_pipeline_authenticates_advisory_status_and_keeps_publish_strict(self): + pipeline = PIPELINE.read_text(encoding="utf-8") + + self.assertIn('if [ "$status" != advisory ]', pipeline) + self.assertIn('if [ "$status" != success ]', pipeline) + self.assertNotIn("continueOnError: true", pipeline) + self.assertIn('exit "$cleanup_rc"', pipeline) + self.assertIn("grep -q 'MSSQL_ODBC_PREFLIGHT_OK'", pipeline) + + def test_stable_rs_transport_is_pinned(self): + version = (ROOT / "eng" / "versions" / "mssql-python-rs-nuget.version").read_text( + encoding="ascii" + ) + + self.assertEqual(version.strip(), "0.1.0") + + +if __name__ == "__main__": + unittest.main()