diff --git a/poetry.lock b/poetry.lock index 3fcb5eeb..841b11da 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6548,4 +6548,4 @@ otel = ["grpcio", "opentelemetry-api", "opentelemetry-exporter-otlp", "opentelem [metadata] lock-version = "2.1" python-versions = "^3.11,<3.15" -content-hash = "f75a182570e1129b6b65927aac9d700eae02d507391e7d2d6850d8910e4d55bf" +content-hash = "9a1aa0f31bd36887b09083ff5558b573e5374253dc997bd26c733f8a6f774baf" diff --git a/pyproject.toml b/pyproject.toml index fb68b278..41125d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ openai = { version = ">=2.8.0,<3.0.0", optional = true } openai-agents = { version = ">=0.4.0,<1.0.0", optional = true } litellm = { version = ">=1.83.14,<2.0.0", optional = true, python = ">=3.11,<3.14" } galileo-core = "^4.3.0" +httpx = ">=0.27.0,<0.29.0" starlette = { version = ">=0.27.0", optional = true } backoff = "^2.2.1" crewai = { version = ">=0.152.0,<2.0.0", optional = true, python = ">=3.11,<3.14" } diff --git a/src/splunk_ao/__init__.py b/src/splunk_ao/__init__.py index 15dd4e32..38b0611c 100644 --- a/src/splunk_ao/__init__.py +++ b/src/splunk_ao/__init__.py @@ -47,10 +47,13 @@ from splunk_ao.schema.metrics import SplunkAOMetrics from splunk_ao.shared.base import SyncState from splunk_ao.shared.exceptions import ( + AmbiguousConfigurationError, APIError, ConfigurationError, + MissingConfigurationError, ResourceConflictError, ResourceNotFoundError, + SplunkAOConfigError, SplunkAOFutureError, ValidationError, ) @@ -65,6 +68,7 @@ "AgentControlTarget", "AgentControlTargetUnresolvedError", "AgentSpan", + "AmbiguousConfigurationError", "AnthropicProvider", "AuthenticationError", "AzureProvider", @@ -93,6 +97,7 @@ "MessageRole", "Metric", "MetricSpec", + "MissingConfigurationError", "Model", "NotFoundError", "OpenAIProvider", @@ -108,6 +113,7 @@ "Span", "SplunkAOAPIError", "SplunkAOAgentControlBridge", + "SplunkAOConfigError", "SplunkAODecorator", "SplunkAOFutureError", "SplunkAOLogger", diff --git a/src/splunk_ao/config.py b/src/splunk_ao/config.py index c2814757..3a8b8c27 100644 --- a/src/splunk_ao/config.py +++ b/src/splunk_ao/config.py @@ -1,16 +1,53 @@ # mypy: disable-error-code=syntax # We need to ignore syntax errors until https://github.com/python/mypy/issues/17535 is resolved. import os +from collections.abc import Iterator from typing import Any, ClassVar, Optional +from httpx import Response +from pydantic import SecretStr, ValidationInfo, field_validator, model_validator from pydantic_core import Url +from galileo_core.constants.request_method import RequestMethod +from galileo_core.helpers.api_client import ApiClient from galileo_core.schemas.base_config import GalileoConfig from splunk_ao.constants import DEFAULT_CONSOLE_URL -from splunk_ao.shared.exceptions import ConfigurationError +from splunk_ao.deployment import DeploymentMode, O11yConfig +from splunk_ao.deployment import resolve_deployment as _resolve_deployment +from splunk_ao.shared.exceptions import ConfigurationError, MissingConfigurationError + + +class O11yApiClient(ApiClient): + """API client for Splunk Observability Cloud AO endpoints.""" + + sf_token: SecretStr + path_prefix: str = "/v2/ao" + + @property + def auth_header(self) -> dict[str, str]: + return {"X-SF-Token": self.sf_token.get_secret_value()} + + def _prefixed(self, path: str) -> str: + normalized_path = f"/{path.lstrip('/')}" + prefix = self.path_prefix.strip("/") + if not prefix: + return normalized_path + + normalized_prefix = f"/{prefix}" + if normalized_path == normalized_prefix or normalized_path.startswith(f"{normalized_prefix}/"): + return normalized_path + return f"{normalized_prefix}{normalized_path}" + + async def arequest(self, method: RequestMethod, path: str, *args: Any, **kwargs: Any) -> Any: + return await super().arequest(method, self._prefixed(path), *args, **kwargs) + + def stream_request(self, method: RequestMethod, path: str, *args: Any, **kwargs: Any) -> Iterator[Response]: + return super().stream_request(method, self._prefixed(path), *args, **kwargs) class SplunkAOConfig(GalileoConfig): + """Configure authentication and endpoints for standalone and O11y deployments.""" + # Config file for this project. config_filename: str = "galileo-python-config.json" console_url: Url = DEFAULT_CONSOLE_URL @@ -21,8 +58,60 @@ def reset(self) -> None: SplunkAOConfig._instance = None super().reset() + @classmethod + def resolve_deployment(cls) -> DeploymentMode: + """Infer the deployment mode from configured environment variables.""" + return _resolve_deployment() + + @classmethod + def _is_o11y_env(cls) -> bool: + try: + return cls.resolve_deployment() == DeploymentMode.O11Y + except MissingConfigurationError: + return False + + @field_validator("api_url", mode="before") + @classmethod + def set_api_url(cls, api_url: str | Url | None, info: ValidationInfo) -> Url: + """Derive the O11y API URL from its realm and preserve standalone validation.""" + if cls._is_o11y_env(): + return Url(O11yConfig.from_env().require_api_url()) + return super().set_api_url(api_url, info) + + @model_validator(mode="after") + def set_jwt_token(self) -> "SplunkAOConfig": + """Skip standalone JWT exchange when O11y uses direct SF-token authentication.""" + if self._is_o11y_env(): + self.jwt_token = None + self.refresh_token = None + return self + super().set_jwt_token() + return self + + @model_validator(mode="after") + def set_validated_api_client(self) -> "SplunkAOConfig": + """Use the SF-token aware API client for O11y deployments.""" + if self._is_o11y_env(): + o11y = O11yConfig.from_env() + self.validated_api_client = O11yApiClient( + host=o11y.api_root, sf_token=o11y.crud_token, jwt_token=SecretStr(""), ssl_context=self.ssl_context + ) + return self + super().set_validated_api_client() + return self + + def _uses_o11y_api_client(self) -> bool: + return isinstance(self.validated_api_client, O11yApiClient) + + def refresh_jwt_token(self) -> None: + """Skip JWT refresh when authenticating directly with an O11y SF token.""" + if self._uses_o11y_api_client(): + return + super().refresh_jwt_token() + @classmethod def get(cls, **kwargs: Any) -> "SplunkAOConfig": + """Initialize the shared config with deployment-aware auth validation.""" if cls._instance is None: cls._bridge_env_vars() error_message = cls._check_auth_config(kwargs) @@ -32,8 +121,8 @@ def get(cls, **kwargs: Any) -> "SplunkAOConfig": assert cls._instance is not None, "Failed to initialize SplunkAOConfig" return cls._instance - @staticmethod - def _bridge_env_vars() -> None: + @classmethod + def _bridge_env_vars(cls) -> None: """Bridge SPLUNK_AO_* env vars into GALILEO_* for galileo-core compatibility. galileo-core still reads GALILEO_* env vars. Until galileo-core is updated, @@ -42,7 +131,10 @@ def _bridge_env_vars() -> None: Only bridges values that are not already set — explicit GALILEO_* overrides win. """ - _BRIDGE = [ + if cls._is_o11y_env() and "GALILEO_CONSOLE_URL" not in os.environ: + os.environ["GALILEO_CONSOLE_URL"] = O11yConfig.from_env().require_console_url() + + bridge = [ ("SPLUNK_AO_API_KEY", "GALILEO_API_KEY"), ("SPLUNK_AO_API_URL", "GALILEO_API_URL"), ("SPLUNK_AO_CONSOLE_URL", "GALILEO_CONSOLE_URL"), @@ -57,7 +149,7 @@ def _bridge_env_vars() -> None: ("SPLUNK_AO_PASSWORD", "GALILEO_PASSWORD"), ("SPLUNK_AO_MODE", "GALILEO_MODE"), ] - for new_key, old_key in _BRIDGE: + for new_key, old_key in bridge: if new_key in os.environ and old_key not in os.environ: os.environ[old_key] = os.environ[new_key] @@ -70,13 +162,15 @@ def _check_auth_config(kwargs: dict) -> str | None: message identifying what's missing. Auth methods supported by the underlying config model: + - SF tokens (o11y): SPLUNK_AO_SF_TOKEN and optional SPLUNK_AO_SF_API_TOKEN env vars - API key (standalone): api_key kwarg or SPLUNK_AO_API_KEY env - Pre-exchanged JWT (standalone): jwt_token or SPLUNK_AO_JWT_TOKEN - SSO (paired): sso_id_token + sso_provider, both kwargs and env vars - Username/password (paired): username + password, both kwargs and env vars - Kwargs and env vars are interchangeable — e.g. sso_id_token can come - from a kwarg while sso_provider comes from the environment. + For standalone auth, kwargs and env vars are interchangeable — e.g. + sso_id_token can come from a kwarg while sso_provider comes from the + environment. O11y tokens are environment-only. """ def _val(kwarg_name: str, env_name: str) -> str | None: @@ -85,6 +179,9 @@ def _val(kwarg_name: str, env_name: str) -> str | None: return str(value) return os.environ.get(env_name) + if os.environ.get("SPLUNK_AO_SF_TOKEN") or os.environ.get("SPLUNK_AO_SF_API_TOKEN"): + return None + # Standalone methods — either alone is sufficient. if _val("api_key", "SPLUNK_AO_API_KEY"): return None @@ -128,8 +225,8 @@ def _val(kwarg_name: str, env_name: str) -> str | None: # Nothing configured anywhere. return ( - "No Splunk AO authentication detected. Set one of: " - "SPLUNK_AO_API_KEY; SPLUNK_AO_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; " + "No Splunk AO authentication detected. Set one of: SPLUNK_AO_REALM with " + "SPLUNK_AO_SF_TOKEN; SPLUNK_AO_API_KEY; SPLUNK_AO_SSO_ID_TOKEN with SPLUNK_AO_SSO_PROVIDER; " "or SPLUNK_AO_USERNAME with SPLUNK_AO_PASSWORD. " "Alternatively, pass the equivalent kwargs to SplunkAOConfig.get(). " "See https://docs.splunk.com for setup instructions." diff --git a/src/splunk_ao/deployment.py b/src/splunk_ao/deployment.py new file mode 100644 index 00000000..2af4ae26 --- /dev/null +++ b/src/splunk_ao/deployment.py @@ -0,0 +1,136 @@ +"""Deployment detection and mode-specific configuration.""" + +import os +from dataclasses import dataclass +from enum import StrEnum + +from pydantic import SecretStr + +from splunk_ao.shared.exceptions import AmbiguousConfigurationError, MissingConfigurationError + +_O11Y_ENV_VARS = ("SPLUNK_AO_REALM", "SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN") +_STANDALONE_ENV_VARS = ("SPLUNK_AO_API_KEY", "SPLUNK_AO_CONSOLE_URL", "SPLUNK_AO_API_URL") + + +class DeploymentMode(StrEnum): + """Supported Splunk AO deployment modes.""" + + O11Y = "o11y" + STANDALONE = "standalone" + + +def _env(name: str) -> str | None: + return os.environ.get(name) or None + + +def resolve_deployment() -> DeploymentMode: + """Infer the deployment mode from the configured environment variables.""" + present_o11y = [name for name in _O11Y_ENV_VARS if _env(name)] + present_standalone = [name for name in _STANDALONE_ENV_VARS if _env(name)] + + if present_o11y and present_standalone: + raise AmbiguousConfigurationError( + "Both o11y and standalone configuration detected. " + f"O11y variables set: {', '.join(present_o11y)}. " + f"Standalone variables set: {', '.join(present_standalone)}." + ) + if present_o11y: + return DeploymentMode.O11Y + if present_standalone: + return DeploymentMode.STANDALONE + + raise MissingConfigurationError( + "No Splunk AO deployment configuration detected. Set an o11y variable " + f"({', '.join(_O11Y_ENV_VARS)}) or a standalone variable " + f"({', '.join(_STANDALONE_ENV_VARS)})." + ) + + +@dataclass +class O11yConfig: + """Configuration for a Splunk Observability Cloud deployment.""" + + realm: str + sf_token: SecretStr + sf_api_token: SecretStr | None = None + + def __post_init__(self) -> None: + if not isinstance(self.sf_token, SecretStr): + self.sf_token = SecretStr(self.sf_token) + if self.sf_api_token is not None and not isinstance(self.sf_api_token, SecretStr): + self.sf_api_token = SecretStr(self.sf_api_token) + + @classmethod + def from_env(cls) -> "O11yConfig": + """Load and validate o11y configuration from the environment.""" + realm = _env("SPLUNK_AO_REALM") + sf_token = _env("SPLUNK_AO_SF_TOKEN") + + if realm is None or sf_token is None: + missing = [ + name for name, value in (("SPLUNK_AO_REALM", realm), ("SPLUNK_AO_SF_TOKEN", sf_token)) if value is None + ] + raise MissingConfigurationError(f"O11y deployment requires {' and '.join(missing)} to be set.") + + sf_api_token = _env("SPLUNK_AO_SF_API_TOKEN") + return cls( + realm=realm, sf_token=SecretStr(sf_token), sf_api_token=SecretStr(sf_api_token) if sf_api_token else None + ) + + @property + def otlp_endpoint(self) -> str: + """Return the realm-derived OTLP trace ingest endpoint.""" + return f"https://ingest.{self.realm}.observability.splunkcloud.com/v2/trace/otlp" + + @property + def crud_token(self) -> SecretStr: + """Return the API token when set, otherwise the ingest token.""" + return self.sf_api_token if self.sf_api_token is not None else self.sf_token + + @property + def api_root(self) -> str: + """Return the realm-derived AO API origin.""" + return f"https://api.{self.realm}.observability.splunkcloud.com" + + def require_api_url(self) -> str: + """Return the realm-derived AO CRUD API URL.""" + return f"{self.api_root}/v2/ao" + + def require_console_url(self) -> str: + """Return the realm-derived AO console URL.""" + return f"https://app.{self.realm}.observability.splunkcloud.com/#/ao" + + +@dataclass +class StandaloneConfig: + """Configuration for a standalone Splunk AO deployment.""" + + api_key: SecretStr + console_url: str + api_url: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.api_key, SecretStr): + self.api_key = SecretStr(self.api_key) + + @classmethod + def from_env(cls) -> "StandaloneConfig": + """Load and validate standalone configuration from the environment.""" + api_key = _env("SPLUNK_AO_API_KEY") + console_url = _env("SPLUNK_AO_CONSOLE_URL") + + if api_key is None or console_url is None: + missing = [ + name + for name, value in (("SPLUNK_AO_API_KEY", api_key), ("SPLUNK_AO_CONSOLE_URL", console_url)) + if value is None + ] + raise MissingConfigurationError(f"Standalone deployment requires {' and '.join(missing)} to be set.") + + return cls(api_key=SecretStr(api_key), console_url=console_url, api_url=_env("SPLUNK_AO_API_URL")) + + @property + def otlp_endpoint(self) -> str: + """Return the explicit or console-derived OTLP trace endpoint.""" + base = self.api_url or self.console_url.replace("://console.", "://api.", 1).replace("://app.", "://api.", 1) + return f"{base.rstrip('/')}/otel/traces" diff --git a/src/splunk_ao/shared/exceptions.py b/src/splunk_ao/shared/exceptions.py index 7533fd73..7d540ff0 100644 --- a/src/splunk_ao/shared/exceptions.py +++ b/src/splunk_ao/shared/exceptions.py @@ -30,6 +30,18 @@ class ValidationError(SplunkAOFutureError): """ +class SplunkAOConfigError(ConfigurationError): + """Base exception for deployment configuration errors.""" + + +class AmbiguousConfigurationError(SplunkAOConfigError): + """Raised when o11y and standalone configuration are both present.""" + + +class MissingConfigurationError(SplunkAOConfigError): + """Raised when required deployment configuration is absent.""" + + class ResourceNotFoundError(NotFoundError, SplunkAOFutureError): """ Backward-compatible alias for NotFoundError. diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 00000000..6a74871a --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,196 @@ +"""Tests for deployment-mode detection and per-mode config validation.""" + +import contextlib +import os +from collections.abc import Iterator + +import pytest + +from splunk_ao.config import SplunkAOConfig +from splunk_ao.deployment import DeploymentMode, O11yConfig, StandaloneConfig +from splunk_ao.shared.exceptions import AmbiguousConfigurationError, MissingConfigurationError + +_DETECTION_ENV_VARS = ( + "SPLUNK_AO_REALM", + "SPLUNK_AO_SF_TOKEN", + "SPLUNK_AO_SF_API_TOKEN", + "SPLUNK_AO_API_KEY", + "SPLUNK_AO_CONSOLE_URL", + "SPLUNK_AO_API_URL", +) +_DEPLOYMENT_ENV_VARS = _DETECTION_ENV_VARS + + +@contextlib.contextmanager +def env(**overrides: str) -> Iterator[None]: + """Temporarily replace deployment-related environment variables.""" + saved = {name: os.environ.pop(name, None) for name in _DEPLOYMENT_ENV_VARS} + try: + os.environ.update(overrides) + yield + finally: + for name in _DEPLOYMENT_ENV_VARS: + os.environ.pop(name, None) + for name, value in saved.items(): + if value is not None: + os.environ[name] = value + + +def test_autodetect_o11y_from_realm_and_sf_token() -> None: + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok"): + assert SplunkAOConfig.resolve_deployment() == DeploymentMode.O11Y + + +def test_autodetect_o11y_from_sf_api_token_only() -> None: + with env(SPLUNK_AO_SF_API_TOKEN="tok"): + assert SplunkAOConfig.resolve_deployment() == DeploymentMode.O11Y + + +def test_autodetect_standalone_from_api_key() -> None: + with env(SPLUNK_AO_API_KEY="key", SPLUNK_AO_CONSOLE_URL="https://ao.example.com"): + assert SplunkAOConfig.resolve_deployment() == DeploymentMode.STANDALONE + + +def test_autodetect_standalone_from_console_url_only() -> None: + with env(SPLUNK_AO_CONSOLE_URL="https://ao.example.com"): + assert SplunkAOConfig.resolve_deployment() == DeploymentMode.STANDALONE + + +def test_autodetect_standalone_from_api_url_only() -> None: + with env(SPLUNK_AO_API_URL="https://api.example.com"): + assert SplunkAOConfig.resolve_deployment() == DeploymentMode.STANDALONE + + +def test_o11y_and_standalone_api_url_are_ambiguous() -> None: + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_URL="https://stale-standalone.example.com"): + with pytest.raises(AmbiguousConfigurationError) as exc_info: + SplunkAOConfig.resolve_deployment() + assert "SPLUNK_AO_API_URL" in str(exc_info.value) + + +def test_ambiguous_raises_when_both_sets_present() -> None: + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_KEY="key"): + with pytest.raises(AmbiguousConfigurationError) as exc_info: + SplunkAOConfig.resolve_deployment() + assert "SPLUNK_AO_REALM" in str(exc_info.value) + assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value) + assert "SPLUNK_AO_API_KEY" in str(exc_info.value) + + +def test_missing_raises_when_neither_set() -> None: + with env(): + with pytest.raises(MissingConfigurationError) as exc_info: + SplunkAOConfig.resolve_deployment() + for name in _DETECTION_ENV_VARS: + assert name in str(exc_info.value) + + +def test_empty_values_do_not_select_a_deployment() -> None: + with env(SPLUNK_AO_REALM="", SPLUNK_AO_API_KEY=""): + with pytest.raises(MissingConfigurationError): + SplunkAOConfig.resolve_deployment() + + +def test_o11y_config_from_env() -> None: + with env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="ingest-tok", SPLUNK_AO_SF_API_TOKEN="api-tok"): + cfg = O11yConfig.from_env() + + assert cfg.realm == "us1" + assert cfg.sf_token.get_secret_value() == "ingest-tok" + assert cfg.sf_api_token is not None + assert cfg.sf_api_token.get_secret_value() == "api-tok" + + +def test_otlp_endpoint_derived_from_realm() -> None: + cfg = O11yConfig(realm="us1", sf_token="tok") + assert cfg.otlp_endpoint == "https://ingest.us1.observability.splunkcloud.com/v2/trace/otlp" + + +def test_crud_token_prefers_api_token() -> None: + cfg = O11yConfig(realm="us1", sf_token="ingest-tok", sf_api_token="api-tok") + assert cfg.crud_token.get_secret_value() == "api-tok" + + +def test_crud_token_falls_back_to_sf_token() -> None: + cfg = O11yConfig(realm="us1", sf_token="tok", sf_api_token=None) + assert cfg.crud_token.get_secret_value() == "tok" + + +def test_missing_realm_raises() -> None: + with env(SPLUNK_AO_SF_TOKEN="tok"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_REALM"): + O11yConfig.from_env() + + +def test_missing_sf_token_raises() -> None: + with env(SPLUNK_AO_REALM="us1"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_SF_TOKEN"): + O11yConfig.from_env() + + +def test_missing_o11y_config_names_both_required_variables() -> None: + with env(): + with pytest.raises(MissingConfigurationError) as exc_info: + O11yConfig.from_env() + assert "SPLUNK_AO_REALM" in str(exc_info.value) + assert "SPLUNK_AO_SF_TOKEN" in str(exc_info.value) + + +def test_api_root_derives_from_realm() -> None: + cfg = O11yConfig(realm="us1", sf_token="tok") + assert cfg.api_root == "https://api.us1.observability.splunkcloud.com" + + +def test_require_api_url_derives_from_realm() -> None: + cfg = O11yConfig(realm="us1", sf_token="tok") + assert cfg.require_api_url() == "https://api.us1.observability.splunkcloud.com/v2/ao" + assert cfg.require_api_url() == f"{cfg.api_root}/v2/ao" + + +def test_require_console_url_derives_from_realm() -> None: + cfg = O11yConfig(realm="us1", sf_token="tok") + assert cfg.require_console_url() == "https://app.us1.observability.splunkcloud.com/#/ao" + + +def test_standalone_config_happy_path() -> None: + with env(SPLUNK_AO_API_KEY="key", SPLUNK_AO_CONSOLE_URL="https://ao.example.com"): + cfg = StandaloneConfig.from_env() + assert cfg.console_url == "https://ao.example.com" + assert cfg.api_key.get_secret_value() == "key" + + +def test_missing_api_key_raises() -> None: + with env(SPLUNK_AO_CONSOLE_URL="https://ao.example.com"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_API_KEY"): + StandaloneConfig.from_env() + + +def test_missing_console_url_raises() -> None: + with env(SPLUNK_AO_API_KEY="key"): + with pytest.raises(MissingConfigurationError, match="SPLUNK_AO_CONSOLE_URL"): + StandaloneConfig.from_env() + + +def test_missing_standalone_config_names_both_required_variables() -> None: + with env(): + with pytest.raises(MissingConfigurationError) as exc_info: + StandaloneConfig.from_env() + assert "SPLUNK_AO_API_KEY" in str(exc_info.value) + assert "SPLUNK_AO_CONSOLE_URL" in str(exc_info.value) + + +def test_otlp_endpoint_derived_from_console_url_when_api_url_unset() -> None: + cfg = StandaloneConfig(api_key="key", console_url="https://console.demo.galileocloud.io") + assert cfg.otlp_endpoint == "https://api.demo.galileocloud.io/otel/traces" + + +def test_otlp_endpoint_derived_from_app_url() -> None: + cfg = StandaloneConfig(api_key="key", console_url="https://app.galileo.ai/") + assert cfg.otlp_endpoint == "https://api.galileo.ai/otel/traces" + + +def test_otlp_endpoint_uses_explicit_api_url_when_set() -> None: + cfg = StandaloneConfig( + api_key="key", console_url="https://console.demo.galileocloud.io", api_url="https://custom-api.example.com/" + ) + assert cfg.otlp_endpoint == "https://custom-api.example.com/otel/traces" diff --git a/tests/test_o11y_config.py b/tests/test_o11y_config.py new file mode 100644 index 00000000..c7f86600 --- /dev/null +++ b/tests/test_o11y_config.py @@ -0,0 +1,225 @@ +import contextlib +import os +from collections.abc import Iterator +from contextlib import nullcontext +from urllib.parse import urljoin +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from galileo_core.constants.request_method import RequestMethod +from galileo_core.constants.routes import Routes +from galileo_core.helpers.api_client import ApiClient +from galileo_core.schemas.base_config import GalileoConfig +from splunk_ao.config import O11yApiClient, SplunkAOConfig +from splunk_ao.shared.exceptions import AmbiguousConfigurationError + +_CONFIG_ENV_VARS = ( + "SPLUNK_AO_REALM", + "SPLUNK_AO_SF_TOKEN", + "SPLUNK_AO_SF_API_TOKEN", + "SPLUNK_AO_API_KEY", + "SPLUNK_AO_API_URL", + "SPLUNK_AO_CONSOLE_URL", + "SPLUNK_AO_JWT_TOKEN", + "SPLUNK_AO_SSO_ID_TOKEN", + "SPLUNK_AO_SSO_PROVIDER", + "SPLUNK_AO_USERNAME", + "SPLUNK_AO_PASSWORD", + "GALILEO_API_KEY", + "GALILEO_API_URL", + "GALILEO_CONSOLE_URL", + "GALILEO_JWT_TOKEN", + "GALILEO_REFRESH_TOKEN", + "GALILEO_SSO_ID_TOKEN", + "GALILEO_SSO_PROVIDER", + "GALILEO_USERNAME", + "GALILEO_PASSWORD", +) + + +@contextlib.contextmanager +def config_env(**overrides: str) -> Iterator[None]: + saved = {name: os.environ.pop(name, None) for name in _CONFIG_ENV_VARS} + try: + os.environ.update(overrides) + yield + finally: + for name in _CONFIG_ENV_VARS: + os.environ.pop(name, None) + for name, value in saved.items(): + if value is not None: + os.environ[name] = value + + +def _o11y_client(token: str = "tok") -> O11yApiClient: + client = O11yApiClient( + host="https://api.us1.observability.splunkcloud.com", sf_token=SecretStr(token), jwt_token=SecretStr("") + ) + client.thread_local.client = None + return client + + +def test_o11y_api_client_uses_sf_token_header() -> None: + assert _o11y_client("my-token").auth_header == {"X-SF-Token": "my-token"} + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/projects", "/v2/ao/projects"), + ("projects", "/v2/ao/projects"), + ("/v2/ao/projects", "/v2/ao/projects"), + ("/v2/ao", "/v2/ao"), + ], +) +def test_o11y_api_client_prefixes_paths_once(path: str, expected: str) -> None: + assert _o11y_client()._prefixed(path) == expected + + +def test_o11y_api_client_sync_request_preserves_prefix_and_header(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_make_request( + request_method: RequestMethod, base_url: str, endpoint: str, headers: dict[str, str], **kwargs: object + ) -> dict: + captured.update(method=request_method, url=urljoin(base_url, endpoint), headers=headers) + return {} + + monkeypatch.setattr(ApiClient, "make_request", staticmethod(fake_make_request)) + _o11y_client().request(RequestMethod.GET, path="/projects") + + assert captured == { + "method": RequestMethod.GET, + "url": "https://api.us1.observability.splunkcloud.com/v2/ao/projects", + "headers": {"accept": "application/json", "Content-Type": "application/json", "X-SF-Token": "tok"}, + } + + +@pytest.mark.asyncio +async def test_o11y_api_client_async_request_preserves_existing_prefix(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_make_request( + request_method: RequestMethod, base_url: str, endpoint: str, headers: dict[str, str], **kwargs: object + ) -> dict: + captured.update(url=urljoin(base_url, endpoint), headers=headers) + return {} + + monkeypatch.setattr(ApiClient, "make_request", staticmethod(fake_make_request)) + await _o11y_client().arequest(RequestMethod.GET, "/v2/ao/projects", {"X-Custom": "value"}) + + assert captured["url"] == "https://api.us1.observability.splunkcloud.com/v2/ao/projects" + assert captured["headers"] == {"X-Custom": "value", "X-SF-Token": "tok"} + + +def test_o11y_api_client_prefixes_streaming_requests(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, str] = {} + + def fake_stream_request( + self: ApiClient, method: RequestMethod, path: str, *args: object, **kwargs: object + ) -> contextlib.AbstractContextManager[str]: + captured["path"] = path + return nullcontext(path) + + monkeypatch.setattr(ApiClient, "stream_request", fake_stream_request) + with _o11y_client().stream_request(RequestMethod.GET, "/projects"): + pass + + assert captured["path"] == "/v2/ao/projects" + + +@pytest.mark.parametrize("token_var", ["SPLUNK_AO_SF_TOKEN", "SPLUNK_AO_SF_API_TOKEN"]) +def test_o11y_auth_guard_accepts_environment_tokens(token_var: str) -> None: + with config_env(**{token_var: "tok"}): + assert SplunkAOConfig._check_auth_config({}) is None + + +def test_o11y_auth_guard_does_not_accept_token_kwargs() -> None: + with config_env(): + assert SplunkAOConfig._check_auth_config({"sf_token": "tok"}) is not None + + +def test_o11y_console_bridge_uses_realm_and_preserves_explicit_legacy_value() -> None: + with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok"): + SplunkAOConfig._bridge_env_vars() + assert os.environ["GALILEO_CONSOLE_URL"] == "https://app.us1.observability.splunkcloud.com/#/ao" + + with config_env( + SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", GALILEO_CONSOLE_URL="https://explicit.example.com" + ): + SplunkAOConfig._bridge_env_vars() + assert os.environ["GALILEO_CONSOLE_URL"] == "https://explicit.example.com" + + +@pytest.mark.parametrize(("api_token", "expected_token"), [(None, "ingest-token"), ("api-token", "api-token")]) +def test_o11y_get_builds_realm_config_without_jwt_calls( + monkeypatch: pytest.MonkeyPatch, api_token: str | None, expected_token: str +) -> None: + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("o11y configuration must not use standalone validation") + + async def async_fail(*args: object, **kwargs: object) -> None: + fail() + + values = {"SPLUNK_AO_REALM": "us1", "SPLUNK_AO_SF_TOKEN": "ingest-token"} + if api_token is not None: + values["SPLUNK_AO_SF_API_TOKEN"] = api_token + values["GALILEO_API_KEY"] = "stale-standalone-key" + values["GALILEO_API_URL"] = "https://stale-api.example.com" + values["GALILEO_JWT_TOKEN"] = "stale-jwt" + + monkeypatch.setattr(SplunkAOConfig, "_instance", None) + monkeypatch.setattr(GalileoConfig, "get_jwt_token", staticmethod(fail)) + monkeypatch.setattr(ApiClient, "make_request", staticmethod(async_fail)) + monkeypatch.setattr(ApiClient, "request", fail) + + with config_env(**values): + cfg = SplunkAOConfig.get(ssl_context=False) + client = cfg.api_client + assert cfg.api_client is client + + assert cfg.jwt_token is None + assert str(cfg.console_url).rstrip("/") == "https://app.us1.observability.splunkcloud.com/#/ao" + assert str(cfg.api_url).rstrip("/") == "https://api.us1.observability.splunkcloud.com/v2/ao" + assert isinstance(client, O11yApiClient) + assert str(client.host).rstrip("/") == "https://api.us1.observability.splunkcloud.com" + assert client.auth_header == {"X-SF-Token": expected_token} + assert client.ssl_context is False + + +def test_ambiguous_environment_fails_before_config_construction(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SplunkAOConfig, "_instance", None) + with config_env(SPLUNK_AO_REALM="us1", SPLUNK_AO_SF_TOKEN="tok", SPLUNK_AO_API_KEY="key"): + with pytest.raises(AmbiguousConfigurationError): + SplunkAOConfig.get() + + +def test_standalone_constructor_flow_still_uses_parent_validation(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[object] = [] + + async def fake_make_request(request_method: RequestMethod, base_url: str, endpoint: str, **kwargs: object) -> dict: + calls.append(endpoint) + return {"status": "ok"} + + def fake_get_jwt_token(*args: object, **kwargs: object) -> tuple[SecretStr, None]: + calls.append("jwt") + return SecretStr("jwt-token"), None + + def fake_request(self: ApiClient, method: RequestMethod, path: str, **kwargs: object) -> dict[str, str]: + calls.append(path) + return {"id": str(uuid4()), "email": "user@example.com", "role": "user"} + + monkeypatch.setattr(SplunkAOConfig, "_instance", None) + monkeypatch.setattr(ApiClient, "make_request", staticmethod(fake_make_request)) + monkeypatch.setattr(GalileoConfig, "get_jwt_token", staticmethod(fake_get_jwt_token)) + monkeypatch.setattr(ApiClient, "request", fake_request) + + with config_env(): + cfg = SplunkAOConfig.get(console_url="https://app.galileo.ai", api_key="key") + + assert not isinstance(cfg.validated_api_client, O11yApiClient) + assert Routes.healthcheck in calls + assert "jwt" in calls + assert Routes.current_user in calls