From 005787920d855cb071bc6c72949b9e14aba4b9f1 Mon Sep 17 00:00:00 2001 From: Erni Durdevic Date: Thu, 18 Jun 2026 15:46:20 +0200 Subject: [PATCH 1/2] Fix SSL cert verification failures behind enterprise inspection proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's urllib does not load the OS trust store on all platforms, so workspaces behind a corporate TLS inspection proxy (which injects a self-signed CA) fail with CERTIFICATE_VERIFY_FAILED even though curl succeeds. Honor REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, and SSL_CERT_FILE — the same env vars respected by curl and the requests library — so customers can point ucode at their enterprise CA bundle. Co-authored-by: Isaac --- src/ucode/databricks.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 574d9066..c2cc15f4 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -13,6 +13,7 @@ import re import shlex import shutil +import ssl import subprocess from pathlib import Path from typing import Literal, cast, overload @@ -194,6 +195,27 @@ def _log_auth_diagnostics() -> None: _debug(f"databrickscfg ({cfg_path})", f"read error: {exc}") +def _make_ssl_context() -> ssl.SSLContext: + """Return an SSL context that trusts the system CA bundle plus any custom CA + pointed to by REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE. + + Enterprise environments often inject a self-signed certificate via an SSL + inspection proxy. curl picks it up from the system store automatically; + Python's default ssl context doesn't on all platforms. Honoring the same + env vars that curl and the `requests` library use lets customers point ucode + at their enterprise CA bundle without patching the system Python install.""" + ctx = ssl.create_default_context() + for env_var in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE"): + ca_bundle = os.environ.get(env_var, "").strip() + if ca_bundle and Path(ca_bundle).is_file(): + try: + ctx.load_verify_locations(cafile=ca_bundle) + except ssl.SSLError: + pass + break + return ctx + + def _http_get_json( url: str, token: str, *, timeout: int = 10 ) -> tuple[dict | list | None, str | None]: @@ -206,7 +228,9 @@ def _http_get_json( headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, ) try: - with urllib_request.urlopen(request, timeout=timeout) as response: + with urllib_request.urlopen( + request, timeout=timeout, context=_make_ssl_context() + ) as response: body = response.read().decode("utf-8") _debug(f"GET {url}", f"HTTP 200, {len(body)} bytes") if _debug_enabled(): @@ -253,7 +277,9 @@ def _http_post_json( }, ) try: - with urllib_request.urlopen(request, timeout=timeout) as response: + with urllib_request.urlopen( + request, timeout=timeout, context=_make_ssl_context() + ) as response: body = response.read().decode("utf-8") _debug(f"POST {url}", f"HTTP {response.status}, {len(body)} bytes") if _debug_enabled(): @@ -1466,7 +1492,7 @@ def discover_sql_warehouse_http_path( ) try: - with urllib_request.urlopen(request, timeout=20) as response: + with urllib_request.urlopen(request, timeout=20, context=_make_ssl_context()) as response: payload = json.loads(response.read().decode("utf-8")) except urllib_error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") if exc.fp else "" From ce480f3cac1b7dec3a167a866cdd9bf63ec05926 Mon Sep 17 00:00:00 2001 From: Erni Durdevic Date: Wed, 9 Sep 2026 00:06:27 +0200 Subject: [PATCH 2/2] Cache custom SSL context and fix CA bundle fallback handling Add @functools.cache to _make_ssl_context so the constructed SSL context is reused across calls. Fix the CA bundle loading loop so a failure to load one bundle (now catching OSError, not just SSLError) falls back to the next candidate instead of breaking out. Update the docstring to mention SSL_CERT_FILE alongside the other env vars. Add tests covering caching and fallback behavior. --- src/ucode/databricks.py | 10 ++++++---- tests/test_databricks.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index c2cc15f4..982aa127 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -195,9 +195,10 @@ def _log_auth_diagnostics() -> None: _debug(f"databrickscfg ({cfg_path})", f"read error: {exc}") +@functools.cache def _make_ssl_context() -> ssl.SSLContext: """Return an SSL context that trusts the system CA bundle plus any custom CA - pointed to by REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE. + pointed to by REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, or SSL_CERT_FILE. Enterprise environments often inject a self-signed certificate via an SSL inspection proxy. curl picks it up from the system store automatically; @@ -210,9 +211,10 @@ def _make_ssl_context() -> ssl.SSLContext: if ca_bundle and Path(ca_bundle).is_file(): try: ctx.load_verify_locations(cafile=ca_bundle) - except ssl.SSLError: - pass - break + except OSError: + continue + else: + break return ctx diff --git a/tests/test_databricks.py b/tests/test_databricks.py index 65f05d89..950c2e3f 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -1202,6 +1202,42 @@ def test_reason_without_body_is_status_only(self): assert reason == "HTTP 404 Not Found" +class TestMakeSslContext: + @pytest.fixture(autouse=True) + def clear_cache(self): + db_mod._make_ssl_context.cache_clear() + yield + db_mod._make_ssl_context.cache_clear() + + def test_context_is_cached(self, monkeypatch): + from unittest.mock import Mock + + context = Mock() + create_default_context = Mock(return_value=context) + monkeypatch.setattr(db_mod.ssl, "create_default_context", create_default_context) + + assert db_mod._make_ssl_context() is context + assert db_mod._make_ssl_context() is context + create_default_context.assert_called_once_with() + + @pytest.mark.parametrize("load_error", [OSError("permission denied"), db_mod.ssl.SSLError()]) + def test_failed_bundle_falls_back_to_next_one(self, monkeypatch, load_error): + from unittest.mock import Mock + + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/first.pem") + monkeypatch.setenv("CURL_CA_BUNDLE", "/second.pem") + monkeypatch.setattr(db_mod.Path, "is_file", lambda self: True) + context = Mock() + context.load_verify_locations.side_effect = [load_error, None] + monkeypatch.setattr(db_mod.ssl, "create_default_context", Mock(return_value=context)) + + assert db_mod._make_ssl_context() is context + assert context.load_verify_locations.call_args_list == [ + ((), {"cafile": "/first.pem"}), + ((), {"cafile": "/second.pem"}), + ] + + class TestParseDatabricksCliVersion: def test_parses_standard_format(self): assert _parse_databricks_cli_version("Databricks CLI v0.299.2") == (0, 299, 2)