Skip to content

Commit 79a4d86

Browse files
authored
Create SSLContexts in the main thread. (#2356)
This solves #2355 without yet understanding why that issue exists. Fixes #2355
1 parent a32dd36 commit 79a4d86

File tree

6 files changed

+196
-8
lines changed

6 files changed

+196
-8
lines changed

CHANGES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release Notes
22

3+
## 2.1.163
4+
5+
This release fixes Pex to work in certain OS / SSL environments where it
6+
did not previously. In particular, under certain Fedora distributions
7+
using certain Python Build Standalone interpreters.
8+
9+
* Create SSLContexts in the main thread. (#2356)
10+
311
## 2.1.162
412

513
This release adds support for `--pip-version 24.0` as well as fixing a

pex/compatibility.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import os
1010
import re
1111
import sys
12+
import threading
1213
from abc import ABCMeta
1314
from sys import version_info as sys_version_info
1415

@@ -273,3 +274,22 @@ def append(piece):
273274
from pipes import quote as _shlex_quote
274275

275276
shlex_quote = _shlex_quote
277+
278+
279+
if PY3:
280+
281+
def in_main_thread():
282+
# type: () -> bool
283+
return threading.current_thread() == threading.main_thread()
284+
285+
else:
286+
287+
def in_main_thread():
288+
# type: () -> bool
289+
290+
# Both CPython 2.7 and PyPy 2.7 do, in fact, have a threading._MainThread type that the
291+
# main thread derives from.
292+
return isinstance(
293+
threading.current_thread(),
294+
threading._MainThread, # type: ignore[attr-defined]
295+
)

pex/fetcher.py

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
import os
88
import ssl
99
import sys
10+
import threading
1011
import time
1112
from contextlib import closing, contextmanager
13+
from ssl import SSLContext
1214

15+
from pex import asserts
1316
from pex.auth import PasswordDatabase, PasswordEntry
1417
from pex.compatibility import (
1518
FileHandler,
@@ -21,15 +24,19 @@
2124
ProxyHandler,
2225
Request,
2326
build_opener,
27+
in_main_thread,
2428
)
2529
from pex.network_configuration import NetworkConfiguration
2630
from pex.typing import TYPE_CHECKING, cast
2731
from pex.version import __version__
2832

2933
if TYPE_CHECKING:
3034
from typing import BinaryIO, Dict, Iterable, Iterator, Mapping, Optional, Text
35+
36+
import attr # vendor:skip
3137
else:
3238
BinaryIO = None
39+
from pex.third_party import attr
3340

3441

3542
@contextmanager
@@ -46,6 +53,60 @@ def guard_stdout():
4653
yield
4754

4855

56+
@attr.s(frozen=True)
57+
class _CertConfig(object):
58+
@classmethod
59+
def create(cls, network_configuration=None):
60+
# type: (Optional[NetworkConfiguration]) -> _CertConfig
61+
if network_configuration is None:
62+
return cls()
63+
return cls(cert=network_configuration.cert, client_cert=network_configuration.client_cert)
64+
65+
cert = attr.ib(default=None) # type: Optional[str]
66+
client_cert = attr.ib(default=None) # type: Optional[str]
67+
68+
def create_ssl_context(self):
69+
# type: () -> SSLContext
70+
asserts.production_assert(
71+
in_main_thread(),
72+
msg=(
73+
"An SSLContext must be initialized from the main thread. An attempt was made to "
74+
"initialize an SSLContext for {cert_config} from thread {thread}.".format(
75+
cert_config=self, thread=threading.current_thread()
76+
)
77+
),
78+
)
79+
with guard_stdout():
80+
ssl_context = ssl.create_default_context(cafile=self.cert)
81+
if self.client_cert:
82+
ssl_context.load_cert_chain(self.client_cert)
83+
return ssl_context
84+
85+
86+
_SSL_CONTEXTS = {} # type: Dict[_CertConfig, SSLContext]
87+
88+
89+
def get_ssl_context(network_configuration=None):
90+
# type: (Optional[NetworkConfiguration]) -> SSLContext
91+
cert_config = _CertConfig.create(network_configuration=network_configuration)
92+
ssl_context = _SSL_CONTEXTS.get(cert_config)
93+
if not ssl_context:
94+
ssl_context = cert_config.create_ssl_context()
95+
_SSL_CONTEXTS[cert_config] = ssl_context
96+
return ssl_context
97+
98+
99+
def initialize_ssl_context(network_configuration=None):
100+
# type: (Optional[NetworkConfiguration]) -> None
101+
get_ssl_context(network_configuration=network_configuration)
102+
103+
104+
# N.B.: We eagerly initialize an SSLContext for the default case of no CA cert and no client cert.
105+
# When a custom CA cert or client cert or both are configured, that code will need to call
106+
# initialize_ssl_context on its own.
107+
initialize_ssl_context()
108+
109+
49110
class URLFetcher(object):
50111
USER_AGENT = "pex/{version}".format(version=__version__)
51112

@@ -62,16 +123,14 @@ def __init__(
62123
self._timeout = network_configuration.timeout
63124
self._max_retries = network_configuration.retries
64125

65-
with guard_stdout():
66-
ssl_context = ssl.create_default_context(cafile=network_configuration.cert)
67-
if network_configuration.client_cert:
68-
ssl_context.load_cert_chain(network_configuration.client_cert)
69-
70126
proxies = None # type: Optional[Dict[str, str]]
71127
if network_configuration.proxy:
72128
proxies = {protocol: network_configuration.proxy for protocol in ("http", "https")}
73129

74-
handlers = [ProxyHandler(proxies), HTTPSHandler(context=ssl_context)]
130+
handlers = [
131+
ProxyHandler(proxies),
132+
HTTPSHandler(context=get_ssl_context(network_configuration=network_configuration)),
133+
]
75134
if handle_file_urls:
76135
handlers.append(FileHandler())
77136

pex/resolve/resolver_options.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from pex import pex_warnings
1010
from pex.argparse import HandleBoolAction
11+
from pex.fetcher import initialize_ssl_context
1112
from pex.network_configuration import NetworkConfiguration
1213
from pex.orderedset import OrderedSet
1314
from pex.pep_503 import ProjectName
@@ -564,13 +565,15 @@ def create_network_configuration(options):
564565
565566
:param options: The Pip resolver configuration options.
566567
"""
567-
return NetworkConfiguration(
568+
network_configuration = NetworkConfiguration(
568569
retries=options.retries,
569570
timeout=options.timeout,
570571
proxy=options.proxy,
571572
cert=options.cert,
572573
client_cert=options.client_cert,
573574
)
575+
initialize_ssl_context(network_configuration=network_configuration)
576+
return network_configuration
574577

575578

576579
def get_max_jobs_value(options):

pex/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "2.1.162"
4+
__version__ = "2.1.163"

tests/integration/test_issue_2355.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright 2024 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
4+
import os
5+
import subprocess
6+
from textwrap import dedent
7+
8+
import pytest
9+
10+
from pex.common import is_exe
11+
from pex.typing import TYPE_CHECKING
12+
from testing import run_pex_command
13+
14+
if TYPE_CHECKING:
15+
from typing import Any
16+
17+
18+
@pytest.mark.skipif(
19+
not any(
20+
is_exe(os.path.join(entry, "docker"))
21+
for entry in os.environ.get("PATH", os.path.defpath).split(os.pathsep)
22+
),
23+
reason="This test needs docker to run.",
24+
)
25+
def test_ssl_context(
26+
tmpdir, # type: Any
27+
pex_project_dir, # type: str
28+
):
29+
# type: (...) -> None
30+
31+
with open(os.path.join(str(tmpdir), "Dockerfile"), "w") as fp:
32+
fp.write(
33+
dedent(
34+
r"""
35+
FROM fedora:37
36+
37+
ARG PBS_RELEASE
38+
ARG PBS_ARCHIVE
39+
40+
RUN \
41+
curl --fail -sSL -O $PBS_RELEASE/$PBS_ARCHIVE && \
42+
curl --fail -sSL -O $PBS_RELEASE/$PBS_ARCHIVE.sha256 && \
43+
[[ \
44+
"$(cat $PBS_ARCHIVE.sha256)" == "$(sha256sum $PBS_ARCHIVE | cut -d' ' -f1)" \
45+
]] && \
46+
tar -xzf $PBS_ARCHIVE
47+
"""
48+
)
49+
)
50+
51+
pbs_release = "https://github.com/indygreg/python-build-standalone/releases/download/20240107"
52+
pbs_archive = "cpython-3.9.18+20240107-x86_64-unknown-linux-gnu-install_only.tar.gz"
53+
subprocess.check_call(
54+
args=[
55+
"docker",
56+
"build",
57+
"-t",
58+
"test_issue_2355",
59+
"--build-arg",
60+
"PBS_RELEASE={pbs_release}".format(pbs_release=pbs_release),
61+
"--build-arg",
62+
"PBS_ARCHIVE={pbs_archive}".format(pbs_archive=pbs_archive),
63+
str(tmpdir),
64+
]
65+
)
66+
67+
work_dir = os.path.join(str(tmpdir), "work_dir")
68+
os.mkdir(work_dir)
69+
subprocess.check_call(
70+
args=[
71+
"docker",
72+
"run",
73+
"--rm",
74+
"-v" "{pex_project_dir}:/code".format(pex_project_dir=pex_project_dir),
75+
"-v",
76+
"{work_dir}:/work".format(work_dir=work_dir),
77+
"-w",
78+
"/code",
79+
"test_issue_2355",
80+
"/python/bin/python3.9",
81+
"-mpex.cli",
82+
"lock",
83+
"create",
84+
"--style",
85+
"universal",
86+
"cowsay==5.0",
87+
"--indent",
88+
"2",
89+
"-o",
90+
"/work/lock.json",
91+
]
92+
)
93+
94+
result = run_pex_command(
95+
args=["--lock", os.path.join(work_dir, "lock.json"), "-c", "cowsay", "--", "Moo!"]
96+
)
97+
result.assert_success()
98+
assert "Moo!" in result.error

0 commit comments

Comments
 (0)