diff --git a/runpod/apps/volume.py b/runpod/apps/volume.py index 943c80b5..35579842 100644 --- a/runpod/apps/volume.py +++ b/runpod/apps/volume.py @@ -2,6 +2,7 @@ import logging import json +import os from abc import ABC, abstractmethod from collections.abc import Mapping from pathlib import Path, PurePosixPath @@ -43,6 +44,7 @@ def reference(self) -> str: @property def path(self) -> Path: """the unique mount path for this volume in the current worker.""" + _ensure_mounts_configured() paths = _mount_bindings.get((self.kind, self.reference), ()) if not paths: raise VolumeError( @@ -130,6 +132,27 @@ def normalize_mounts(mounts: Optional[Mapping[str, Volume]]) -> Dict[str, Volume _mount_bindings: Mapping[Tuple[str, str], Tuple[Path, ...]] = MappingProxyType({}) +_mounts_configured = False + + +def _ensure_mounts_configured() -> None: + """bind mounts from RUNPOD_MOUNTS if the worker runtime did not. + + the runtime calls _configure_mounts before user code, but runtime + images released before that hook never do. deployed workers run + this vendored package, so reading the provisioning env here keeps + vol.path working regardless of the image's runtime version. + """ + if _mounts_configured: + return + raw = os.environ.get("RUNPOD_MOUNTS") + if not raw: + return + try: + bindings = json.loads(raw) + except json.JSONDecodeError as exc: + raise VolumeError("RUNPOD_MOUNTS must contain a valid JSON array") from exc + _configure_mounts(bindings) def _configure_mounts(bindings: List[Dict[str, str]]) -> None: @@ -153,7 +176,8 @@ def _configure_mounts(bindings: List[Dict[str, str]]) -> None: paths = resolved.setdefault(key, []) if path not in paths: paths.append(path) - global _mount_bindings + global _mount_bindings, _mounts_configured + _mounts_configured = True _mount_bindings = MappingProxyType( {key: tuple(paths) for key, paths in resolved.items()} ) diff --git a/tests/test_apps/test_volume.py b/tests/test_apps/test_volume.py index 70c255db..3ad36829 100644 --- a/tests/test_apps/test_volume.py +++ b/tests/test_apps/test_volume.py @@ -109,6 +109,48 @@ def test_multiple_bindings_require_explicit_path(self): with pytest.raises(VolumeError): NetworkVolume("nv-1").path + def test_path_binds_from_env_when_runtime_did_not(self, monkeypatch): + # runtime images released before the mounts hook never call + # _configure_mounts; the vendored sdk reads the env itself + import runpod.apps.volume as volume_mod + + monkeypatch.setattr(volume_mod, "_mounts_configured", False) + monkeypatch.setattr(volume_mod, "_mount_bindings", {}) + monkeypatch.setenv( + "RUNPOD_MOUNTS", + json.dumps( + [ + { + "kind": "network", + "reference": "models", + "id": "nv-1", + "path": "/runpod-volume", + } + ] + ), + ) + assert str(NetworkVolume("models").path) == "/runpod-volume" + assert str(NetworkVolume("nv-1").path) == "/runpod-volume" + + def test_runtime_configuration_wins_over_env(self, monkeypatch): + monkeypatch.setenv( + "RUNPOD_MOUNTS", + json.dumps( + [{"kind": "network", "reference": "models", "id": "nv-1", "path": "/x"}] + ), + ) + # clean_mounts already configured an empty set, as the runtime does + with pytest.raises(VolumeError, match="not mounted"): + NetworkVolume("models").path + + def test_invalid_env_bindings_raise(self, monkeypatch): + import runpod.apps.volume as volume_mod + + monkeypatch.setattr(volume_mod, "_mounts_configured", False) + monkeypatch.setenv("RUNPOD_MOUNTS", "{not json") + with pytest.raises(VolumeError, match="valid JSON"): + NetworkVolume("models").path + def test_invalid_configuration_does_not_partially_publish(self, tmp_path): (tmp_path / "model").write_text("retained") binding = {