Skip to content

Commit 2e8ea59

Browse files
committed
fix: clarify best-effort environment probe failures
1 parent 52092c6 commit 2e8ea59

File tree

2 files changed

+178
-88
lines changed

2 files changed

+178
-88
lines changed

src/promptfoo/environment.py

Lines changed: 80 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@ class Environment:
3333
has_sudo: bool = False # Best guess if user has sudo access
3434

3535

36+
def _read_probe_file(path: Path) -> Optional[str]:
37+
"""
38+
Read an optional environment probe file.
39+
40+
Returns:
41+
File contents, or None when the probe file does not exist or cannot be read.
42+
"""
43+
if not path.exists():
44+
return None
45+
46+
try:
47+
with open(path) as f:
48+
return f.read()
49+
except OSError:
50+
# Environment detection is best-effort. Proc/sys metadata files can be
51+
# unreadable or disappear between exists() and open(), so treat that as
52+
# "signal unavailable" and continue with fallback probes.
53+
return None
54+
55+
3656
def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
3757
"""
3858
Detect Linux distribution and version.
@@ -48,47 +68,46 @@ def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
4868

4969
# Try /etc/os-release first, then /usr/lib/os-release (per freedesktop spec)
5070
for os_release_path in [Path("/etc/os-release"), Path("/usr/lib/os-release")]:
51-
if os_release_path.exists():
52-
try:
53-
with open(os_release_path) as f:
54-
os_release = {}
55-
for line in f:
56-
line = line.strip()
57-
if not line or line.startswith("#"):
58-
continue
59-
if "=" in line:
60-
key, _, value = line.partition("=")
61-
# Remove quotes
62-
value = value.strip('"').strip("'")
63-
os_release[key] = value
64-
65-
distro_id = os_release.get("ID", "").lower()
66-
version = os_release.get("VERSION_ID", "")
67-
id_like = os_release.get("ID_LIKE", "").lower().split()
68-
69-
# Normalize distro IDs
70-
if distro_id in known_base_distros:
71-
return distro_id, version
72-
elif distro_id in rhel_family:
73-
# Oracle Linux (ol), Amazon Linux (amzn)
74-
return "rhel", version
75-
elif distro_id in suse_family:
76-
return "suse", version
77-
78-
# Check ID_LIKE for derivative distributions (e.g., Pop!_OS, Raspbian, Mint)
79-
if id_like:
80-
for parent in id_like:
81-
if parent in known_base_distros:
82-
return parent, version
83-
elif parent in rhel_family:
84-
return "rhel", version
85-
elif parent in suse_family:
86-
return "suse", version
87-
88-
# Return the raw distro_id if we couldn't normalize it
89-
return distro_id, version
90-
except OSError:
91-
pass
71+
os_release_content = _read_probe_file(os_release_path)
72+
if os_release_content is None:
73+
continue
74+
75+
os_release = {}
76+
for line in os_release_content.splitlines():
77+
line = line.strip()
78+
if not line or line.startswith("#"):
79+
continue
80+
if "=" in line:
81+
key, _, value = line.partition("=")
82+
# Remove quotes
83+
value = value.strip('"').strip("'")
84+
os_release[key] = value
85+
86+
distro_id = os_release.get("ID", "").lower()
87+
version = os_release.get("VERSION_ID", "")
88+
id_like = os_release.get("ID_LIKE", "").lower().split()
89+
90+
# Normalize distro IDs
91+
if distro_id in known_base_distros:
92+
return distro_id, version
93+
elif distro_id in rhel_family:
94+
# Oracle Linux (ol), Amazon Linux (amzn)
95+
return "rhel", version
96+
elif distro_id in suse_family:
97+
return "suse", version
98+
99+
# Check ID_LIKE for derivative distributions (e.g., Pop!_OS, Raspbian, Mint)
100+
if id_like:
101+
for parent in id_like:
102+
if parent in known_base_distros:
103+
return parent, version
104+
elif parent in rhel_family:
105+
return "rhel", version
106+
elif parent in suse_family:
107+
return "suse", version
108+
109+
# Return the raw distro_id if we couldn't normalize it
110+
return distro_id, version
92111

93112
# Fallback: check for specific files
94113
if Path("/etc/debian_version").exists():
@@ -112,44 +131,31 @@ def _detect_cloud_provider() -> Optional[str]:
112131
"""
113132
# AWS detection
114133
# Check for EC2 metadata
115-
if Path("/sys/hypervisor/uuid").exists():
116-
try:
117-
with open("/sys/hypervisor/uuid") as f:
118-
uuid = f.read().strip()
119-
if uuid.startswith("ec2") or uuid.startswith("EC2"):
120-
return "aws"
121-
except OSError:
122-
pass
134+
uuid = _read_probe_file(Path("/sys/hypervisor/uuid"))
135+
if uuid and uuid.strip().lower().startswith("ec2"):
136+
return "aws"
123137

