diff --git a/src/runpod_flash/core/credentials.py b/src/runpod_flash/core/credentials.py index a984bf32..0abc1888 100644 --- a/src/runpod_flash/core/credentials.py +++ b/src/runpod_flash/core/credentials.py @@ -51,10 +51,35 @@ def get_api_key() -> Optional[str]: creds = get_credentials() except Exception: log.debug("Failed to read credentials file", exc_info=True) - return None - if creds and isinstance(creds.get("api_key"), str) and creds["api_key"].strip(): - return creds["api_key"] + else: + if creds and isinstance(creds.get("api_key"), str) and creds["api_key"].strip(): + return creds["api_key"] + + return _get_runpodctl_api_key() + +def _get_runpodctl_api_key() -> Optional[str]: + """Read runpodctl's top-level `apikey` from the config file. + + runpodctl writes a top-level `apikey` (and `apiurl`) key with no profile + table, which runpod-python's profile-based lookup cannot see. Fall back to + parsing the file directly so a config written by runpodctl authenticates + flash. Returns None when the file or key is missing, malformed, or blank. + """ + try: + import tomllib + except ImportError: + import tomli as tomllib + + try: + with get_credentials_path().open("rb") as f: + data = tomllib.load(f) + except (OSError, ValueError): + log.debug("Failed to read credentials file for runpodctl apikey", exc_info=True) + return None + api_key = data.get("apikey") + if isinstance(api_key, str) and api_key.strip(): + return api_key return None diff --git a/tests/unit/test_credentials.py b/tests/unit/test_credentials.py index 5ef368a2..066a530c 100644 --- a/tests/unit/test_credentials.py +++ b/tests/unit/test_credentials.py @@ -11,6 +11,8 @@ else: import tomli as tomllib +import pytest + from runpod_flash.core.credentials import ( get_api_key, get_credentials_path, @@ -57,6 +59,37 @@ def test_handles_corrupt_credentials_file(self, isolate_credentials_file): isolate_credentials_file.write_text("not valid toml {{{{") assert get_api_key() is None + def test_falls_back_to_runpodctl_top_level_apikey(self, isolate_credentials_file): + """A config.toml written by runpodctl (top-level `apikey`, no [default] + profile) must authenticate flash.""" + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text( + "apikey = 'rpa_runpodctl_key'\napiurl = 'https://api.runpod.io/graphql'\n" + ) + assert get_api_key() == "rpa_runpodctl_key" + + def test_default_profile_takes_precedence_over_runpodctl_apikey( + self, isolate_credentials_file + ): + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text( + "apikey = 'rpa_runpodctl_key'\n[default]\napi_key = 'flash-key'\n" + ) + assert get_api_key() == "flash-key" + + def test_ignores_blank_runpodctl_apikey(self, isolate_credentials_file): + isolate_credentials_file.parent.mkdir(parents=True, exist_ok=True) + isolate_credentials_file.write_text("apikey = ' '\n") + assert get_api_key() is None + + def test_no_file_still_raises_runpod_api_key_error(self, isolate_credentials_file): + """With no credentials file at all, validation must raise.""" + from runpod_flash.core.exceptions import RunpodAPIKeyError + from runpod_flash.core.validation import validate_api_key + + with pytest.raises(RunpodAPIKeyError): + validate_api_key() + class TestSaveApiKey: def test_creates_file_and_directories(self, isolate_credentials_file):