Skip to content

Commit 470e2bf

Browse files
authored
Merge branch 'main' into saumya/stress-tests-gh
2 parents 51d6f81 + 4b28f34 commit 470e2bf

File tree

10 files changed

+177
-56
lines changed

10 files changed

+177
-56
lines changed

OneBranchPipelines/build-release-package-pipeline.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ extends:
201201
parameters:
202202
# Pool Configuration
203203
# Different platforms use different agent pools:
204-
# - Windows: Custom 1ES pool (Django-1ES-pool) with WIN22-SQL22 image (Windows Server 2022 + SQL Server 2022)
205-
# - Linux: Custom 1ES pool (Django-1ES-pool) with ADO-UB22-SQL22 image (Ubuntu 22.04 + SQL Server 2022)
204+
# - Windows: Custom 1ES pool (Python-1ES-pool) with PYTHON-1ES-MMS2022 image (Windows Server 2022 + SQL Server 2022)
205+
# - Linux: Custom 1ES pool (Python-1ES-pool) with PYTHON-1ES-UB2404 image (Ubuntu 24.04 + SQL Server 2022)
206206
# - macOS: Microsoft-hosted pool (Azure Pipelines) with macOS-14 image (macOS Sonoma)
207207
# Note: Container definitions section present but unused (pools configured in individual stage templates)
208208

OneBranchPipelines/dummy-release-pipeline.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ extends:
9696
pool:
9797
type: windows
9898
isCustom: true
99-
name: Django-1ES-pool
99+
name: Python-1ES-pool
100100
demands:
101-
- imageOverride -equals WIN22-SQL22
101+
- imageOverride -equals PYTHON-1ES-MMS2022
102102

103103
variables:
104104
ob_outputDirectory: '$(Build.ArtifactStagingDirectory)'

OneBranchPipelines/official-release-pipeline.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ extends:
9999
pool:
100100
type: windows
101101
isCustom: true
102-
name: Django-1ES-pool
102+
name: Python-1ES-pool
103103
demands:
104-
- imageOverride -equals WIN22-SQL22
104+
- imageOverride -equals PYTHON-1ES-MMS2022
105105

106106
variables:
107107
ob_outputDirectory: '$(Build.ArtifactStagingDirectory)'

OneBranchPipelines/stages/build-linux-single-stage.yml

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ stages:
3535
pool:
3636
type: linux
3737
isCustom: true
38-
name: Django-1ES-pool
38+
name: Python-1ES-pool
3939
demands:
40-
- imageOverride -equals ADO-UB22-SQL22
40+
- imageOverride -equals PYTHON-1ES-UB2404
4141
# Extended timeout for multi-version builds + testing (5 Python versions × build + test time)
4242
timeoutInMinutes: 120
4343

@@ -65,12 +65,8 @@ stages:
6565
- checkout: self
6666
fetchDepth: 0
6767

68-
# Install Docker
69-
- task: DockerInstaller@0
70-
inputs:
71-
dockerVersion: '20.10.21'
72-
displayName: 'Install Docker'
73-
68+
# Docker is pre-installed now
69+
# Verify it's working and start the daemon
7470
- bash: |
7571
set -e
7672
echo "Verifying we're on Linux..."
@@ -82,9 +78,14 @@ stages:
8278
8379
uname -a
8480
85-
# Start dockerd
86-
sudo dockerd > docker.log 2>&1 &
87-
sleep 10
81+
# Check if Docker daemon is already running
82+
if ! docker info > /dev/null 2>&1; then
83+
echo "Docker daemon not running, starting it..."
84+
sudo dockerd > docker.log 2>&1 &
85+
sleep 10
86+
else
87+
echo "Docker daemon already running"
88+
fi
8889
8990
# Verify Docker works
9091
docker --version

OneBranchPipelines/stages/build-windows-single-stage.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ stages:
3838
pool:
3939
type: windows
4040
isCustom: true
41-
name: Django-1ES-pool
41+
name: Python-1ES-pool
4242
demands:
43-
- imageOverride -equals WIN22-SQL22
43+
- imageOverride -equals PYTHON-1ES-MMS2022
4444
# Extended timeout for downloads, builds, and testing
4545
timeoutInMinutes: 120
4646

mssql_python/auth.py

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Tuple, Dict, Optional, List
1010

1111
from mssql_python.logging import logger
12-
from mssql_python.constants import AuthType
12+
from mssql_python.constants import AuthType, ConstantsDDBC
1313

1414

1515
class AADAuth:
@@ -30,7 +30,25 @@ def get_token_struct(token: str) -> bytes:
3030