124138
# Check AWS environment variables
125139
if os.getenv("AWS_EXECUTION_ENV") or os.getenv("AWS_REGION"):
126140
return "aws"
127141

128142
# GCP detection
129143
# Check for GCP metadata
130-
if Path("/sys/class/dmi/id/product_name").exists():
131-
try:
132-
with open("/sys/class/dmi/id/product_name") as f:
133-
product = f.read().strip()
134-
if "Google" in product or "GCE" in product:
135-
return "gcp"
136-
except OSError:
137-
pass
144+
product = _read_probe_file(Path("/sys/class/dmi/id/product_name"))
145+
if product:
146+
product = product.strip()
147+
if "Google" in product or "GCE" in product:
148+
return "gcp"
138149

139150
# Check GCP environment variables
140151
if os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT"):
141152
return "gcp"
142153

143154
# Azure detection
144-
if Path("/sys/class/dmi/id/sys_vendor").exists():
145-
try:
146-
with open("/sys/class/dmi/id/sys_vendor") as f:
147-
vendor = f.read().strip()
148-
# Could be Azure or Hyper-V, check for Azure-specific
149-
if "Microsoft Corporation" in vendor and Path("/var/lib/waagent").exists():
150-
return "azure"
151-
except OSError:
152-
pass
155+
vendor = _read_probe_file(Path("/sys/class/dmi/id/sys_vendor"))
156+
# Could be Azure or Hyper-V, check for Azure-specific
157+
if vendor and "Microsoft Corporation" in vendor.strip() and Path("/var/lib/waagent").exists():
158+
return "azure"
153159

154160
# Check Azure environment variables
155161
if os.getenv("AZURE_SUBSCRIPTION_ID") or os.getenv("WEBSITE_INSTANCE_ID"):
@@ -173,14 +179,9 @@ def _detect_container() -> tuple[bool, bool]:
173179
is_docker = True
174180

175181
# Also check cgroup
176-
if Path("/proc/1/cgroup").exists():
177-
try:
178-
with open("/proc/1/cgroup") as f:
179-
cgroup_content = f.read()
180-
if "docker" in cgroup_content or "containerd" in cgroup_content:
181-
is_docker = True
182-
except OSError:
183-
pass
182+
cgroup_content = _read_probe_file(Path("/proc/1/cgroup"))
183+
if cgroup_content and ("docker" in cgroup_content or "containerd" in cgroup_content):
184+
is_docker = True
184185

185186
# Kubernetes detection
186187
if os.getenv("KUBERNETES_SERVICE_HOST"):
@@ -201,14 +202,11 @@ def _detect_wsl() -> bool:
201202
return True
202203

203204
# Check /proc/version for Microsoft/WSL signatures
204-
if Path("/proc/version").exists():
205-
try:
206-
with open("/proc/version") as f:
207-
version_info = f.read().lower()
208-
if "microsoft" in version_info or "wsl" in version_info:
209-
return True
210-
except OSError:
211-
pass
205+
version_info = _read_probe_file(Path("/proc/version"))
206+
if version_info:
207+
version_info = version_info.lower()
208+
if "microsoft" in version_info or "wsl" in version_info:
209+
return True
212210

213211
# Check for Windows filesystem mounts (WSL mounts Windows drives at /mnt/)
214212
# This is less reliable but can catch WSL 1

tests/test_environment.py

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,29 @@
1717
_detect_container,
1818
_detect_linux_distro,
1919
_detect_python_env,
20+
_detect_wsl,
2021
_has_sudo_access,
22+
_read_probe_file,
2123
detect_environment,
2224
)
2325

2426

27+
class TestProbeFileReads:
28+
"""Test best-effort probe file reads."""
29+
30+
def test_read_probe_file_returns_none_when_missing(self, tmp_path: Path) -> None:
31+
"""Missing probe files return None."""
32+
assert _read_probe_file(tmp_path / "missing") is None
33+
34+
def test_read_probe_file_returns_none_when_unreadable(self, tmp_path: Path) -> None:
35+
"""Unreadable probe files return None instead of raising."""
36+
probe_file = tmp_path / "probe"
37+
probe_file.write_text("value")
38+
39+
with mock.patch("builtins.open", side_effect=OSError("permission denied")):
40+
assert _read_probe_file(probe_file) is None
41+
42+
2543
class TestLinuxDistroDetection:
2644
"""Test Linux distribution detection."""
2745

@@ -128,6 +146,40 @@ def path_constructor(path_str: str) -> mock.Mock:
128146
assert distro == "ubuntu"
129147
assert version == "22.04"
130148

