Skip to content

Commit

Permalink
Create SSLContexts in the main thread. (#2356)
Browse files Browse the repository at this point in the history
This solves #2355 without yet understanding why that issue exists.

Fixes #2355
  • Loading branch information
jsirois authored Feb 7, 2024
1 parent a32dd36 commit 79a4d86
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 8 deletions.
8 changes: 8 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Release Notes

## 2.1.163

This release fixes Pex to work in certain OS / SSL environments where it
did not previously. In particular, under certain Fedora distributions
using certain Python Build Standalone interpreters.

* Create SSLContexts in the main thread. (#2356)

## 2.1.162

This release adds support for `--pip-version 24.0` as well as fixing a
Expand Down
20 changes: 20 additions & 0 deletions pex/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import re
import sys
import threading
from abc import ABCMeta
from sys import version_info as sys_version_info

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

shlex_quote = _shlex_quote


if PY3:

def in_main_thread():
# type: () -> bool
return threading.current_thread() == threading.main_thread()

else:

def in_main_thread():
# type: () -> bool

# Both CPython 2.7 and PyPy 2.7 do, in fact, have a threading._MainThread type that the
# main thread derives from.
return isinstance(
threading.current_thread(),
threading._MainThread, # type: ignore[attr-defined]
)
71 changes: 65 additions & 6 deletions pex/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
import os
import ssl
import sys
import threading
import time
from contextlib import closing, contextmanager
from ssl import SSLContext

from pex import asserts
from pex.auth import PasswordDatabase, PasswordEntry
from pex.compatibility import (
FileHandler,
Expand All @@ -21,15 +24,19 @@
ProxyHandler,
Request,
build_opener,
in_main_thread,
)
from pex.network_configuration import NetworkConfiguration
from pex.typing import TYPE_CHECKING, cast
from pex.version import __version__

if TYPE_CHECKING:
from typing import BinaryIO, Dict, Iterable, Iterator, Mapping, Optional, Text

import attr # vendor:skip
else:
BinaryIO = None
from pex.third_party import attr


@contextmanager
Expand All @@ -46,6 +53,60 @@ def guard_stdout():
yield


@attr.s(frozen=True)
class _CertConfig(object):
@classmethod
def create(cls, network_configuration=None):
# type: (Optional[NetworkConfiguration]) -> _CertConfig
if network_configuration is None:
return cls()
return cls(cert=network_configuration.cert, client_cert=network_configuration.client_cert)

cert = attr.ib(default=None) # type: Optional[str]
client_cert = attr.ib(default=None) # type: Optional[str]

def create_ssl_context(self):
# type: () -> SSLContext
asserts.production_assert(
in_main_thread(),
msg=(
"An SSLContext must be initialized from the main thread. An attempt was made to "
"initialize an SSLContext for {cert_config} from thread {thread}.".format(
cert_config=self, thread=threading.current_thread()
)
),
)
with guard_stdout():
ssl_context = ssl.create_default_context(cafile=self.cert)
if self.client_cert:
ssl_context.load_cert_chain(self.client_cert)
return ssl_context


_SSL_CONTEXTS = {} # type: Dict[_CertConfig, SSLContext]


def get_ssl_context(network_configuration=None):
# type: (Optional[NetworkConfiguration]) -> SSLContext
cert_config = _CertConfig.create(network_configuration=network_configuration)
ssl_context = _SSL_CONTEXTS.get(cert_config)
if not ssl_context:
ssl_context = cert_config.create_ssl_context()
_SSL_CONTEXTS[cert_config] = ssl_context
return ssl_context


def initialize_ssl_context(network_configuration=None):
# type: (Optional[NetworkConfiguration]) -> None
get_ssl_context(network_configuration=network_configuration)


# N.B.: We eagerly initialize an SSLContext for the default case of no CA cert and no client cert.
# When a custom CA cert or client cert or both are configured, that code will need to call
# initialize_ssl_context on its own.
initialize_ssl_context()


class URLFetcher(object):
USER_AGENT = "pex/{version}".format(version=__version__)

Expand All @@ -62,16 +123,14 @@ def __init__(
self._timeout = network_configuration.timeout
self._max_retries = network_configuration.retries

with guard_stdout():
ssl_context = ssl.create_default_context(cafile=network_configuration.cert)
if network_configuration.client_cert:
ssl_context.load_cert_chain(network_configuration.client_cert)

proxies = None # type: Optional[Dict[str, str]]
if network_configuration.proxy:
proxies = {protocol: network_configuration.proxy for protocol in ("http", "https")}

handlers = [ProxyHandler(proxies), HTTPSHandler(context=ssl_context)]
handlers = [
ProxyHandler(proxies),
HTTPSHandler(context=get_ssl_context(network_configuration=network_configuration)),
]
if handle_file_urls:
handlers.append(FileHandler())

Expand Down
5 changes: 4 additions & 1 deletion pex/resolve/resolver_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from pex import pex_warnings
from pex.argparse import HandleBoolAction
from pex.fetcher import initialize_ssl_context
from pex.network_configuration import NetworkConfiguration
from pex.orderedset import OrderedSet
from pex.pep_503 import ProjectName
Expand Down Expand Up @@ -564,13 +565,15 @@ def create_network_configuration(options):
:param options: The Pip resolver configuration options.
"""
return NetworkConfiguration(
network_configuration = NetworkConfiguration(
retries=options.retries,
timeout=options.timeout,
proxy=options.proxy,
cert=options.cert,
client_cert=options.client_cert,
)
initialize_ssl_context(network_configuration=network_configuration)
return network_configuration


def get_max_jobs_value(options):
Expand Down
2 changes: 1 addition & 1 deletion pex/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

__version__ = "2.1.162"
__version__ = "2.1.163"
98 changes: 98 additions & 0 deletions tests/integration/test_issue_2355.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright 2024 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

import os
import subprocess
from textwrap import dedent

import pytest

from pex.common import is_exe
from pex.typing import TYPE_CHECKING
from testing import run_pex_command

if TYPE_CHECKING:
from typing import Any


@pytest.mark.skipif(
not any(
is_exe(os.path.join(entry, "docker"))
for entry in os.environ.get("PATH", os.path.defpath).split(os.pathsep)
),
reason="This test needs docker to run.",
)
def test_ssl_context(
tmpdir, # type: Any
pex_project_dir, # type: str
):
# type: (...) -> None

with open(os.path.join(str(tmpdir), "Dockerfile"), "w") as fp:
fp.write(
dedent(
r"""
FROM fedora:37
ARG PBS_RELEASE
ARG PBS_ARCHIVE
RUN \
curl --fail -sSL -O $PBS_RELEASE/$PBS_ARCHIVE && \
curl --fail -sSL -O $PBS_RELEASE/$PBS_ARCHIVE.sha256 && \
[[ \
"$(cat $PBS_ARCHIVE.sha256)" == "$(sha256sum $PBS_ARCHIVE | cut -d' ' -f1)" \
]] && \
tar -xzf $PBS_ARCHIVE
"""
)
)

pbs_release = "https://github.com/indygreg/python-build-standalone/releases/download/20240107"
pbs_archive = "cpython-3.9.18+20240107-x86_64-unknown-linux-gnu-install_only.tar.gz"
subprocess.check_call(
args=[
"docker",
"build",
"-t",
"test_issue_2355",
"--build-arg",
"PBS_RELEASE={pbs_release}".format(pbs_release=pbs_release),
"--build-arg",
"PBS_ARCHIVE={pbs_archive}".format(pbs_archive=pbs_archive),
str(tmpdir),
]
)

work_dir = os.path.join(str(tmpdir), "work_dir")
os.mkdir(work_dir)
subprocess.check_call(
args=[
"docker",
"run",
"--rm",
"-v" "{pex_project_dir}:/code".format(pex_project_dir=pex_project_dir),
"-v",
"{work_dir}:/work".format(work_dir=work_dir),
"-w",
"/code",
"test_issue_2355",
"/python/bin/python3.9",
"-mpex.cli",
"lock",
"create",
"--style",
"universal",
"cowsay==5.0",
"--indent",
"2",
"-o",
"/work/lock.json",
]
)

result = run_pex_command(
args=["--lock", os.path.join(work_dir, "lock.json"), "-c", "cowsay", "--", "Moo!"]
)
result.assert_success()
assert "Moo!" in result.error

0 comments on commit 79a4d86

Please sign in to comment.