3131
@staticmethod
3232
def get_token(auth_type: str) -> bytes:
33-
"""Get token using the specified authentication type"""
33+
"""Get DDBC token struct for the specified authentication type."""
34+
token_struct, _ = AADAuth._acquire_token(auth_type)
35+
return token_struct
36+
37+
@staticmethod
38+
def get_raw_token(auth_type: str) -> str:
39+
"""Acquire a fresh raw JWT for the mssql-py-core connection (bulk copy).
40+
41+
This deliberately does NOT cache the credential or token — each call
42+
creates a new Azure Identity credential instance and requests a token.
43+
A fresh acquisition avoids expired-token errors when bulkcopy() is
44+
called long after the original DDBC connect().
45+
"""
46+
_, raw_token = AADAuth._acquire_token(auth_type)
47+
return raw_token
48+
49+
@staticmethod
50+
def _acquire_token(auth_type: str) -> Tuple[bytes, str]:
51+
"""Internal: acquire token and return (ddbc_struct, raw_jwt)."""
3452
# Import Azure libraries inside method to support test mocking
3553
# pylint: disable=import-outside-toplevel
3654
try:
@@ -53,30 +71,27 @@ def get_token(auth_type: str) -> bytes:
5371
"interactive": InteractiveBrowserCredential,
5472
}
5573

56-
credential_class = credential_map[auth_type]
74+
credential_class = credential_map.get(auth_type)
75+
if not credential_class:
76+
raise ValueError(
77+
f"Unsupported auth_type '{auth_type}'. " f"Supported: {', '.join(credential_map)}"
78+
)
5779
logger.info(
5880
"get_token: Starting Azure AD authentication - auth_type=%s, credential_class=%s",
5981
auth_type,
6082
credential_class.__name__,
6183
)
6284

