diff --git a/src/lightspeed_stack.py b/src/lightspeed_stack.py index 858799c36..0c58c0bc3 100644 --- a/src/lightspeed_stack.py +++ b/src/lightspeed_stack.py @@ -15,6 +15,7 @@ from runners.quota_scheduler import start_quota_scheduler from runners.uvicorn import start_uvicorn from utils import schema_dumper +from utils.proxy_env import sanitize_no_proxy_env # Resolve log level and handler from centralized logging utilities log_level = resolve_log_level() @@ -114,6 +115,7 @@ def main() -> None: (exits with status 1). """ logger.info("Lightspeed Core Stack startup") + sanitize_no_proxy_env() parser = create_argument_parser() args = parser.parse_args() diff --git a/src/utils/proxy_env.py b/src/utils/proxy_env.py new file mode 100644 index 000000000..66a2f869e --- /dev/null +++ b/src/utils/proxy_env.py @@ -0,0 +1,75 @@ +"""Helpers for normalizing proxy-related environment variables.""" + +from __future__ import annotations + +import logging +import os +import re + +logger = logging.getLogger(__name__) + +_NO_PROXY_ENV_VARS = ("NO_PROXY", "no_proxy") + +# IPv6 CIDR entries (for example fd00:1234:5678::/64) crash httpx until +# https://github.com/encode/httpx/pull/3741 ships in a release. +_IPV6_CIDR_PATTERN = re.compile(r"::.*/") + + +def is_unsupported_no_proxy_entry(entry: str) -> bool: + """Return True when a NO_PROXY entry cannot be parsed by httpx.""" + stripped = entry.strip() + if not stripped: + return True + return _IPV6_CIDR_PATTERN.search(stripped) is not None + + +def sanitize_no_proxy_value(value: str) -> tuple[str, list[str]]: + """Remove unsupported NO_PROXY entries from a comma-separated value.""" + kept: list[str] = [] + removed: list[str] = [] + + for entry in value.split(","): + stripped = entry.strip() + if not stripped: + continue + if is_unsupported_no_proxy_entry(stripped): + removed.append(stripped) + else: + kept.append(stripped) + + return ",".join(kept), removed + + +def sanitize_no_proxy_env() -> list[str]: + """Sanitize NO_PROXY/no_proxy before httpx reads proxy settings. + + OpenShift cluster proxies commonly inject IPv6 CIDR bypass entries. + httpx currently mis-parses those values and raises InvalidURL during + client initialization (encode/httpx#3221). + + Returns: + Entries removed from the environment. + """ + removed: list[str] = [] + + for var in _NO_PROXY_ENV_VARS: + value = os.environ.get(var) + if value is None: + continue + + sanitized, removed_entries = sanitize_no_proxy_value(value) + removed.extend(removed_entries) + + if sanitized: + os.environ[var] = sanitized + else: + os.environ.pop(var, None) + + if removed: + logger.warning( + "Removed unsupported IPv6 CIDR entries from NO_PROXY/no_proxy " + "to avoid httpx startup failures: %s", + ", ".join(removed), + ) + + return removed diff --git a/tests/unit/utils/test_proxy_env.py b/tests/unit/utils/test_proxy_env.py new file mode 100644 index 000000000..e4358f1b9 --- /dev/null +++ b/tests/unit/utils/test_proxy_env.py @@ -0,0 +1,94 @@ +"""Unit tests for proxy environment helpers.""" + +import os + +import httpx +import pytest + +from utils.proxy_env import ( + is_unsupported_no_proxy_entry, + sanitize_no_proxy_env, + sanitize_no_proxy_value, +) + + +class TestIsUnsupportedNoProxyEntry: + """Tests for is_unsupported_no_proxy_entry.""" + + @pytest.mark.parametrize( + "entry", + [ + "fd00:1234:5678::/64", + "fd01::/48", + "fd02::/112", + ], + ) + def test_ipv6_cidr_entries_are_unsupported(self, entry: str) -> None: + """IPv6 CIDR entries should be treated as unsupported.""" + assert is_unsupported_no_proxy_entry(entry) is True + + @pytest.mark.parametrize( + "entry", + [ + "localhost", + "127.0.0.0/8", + "10.0.0.0/8", + ".cluster.local", + ".svc", + ], + ) + def test_common_entries_remain_supported(self, entry: str) -> None: + """Common OpenShift NO_PROXY entries should be preserved.""" + assert is_unsupported_no_proxy_entry(entry) is False + + +class TestSanitizeNoProxyValue: + """Tests for sanitize_no_proxy_value.""" + + def test_removes_only_ipv6_cidr_entries(self) -> None: + """Sanitization should keep supported entries and drop IPv6 CIDRs.""" + original = ( + "localhost,127.0.0.0/8,fd00:1234:5678::/64,fd01::/48," + "fd02::/112,.cluster.local,.svc" + ) + + sanitized, removed = sanitize_no_proxy_value(original) + + assert sanitized == "localhost,127.0.0.0/8,.cluster.local,.svc" + assert removed == [ + "fd00:1234:5678::/64", + "fd01::/48", + "fd02::/112", + ] + + +class TestSanitizeNoProxyEnv: + """Tests for sanitize_no_proxy_env.""" + + def test_updates_both_proxy_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Both NO_PROXY and no_proxy should be sanitized.""" + monkeypatch.setenv( + "NO_PROXY", + "localhost,fd00:1234:5678::/64,.cluster.local", + ) + monkeypatch.setenv("no_proxy", "localhost,fd01::/48") + + removed = sanitize_no_proxy_env() + + assert removed == ["fd00:1234:5678::/64", "fd01::/48"] + assert os.environ["NO_PROXY"] == "localhost,.cluster.local" + assert os.environ["no_proxy"] == "localhost" + + def test_httpx_client_initializes_after_sanitize( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """httpx should not crash once unsupported entries are removed.""" + monkeypatch.setenv( + "NO_PROXY", + "localhost,fd00:1234:5678::/64,fd01::/48,fd02::/112", + ) + + sanitize_no_proxy_env() + + with httpx.Client() as client: + assert client is not None