149+
def test_detect_linux_distro_skips_unreadable_os_release(self) -> None:
150+
"""Unreadable /etc/os-release falls back to /usr/lib/os-release."""
151+
etc_path = mock.Mock()
152+
etc_path.exists.return_value = True
153+
154+
usr_path = mock.Mock()
155+
usr_path.exists.return_value = True
156+
157+
def path_constructor(path_str: str) -> mock.Mock:
158+
if path_str == "/etc/os-release":
159+
return etc_path
160+
elif path_str == "/usr/lib/os-release":
161+
return usr_path
162+
fallback_path = mock.Mock()
163+
fallback_path.exists.return_value = False
164+
return fallback_path
165+
166+
usr_open = mock.mock_open(read_data='ID=ubuntu\nVERSION_ID="22.04"')
167+
168+
def open_side_effect(path: mock.Mock) -> mock.MagicMock:
169+
if path is etc_path:
170+
raise OSError("permission denied")
171+
if path is usr_path:
172+
return usr_open()
173+
raise AssertionError(f"unexpected probe path: {path!r}")
174+
175+
with (
176+
mock.patch("promptfoo.environment.Path", side_effect=path_constructor),
177+
mock.patch("builtins.open", side_effect=open_side_effect),
178+
):
179+
distro, version = _detect_linux_distro()
180+
assert distro == "ubuntu"
181+
assert version == "22.04"
182+
131183

132184
class TestCloudProviderDetection:
133185
"""Test cloud provider detection."""
@@ -184,6 +236,20 @@ def test_no_cloud_provider_detected(self) -> None:
184236
provider = _detect_cloud_provider()
185237
assert provider is None
186238

239+
def test_detect_cloud_provider_ignores_unreadable_probe_files(self, monkeypatch: pytest.MonkeyPatch) -> None:
240+
"""Unreadable cloud metadata files fall back to environment variables."""
241+
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "my-project")
242+
243+
path_mock = mock.Mock()
244+
path_mock.exists.return_value = True
245+
246+
with (
247+
mock.patch("promptfoo.environment.Path", return_value=path_mock),
248+
mock.patch("builtins.open", side_effect=OSError("permission denied")),
249+
):
250+
provider = _detect_cloud_provider()
251+
assert provider == "gcp"
252+
187253

188254
class TestContainerDetection:
189255
"""Test container detection."""
@@ -204,6 +270,23 @@ def test_detect_container_returns_tuple(self) -> None:
204270
assert isinstance(is_docker, bool)
205271
assert isinstance(is_k8s, bool)
206272

273+
def test_detect_container_ignores_unreadable_cgroup(self) -> None:
274+
"""Unreadable cgroup metadata does not raise."""
275+
276+
def path_constructor(path_str: str) -> mock.Mock:
277+
path_mock = mock.Mock()
278+
path_mock.exists.return_value = path_str == "/proc/1/cgroup"
279+
return path_mock
280+
281+
with (
282+
mock.patch("promptfoo.environment.Path", side_effect=path_constructor),
283+
mock.patch("builtins.open", side_effect=OSError("permission denied")),
284+
mock.patch.dict(os.environ, {}, clear=True),
285+
):
286+
is_docker, is_k8s = _detect_container()
287+
assert is_docker is False
288+
assert is_k8s is False
289+
207290

208291
class TestWSLDetection:
209292
"""Test WSL detection."""
@@ -212,28 +295,37 @@ def test_detect_wsl_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
212295
"""Detect WSL from WSL_DISTRO_NAME environment variable."""
213296
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
214297

215-
from promptfoo.environment import _detect_wsl
216-
217298
assert _detect_wsl() is True
218299

219300
def test_detect_wsl_from_interop_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
220301
"""Detect WSL from WSL_INTEROP environment variable."""
221302
monkeypatch.setenv("WSL_INTEROP", "/run/WSL/123_interop")
222303

223-
from promptfoo.environment import _detect_wsl
224-
225304
assert _detect_wsl() is True
226305

227306
def test_no_wsl_detected(self) -> None:
228307
"""Return False when not in WSL."""
229308
with mock.patch.dict(os.environ, {}, clear=True):
230-
from promptfoo.environment import _detect_wsl
231-
232309
# This will return False unless we're actually in WSL
233310
# Just verify it returns a boolean
234311
result = _detect_wsl()
235312
assert isinstance(result, bool)
236313

314+
def test_detect_wsl_ignores_unreadable_proc_version(self) -> None:
315+
"""Unreadable /proc/version does not raise."""
316+
317+
def path_constructor(path_str: str) -> mock.Mock:
318+
path_mock = mock.Mock()
319+
path_mock.exists.return_value = path_str == "/proc/version"
320+
return path_mock
321+
322+
with (
323+
mock.patch("promptfoo.environment.Path", side_effect=path_constructor),
324+
mock.patch("builtins.open", side_effect=OSError("permission denied")),
325+
mock.patch.dict(os.environ, {}, clear=True),
326+
):
327+
assert _detect_wsl() is False
328+
237329

238330
class TestCIDetection:
239331
"""Test CI/CD platform detection."""

0 commit comments

Comments
 (0)