6385
try:
64-
logger.debug(
65-
"get_token: Creating credential instance - credential_class=%s",
66-
credential_class.__name__,
67-
)
6886
credential = credential_class()
69-
logger.debug(
70-
"get_token: Requesting token from Azure AD - scope=https://database.windows.net/.default"
71-
)
72-
token = credential.get_token("https://database.windows.net/.default").token
87+
raw_token = credential.get_token("https://database.windows.net/.default").token
7388
logger.info(
7489
"get_token: Azure AD token acquired successfully - token_length=%d chars",
75-
len(token),
90+
len(raw_token),
7691
)
77-
return AADAuth.get_token_struct(token)
92+
token_struct = AADAuth.get_token_struct(raw_token)
93+
return token_struct, raw_token
7894
except ClientAuthenticationError as e:
79-
# Re-raise with more specific context about Azure AD authentication failure
8095
logger.error(
8196
"get_token: Azure AD authentication failed - credential_class=%s, error=%s",
8297
credential_class.__name__,
@@ -88,7 +103,6 @@ def get_token(auth_type: str) -> bytes:
88103
f"user cancellation, network issues, or unsupported configuration."
89104
) from e
90105
except Exception as e:
91-
# Catch any other unexpected exceptions
92106
logger.error(
93107
"get_token: Unexpected error during credential creation - credential_class=%s, error=%s",
94108
credential_class.__name__,
@@ -180,7 +194,7 @@ def remove_sensitive_params(parameters: List[str]) -> List[str]:
180194

181195

182196
def get_auth_token(auth_type: str) -> Optional[bytes]:
183-
"""Get authentication token based on auth type"""
197+
"""Get DDBC authentication token struct based on auth type."""
184198
logger.debug("get_auth_token: Starting - auth_type=%s", auth_type)
185199
if not auth_type:
186200
logger.debug("get_auth_token: No auth_type specified, returning None")
@@ -202,17 +216,37 @@ def get_auth_token(auth_type: str) -> Optional[bytes]:
202216
return None
203217

204218

219+
def extract_auth_type(connection_string: str) -> Optional[str]:
220+
"""Extract Entra ID auth type from a connection string.
221+
222+
Used as a fallback when process_connection_string does not propagate
223+
auth_type (e.g. Windows Interactive where DDBC handles auth natively).
224+
Bulkcopy still needs the auth type to acquire a token via Azure Identity.
225+
"""
226+
auth_map = {
227+
AuthType.INTERACTIVE.value: "interactive",
228+
AuthType.DEVICE_CODE.value: "devicecode",
229+
AuthType.DEFAULT.value: "default",
230+
}
231+
for part in connection_string.split(";"):
232+
key, _, value = part.strip().partition("=")
233+
if key.strip().lower() == "authentication":
234+
return auth_map.get(value.strip().lower())
235+
return None
236+
237+
205238
def process_connection_string(
206239
connection_string: str,
207-
) -> Tuple[str, Optional[Dict[int, bytes]]]:
240+
) -> Tuple[str, Optional[Dict[int, bytes]], Optional[str]]:
208241
"""
209242
Process connection string and handle authentication.
210243
211244
Args:
212245
connection_string: The connection string to process
213246
214247
Returns:
215-
Tuple[str, Optional[Dict]]: Processed connection string and attrs_before dict if needed
248+
Tuple[str, Optional[Dict], Optional[str]]: Processed connection string,
249+
attrs_before dict if needed, and auth_type string for bulk copy token acquisition
216250
217251
Raises:
218252
ValueError: If the connection string is invalid or empty
@@ -259,7 +293,11 @@ def process_connection_string(
259293
"process_connection_string: Token authentication configured successfully - auth_type=%s",
260294
auth_type,
261295
)
262-
return ";".join(modified_parameters) + ";", {1256: token_struct}
296+
return (
297+
";".join(modified_parameters) + ";",
298+
{ConstantsDDBC.SQL_COPT_SS_ACCESS_TOKEN.value: token_struct},
299+
auth_type,
300+
)
263301
else:
264302
logger.warning(
265303
"process_connection_string: Token acquisition failed, proceeding without token"
@@ -269,4 +307,4 @@ def process_connection_string(
269307
"process_connection_string: Connection string processing complete - has_auth=%s",
270308
bool(auth_type),
271309
)
272-
return ";".join(modified_parameters) + ";", None
310+
return ";".join(modified_parameters) + ";", None, auth_type

mssql_python/connection.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
ProgrammingError,
4040
NotSupportedError,
4141
)
42-
from mssql_python.auth import process_connection_string
42+
from mssql_python.auth import extract_auth_type, process_connection_string
4343
from mssql_python.constants import ConstantsDDBC, GetInfoConstants
4444
from mssql_python.connection_string_parser import _ConnectionStringParser
4545
from mssql_python.connection_string_builder import _ConnectionStringBuilder
@@ -263,6 +263,11 @@ def __init__(
263263
},
264264
}
265265

266+
# Auth type for acquiring fresh tokens at bulk copy time.
267+
# We intentionally do NOT cache the token — a fresh one is acquired
268+
# each time bulkcopy() is called to avoid expired-token errors.
269+
self._auth_type = None
270+
266271
# Check if the connection string contains authentication parameters
267272
# This is important for processing the connection string correctly.
268273
# If authentication is specified, it will be processed to handle
@@ -272,6 +277,10 @@ def __init__(
272277
self.connection_str = connection_result[0]
273278
if connection_result[1]:
274279
self._attrs_before.update(connection_result[1])
280+
# Store auth type so bulkcopy() can acquire a fresh token later.
281+
# On Windows Interactive, process_connection_string returns None
282+
# (DDBC handles auth natively), so fall back to the connection string.
283+
self._auth_type = connection_result[2] or extract_auth_type(self.connection_str)
275284

276285
self._closed = False
277286
self._timeout = timeout

mssql_python/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ class ConstantsDDBC(Enum):
158158
SQL_ATTR_SERVER_NAME = 13
159159
SQL_ATTR_RESET_CONNECTION = 116
160160

161+
# SQL Server-specific connection option constants
162+
SQL_COPT_SS_ACCESS_TOKEN = 1256
163+
161164
# Transaction Isolation Level Constants
162165
SQL_TXN_READ_UNCOMMITTED = 1
163166
SQL_TXN_READ_COMMITTED = 2

mssql_python/cursor.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2607,15 +2607,36 @@ def _bulkcopy(
26072607
context = {
26082608
"server": params.get("server"),
26092609
"database": params.get("database"),
2610-
"user_name": params.get("uid", ""),
26112610
"trust_server_certificate": trust_cert,
26122611
"encryption": encryption,
26132612
}
26142613

2615-
# Extract password separately to avoid storing it in generic context that may be logged
2616-
password = params.get("pwd", "")
2614+
# Build pycore_context with appropriate authentication.
2615+
# For Azure AD: acquire a FRESH token right now instead of reusing
2616+
# the one from connect() time — avoids expired-token errors when
2617+
# bulkcopy() is called long after the original connection.
26172618
pycore_context = dict(context)
2618-
pycore_context["password"] = password
2619+
2620+
if self.connection._auth_type:
2621+
# Fresh token acquisition for mssql-py-core connection
2622+
from mssql_python.auth import AADAuth
2623+
2624+
try:
2625+
raw_token = AADAuth.get_raw_token(self.connection._auth_type)
2626+
except (RuntimeError, ValueError) as e:
2627+
raise RuntimeError(
2628+
f"Bulk copy failed: unable to acquire Azure AD token "
2629+
f"for auth_type '{self.connection._auth_type}': {e}"
2630+
) from e
2631+
pycore_context["access_token"] = raw_token
2632+
logger.debug(
2633+
"Bulk copy: acquired fresh Azure AD token for auth_type=%s",
2634+
self.connection._auth_type,
2635+
)
2636+
else:
2637+
# SQL Server authentication — use uid/password from connection string
2638+
pycore_context["user_name"] = params.get("uid", "")
2639+
pycore_context["password"] = params.get("pwd", "")
26192640

26202641
pycore_connection = None
26212642
pycore_cursor = None
@@ -2653,10 +2674,10 @@ def _bulkcopy(
26532674

26542675
finally:
26552676
# Clear sensitive data to minimize memory exposure
2656-
password = ""
26572677
if pycore_context:
2658-
pycore_context["password"] = ""
2659-
pycore_context["user_name"] = ""
2678+
pycore_context.pop("password", None)
2679+
pycore_context.pop("user_name", None)
2680+
pycore_context.pop("access_token", None)
26602681
# Clean up bulk copy resources
26612682
for resource in (pycore_cursor, pycore_connection):
26622683
if resource and hasattr(resource, "close"):

0 commit comments

Comments
 (0)