diff --git a/packages/core/ai_core_sdk/credentials.py b/packages/core/ai_core_sdk/credentials.py index ca3874d..6499a4b 100644 --- a/packages/core/ai_core_sdk/credentials.py +++ b/packages/core/ai_core_sdk/credentials.py @@ -9,7 +9,7 @@ from ai_core_sdk.helpers import get_home from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, AUTH_ENDPOINT_SUFFIX, CONFIG_FILE_ENV_VAR, PROFILE_ENV_VAR, - VCAP_AICORE_SERVICE_NAME, VCAP_SERVICES_ENV_VAR) + SERVICE_KEY_ENV_VAR, VCAP_AICORE_SERVICE_NAME, VCAP_SERVICES_ENV_VAR) from ai_core_sdk.helpers.logging import get_logger logger = get_logger() @@ -241,6 +241,37 @@ def _str_or_none(value) -> Optional[str]: return str(value) if value else None +def _parse_service_key(credential_values: List[CredentialsValue]) -> Optional[Callable[[CredentialsValue], Optional[str]]]: + """Return a source getter for AICORE_SERVICE_KEY if the env var is set and valid JSON, else None. + + AICORE_SERVICE_KEY is expected to be the raw JSON string of a BTP service key, i.e. the + ``credentials`` object from a VCAP_SERVICES binding without the outer envelope. Credential + fields are extracted using the ``vcap_key`` paths already defined on each ``CredentialsValue``, + but with the leading ``'credentials'`` segment stripped (same as the CLI's load_service_key). + """ + raw = os.environ.get(SERVICE_KEY_ENV_VAR) + if not raw: + return None + try: + service_key = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError( + f"{SERVICE_KEY_ENV_VAR} is set but contains invalid JSON: {exc}" + ) from exc + + def _get(cv: CredentialsValue) -> Optional[str]: + if not cv.vcap_key: + return None + # vcap_key is e.g. ('credentials', 'clientid') — drop the 'credentials' prefix + key_path = cv.vcap_key[1:] + try: + return _str_or_none(get_nested_value(service_key, key_path)) + except KeyError: + return None + + return _get + + def fetch_credentials(profile: str = None, credential_values: List[CredentialsValue] = CORE_CREDENTIAL_VALUES, validate: bool = True, **kwargs) -> Dict[str, str]: """ @@ -261,11 +292,17 @@ def fetch_credentials(profile: str = None, credential_values: List[CredentialsVa except KeyError: vcap_service = None + service_key_getter = _parse_service_key(credential_values) + sources = [ Source("kwargs", lambda cv: _str_or_none(kwargs.get(cv.name))), Source("environment variables", lambda cv: _str_or_none(os.environ.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), + *( + [Source(SERVICE_KEY_ENV_VAR, service_key_getter)] + if service_key_getter is not None else [] + ), Source("config file", lambda cv: _str_or_none(config.get(f'{AI_CORE_PREFIX}_{cv.name.upper()}'))), Source("VCAP service", diff --git a/packages/core/ai_core_sdk/helpers/constants.py b/packages/core/ai_core_sdk/helpers/constants.py index 7091330..ba2c5b2 100644 --- a/packages/core/ai_core_sdk/helpers/constants.py +++ b/packages/core/ai_core_sdk/helpers/constants.py @@ -8,6 +8,7 @@ DEFAULT_HOME_PATH = os.path.join(os.path.expanduser('~'), '.aicore') HOME_PATH_ENV_VAR = f'{AI_CORE_PREFIX}_HOME' PROFILE_ENV_VAR = f'{AI_CORE_PREFIX}_PROFILE' +SERVICE_KEY_ENV_VAR = f'{AI_CORE_PREFIX}_SERVICE_KEY' VCAP_AICORE_SERVICE_NAME = 'aicore' VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES' diff --git a/packages/core/docs/ai_core_sdk.credentials.html b/packages/core/docs/ai_core_sdk.credentials.html index 3621959..6e4a75d 100644 --- a/packages/core/docs/ai_core_sdk.credentials.html +++ b/packages/core/docs/ai_core_sdk.credentials.html @@ -254,6 +254,7 @@ NoDefault = NoDefault
Optional = typing.Optional
PROFILE_ENV_VAR = 'AICORE_PROFILE'
+SERVICE_KEY_ENV_VAR = 'AICORE_SERVICE_KEY'
Tuple = typing.Tuple
VCAP_AICORE_SERVICE_NAME = 'aicore'
VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES'
diff --git a/packages/core/docs/ai_core_sdk.helpers.constants.html b/packages/core/docs/ai_core_sdk.helpers.constants.html index ed3acc2..3c4437e 100644 --- a/packages/core/docs/ai_core_sdk.helpers.constants.html +++ b/packages/core/docs/ai_core_sdk.helpers.constants.html @@ -96,6 +96,7 @@ DEFAULT_HOME_PATH = '/home/runner/.aicore'
HOME_PATH_ENV_VAR = 'AICORE_HOME'
PROFILE_ENV_VAR = 'AICORE_PROFILE'
+SERVICE_KEY_ENV_VAR = 'AICORE_SERVICE_KEY'
VCAP_AICORE_SERVICE_NAME = 'aicore'
VCAP_SERVICES_ENV_VAR = 'VCAP_SERVICES' \ No newline at end of file diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index bc7784a..86a1d0b 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -37,10 +37,17 @@ dev = [ "pyhamcrest==2.1.0", "pytest-dotenv>=0.5.2", ] +docs = [ + "sphinx<9.0.0", + "sphinxawesome-theme", +] [tool.pytest.ini_options] testpaths = ["tests"] norecursedirs = ["integration_tests"] +# Prevent pytest-dotenv from loading the repo-root .env (which contains real credentials) +# into unit tests. Integration tests load it explicitly via conftest. +env_files = [] [project.scripts] aicore = "ai_core_sdk.cli:cli" diff --git a/packages/core/tests/ai_core_client/test_credentials.py b/packages/core/tests/ai_core_client/test_credentials.py index 26b779d..d803e38 100644 --- a/packages/core/tests/ai_core_client/test_credentials.py +++ b/packages/core/tests/ai_core_client/test_credentials.py @@ -15,7 +15,7 @@ init_conf, CORE_CREDENTIAL_VALUES, ) from ai_core_sdk.helpers.constants import (AI_CORE_PREFIX, HOME_PATH_ENV_VAR, PROFILE_ENV_VAR, VCAP_SERVICES_ENV_VAR, - VCAP_AICORE_SERVICE_NAME, CONFIG_FILE_ENV_VAR) + VCAP_AICORE_SERVICE_NAME, CONFIG_FILE_ENV_VAR, SERVICE_KEY_ENV_VAR) VCAP_SERVICE_DICT = { VCAP_AICORE_SERVICE_NAME: [{ @@ -290,6 +290,77 @@ def test_init_conf_permission_error(self, mock_logger): # Restore permissions for cleanup in teardown config_file.chmod(0o644) + @patch('ai_core_sdk.credentials.logger') + def test_fetch_credentials_from_service_key(self, mock_logger): + mock_logger.debug = MagicMock() + + service_key = { + 'clientid': 'sk-client-id', + 'clientsecret': 'sk-client-secret', + 'url': 'https://sk-auth-url', + 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, + } + with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: json.dumps(service_key)}): + credentials = fetch_credentials() + + self.assertEqual(credentials['client_id'], 'sk-client-id') + self.assertEqual(credentials['client_secret'], 'sk-client-secret') + self.assertEqual(credentials['auth_url'], 'https://sk-auth-url/oauth/token') + self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') + mock_logger.debug.assert_any_call(f"Using credentials from: {SERVICE_KEY_ENV_VAR}") + + @patch('ai_core_sdk.credentials.logger') + def test_fetch_credentials_from_service_key_x509(self, mock_logger): + mock_logger.debug = MagicMock() + + service_key = { + 'clientid': 'sk-client-id', + 'certurl': 'https://sk-cert-url', + 'certificate': 'sk-cert-content', + 'key': 'sk-key-content', + 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, + } + with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: json.dumps(service_key)}): + credentials = fetch_credentials() + + self.assertEqual(credentials['client_id'], 'sk-client-id') + self.assertEqual(credentials['cert_str'], 'sk-cert-content') + self.assertEqual(credentials['key_str'], 'sk-key-content') + self.assertEqual(credentials['auth_url'], 'https://sk-cert-url/oauth/token') + self.assertEqual(credentials['base_url'], 'https://sk-api-url/v2') + mock_logger.debug.assert_any_call(f"Using credentials from: {SERVICE_KEY_ENV_VAR}") + + @patch('ai_core_sdk.credentials.logger') + def test_service_key_lower_precedence_than_env_vars(self, mock_logger): + mock_logger.debug = MagicMock() + + service_key = { + 'clientid': 'sk-client-id', + 'clientsecret': 'sk-client-secret', + 'url': 'https://sk-auth-url', + 'serviceurls': {'AI_API_URL': 'https://sk-api-url'}, + } + with patch.dict(os.environ, { + SERVICE_KEY_ENV_VAR: json.dumps(service_key), + f'{AI_CORE_PREFIX}_CLIENT_ID': 'env-client-id', + f'{AI_CORE_PREFIX}_CLIENT_SECRET': 'env-client-secret', + f'{AI_CORE_PREFIX}_AUTH_URL': 'https://env-auth-url', + f'{AI_CORE_PREFIX}_BASE_URL': 'https://env-base-url', + }): + credentials = fetch_credentials() + + # env vars win + self.assertEqual(credentials['client_id'], 'env-client-id') + self.assertEqual(credentials['client_secret'], 'env-client-secret') + mock_logger.debug.assert_any_call("Using credentials from: environment variables") + + def test_service_key_invalid_json_raises(self): + with patch.dict(os.environ, {SERVICE_KEY_ENV_VAR: 'not-valid-json'}): + with self.assertRaises(ValueError) as ctx: + fetch_credentials() + self.assertIn(SERVICE_KEY_ENV_VAR, str(ctx.exception)) + self.assertIn('invalid JSON', str(ctx.exception)) + @patch('ai_core_sdk.credentials.logger') def test_injecting_credential_values(self, mock_logger): mock_logger.debug = MagicMock() diff --git a/uv.lock b/uv.lock index 461ae24..e1f806f 100644 --- a/uv.lock +++ b/uv.lock @@ -4118,6 +4118,11 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-dotenv" }, ] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinxawesome-theme" }, +] [package.metadata] requires-dist = [ @@ -4133,6 +4138,10 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-dotenv", specifier = ">=0.5.2" }, ] +docs = [ + { name = "sphinx", specifier = "<9.0.0" }, + { name = "sphinxawesome-theme" }, +] [[package]] name = "sap-ai-sdk-gen"