diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b750ee4..85244db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes + +- Local configuration directory renamed from `~/.galileo` to `~/.splunk`. The override environment variable is now `SPLUNK_AO_HOME_DIR` (previously `GALILEO_HOME_DIR`). + ## [0.2.1] - 2026-08-07 ### Fixed diff --git a/splunk-ao-migration-tool/README.md b/splunk-ao-migration-tool/README.md index 250c0f95..746d7f80 100644 --- a/splunk-ao-migration-tool/README.md +++ b/splunk-ao-migration-tool/README.md @@ -280,8 +280,9 @@ All `GALILEO_*` environment variables are renamed to `SPLUNK_AO_*`. This is a ** | `GALILEO_DEFAULT_SCORER_MODEL` | `SPLUNK_AO_DEFAULT_SCORER_MODEL` | | `GALILEO_DEFAULT_SCORER_JUDGES` | `SPLUNK_AO_DEFAULT_SCORER_JUDGES` | | `GALILEO_CODE_VALIDATION_*` (4 vars) | `SPLUNK_AO_CODE_VALIDATION_*` | +| `GALILEO_HOME_DIR` ¹ | `SPLUNK_AO_HOME_DIR` | -¹ `GALILEO_API_URL` was not a user-facing env var in `galileo-python` — it was an implicit Pydantic settings field on `galileo-core`'s `GalileoConfig`. `SPLUNK_AO_API_URL` is its effective rename and is explicitly bridged in `SplunkAOConfig._bridge_env_vars()`. +¹ These variables were not user-facing env vars in `galileo-python` — they were implicit Pydantic settings fields on `galileo-core`'s `GalileoConfig`. Their `SPLUNK_AO_*` counterparts are effective renames, explicitly bridged in `SplunkAOConfig._bridge_env_vars()`. ² `SPLUNK_AO_LOG_STREAM` and `SPLUNK_AO_LOG_STREAM_ID` remain as deprecated aliases for `SPLUNK_AO_AGENT_STREAM` and `SPLUNK_AO_AGENT_STREAM_ID`. @@ -340,16 +341,19 @@ The `GalileoScorers` enum has been removed entirely. Migrate to `SplunkAOEvaluat + scorer = SplunkAOEvaluators.completeness ``` -### 5.3 On-Disk Config File +### 5.3 On-Disk Config File and Directory On logout or reset, `splunk-ao-python` writes a non-secret debug snapshot to -`~/.galileo/splunk-ao-config.json`. The directory `~/.galileo/` is inherited -from `galileo-core` and unchanged. +`~/.splunk/splunk-ao-config.json`. Both the directory (`~/.splunk/`, was +`~/.galileo/`) and the filename (`splunk-ao-config.json`, was +`galileo-python-config.json`) have changed. This file is never read back and has no effect on authentication or config resolution. If you have an existing `~/.galileo/galileo-python-config.json` from `galileo-python`, it can be deleted at leisure or simply ignored. +The directory can be overridden via `SPLUNK_AO_HOME_DIR` (previously `GALILEO_HOME_DIR`). + --- ## 6. HTTP Tracing Headers diff --git a/src/splunk_ao/config.py b/src/splunk_ao/config.py index bf002819..ac0b8e9c 100644 --- a/src/splunk_ao/config.py +++ b/src/splunk_ao/config.py @@ -2,10 +2,11 @@ # We need to ignore syntax errors until https://github.com/python/mypy/issues/17535 is resolved. import os from collections.abc import Iterator +from pathlib import Path from typing import Any, ClassVar, Optional from httpx import Response -from pydantic import SecretStr, ValidationInfo, field_validator, model_validator +from pydantic import Field, SecretStr, ValidationInfo, field_validator, model_validator from pydantic_core import Url from galileo_core.constants.request_method import RequestMethod @@ -64,18 +65,38 @@ def stream_request(self, method: RequestMethod, path: str, *args: Any, **kwargs: ("SPLUNK_AO_USERNAME", "GALILEO_USERNAME"), ("SPLUNK_AO_PASSWORD", "GALILEO_PASSWORD"), ("SPLUNK_AO_MODE", "GALILEO_MODE"), + ("SPLUNK_AO_HOME_DIR", "GALILEO_HOME_DIR"), ] class SplunkAOConfig(GalileoConfig): """Configure authentication and endpoints for standalone and O11y deployments.""" + home_dir: Path = Field( + default_factory=lambda: Path.home() / ".splunk", + validate_default=True, + description="Home directory for Splunk AO.", + exclude=True, + ) # Config file for this project. config_filename: str = "splunk-ao-config.json" console_url: Url = DEFAULT_CONSOLE_URL _instance: ClassVar[Optional["SplunkAOConfig"]] = None + @field_validator("home_dir", mode="before") + @classmethod + def set_home_dir(cls, value: str | Path) -> Path: + value = Path(value) + if not value.exists(): + try: + value.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise ValueError(f"Could not create home directory {value}: {e}") from e + if not value.is_dir(): + raise ValueError(f"Home directory {value} is not a directory.") + return value + def reset(self) -> None: # Remove any GALILEO_* keys the bridge injected into os.environ so that # the next get() call re-bridges from scratch with whatever SPLUNK_AO_* diff --git a/tests/test_config.py b/tests/test_config.py index d9369a71..e58ef3d4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -28,31 +29,11 @@ def _clear_auth_env(monkeypatch) -> None: # _bridge_env_vars tests # --------------------------------------------------------------------------- -# Every (SPLUNK_AO_*, GALILEO_*) pair defined in _bridge_env_vars. -# Deprecated aliases (SPLUNK_AO_LOG_STREAM, SPLUNK_AO_LOG_STREAM_ID) share a -# GALILEO_* target with their primary key so they are listed separately and +# Deprecated aliases share a GALILEO_* target with their primary key and are # excluded from the parametrized 1:1 propagation tests. -_ALL_BRIDGE_PAIRS = [ - ("SPLUNK_AO_API_KEY", "GALILEO_API_KEY"), - ("SPLUNK_AO_API_URL", "GALILEO_API_URL"), - ("SPLUNK_AO_CONSOLE_URL", "GALILEO_CONSOLE_URL"), - ("SPLUNK_AO_PROJECT", "GALILEO_PROJECT"), - ("SPLUNK_AO_PROJECT_ID", "GALILEO_PROJECT_ID"), - ("SPLUNK_AO_AGENT_STREAM", "GALILEO_LOG_STREAM"), - ("SPLUNK_AO_LOG_STREAM", "GALILEO_LOG_STREAM"), # deprecated alias - ("SPLUNK_AO_AGENT_STREAM_ID", "GALILEO_LOG_STREAM_ID"), - ("SPLUNK_AO_LOG_STREAM_ID", "GALILEO_LOG_STREAM_ID"), # deprecated alias - ("SPLUNK_AO_JWT_TOKEN", "GALILEO_JWT_TOKEN"), - ("SPLUNK_AO_SSO_ID_TOKEN", "GALILEO_SSO_ID_TOKEN"), - ("SPLUNK_AO_SSO_PROVIDER", "GALILEO_SSO_PROVIDER"), - ("SPLUNK_AO_USERNAME", "GALILEO_USERNAME"), - ("SPLUNK_AO_PASSWORD", "GALILEO_PASSWORD"), - ("SPLUNK_AO_MODE", "GALILEO_MODE"), -] +_DEPRECATED_BRIDGE_KEYS = {"SPLUNK_AO_LOG_STREAM", "SPLUNK_AO_LOG_STREAM_ID"} -_CANONICAL_BRIDGE_PAIRS = [ - p for p in _ALL_BRIDGE_PAIRS if p[0] not in ("SPLUNK_AO_LOG_STREAM", "SPLUNK_AO_LOG_STREAM_ID") -] +_CANONICAL_BRIDGE_PAIRS = [p for p in _BRIDGE if p[0] not in _DEPRECATED_BRIDGE_KEYS] # Safe test values per key — URL keys must be valid URLs to avoid leaking # an invalid GALILEO_* URL into the shared os.environ and breaking other tests. @@ -101,23 +82,51 @@ def test_bridge_env_vars_does_not_overwrite_existing_galileo_value(splunk_key, g def test_bridge_env_vars_skips_absent_splunk_ao_keys() -> None: """When a SPLUNK_AO_* key is absent, the corresponding GALILEO_* key must not be set (no spurious entries introduced by the bridge).""" - all_bridge_keys = {k for pair in _ALL_BRIDGE_PAIRS for k in pair} + all_bridge_keys = {k for pair in _BRIDGE for k in pair} # Build an env that has no bridge-related keys at all. clean_env = {k: v for k, v in os.environ.items() if k not in all_bridge_keys} with patch.dict(os.environ, clean_env, clear=True): SplunkAOConfig._bridge_env_vars() - for _, galileo_key in _ALL_BRIDGE_PAIRS: + for _, galileo_key in _BRIDGE: assert galileo_key not in os.environ, ( f"{galileo_key} must not be set when its SPLUNK_AO_* source is absent" ) +_EXPECTED_BRIDGE_PAIRS = { + ("SPLUNK_AO_API_KEY", "GALILEO_API_KEY"), + ("SPLUNK_AO_API_URL", "GALILEO_API_URL"), + ("SPLUNK_AO_CONSOLE_URL", "GALILEO_CONSOLE_URL"), + ("SPLUNK_AO_PROJECT", "GALILEO_PROJECT"), + ("SPLUNK_AO_PROJECT_ID", "GALILEO_PROJECT_ID"), + ("SPLUNK_AO_AGENT_STREAM", "GALILEO_LOG_STREAM"), + ("SPLUNK_AO_LOG_STREAM", "GALILEO_LOG_STREAM"), # deprecated alias + ("SPLUNK_AO_AGENT_STREAM_ID", "GALILEO_LOG_STREAM_ID"), + ("SPLUNK_AO_LOG_STREAM_ID", "GALILEO_LOG_STREAM_ID"), # deprecated alias + ("SPLUNK_AO_JWT_TOKEN", "GALILEO_JWT_TOKEN"), + ("SPLUNK_AO_SSO_ID_TOKEN", "GALILEO_SSO_ID_TOKEN"), + ("SPLUNK_AO_SSO_PROVIDER", "GALILEO_SSO_PROVIDER"), + ("SPLUNK_AO_USERNAME", "GALILEO_USERNAME"), + ("SPLUNK_AO_PASSWORD", "GALILEO_PASSWORD"), + ("SPLUNK_AO_MODE", "GALILEO_MODE"), + ("SPLUNK_AO_HOME_DIR", "GALILEO_HOME_DIR"), +} + + +def test_bridge_pairs_match_expected_set() -> None: + """Independent statement of the bridge contract: a pair added or removed in + config.py must be reflected here deliberately, not absorbed silently.""" + assert set(_BRIDGE) == _EXPECTED_BRIDGE_PAIRS + + # --------------------------------------------------------------------------- def test_default_console_url() -> None: """Default console_url and api_url when SPLUNK_AO_CONSOLE_URL is not set.""" - with patch.dict("os.environ", {}, clear=True): + all_bridge_keys = {k for pair in _BRIDGE for k in pair} + clean_env = {k: v for k, v in os.environ.items() if k not in all_bridge_keys} + with patch.dict("os.environ", clean_env, clear=True): if SplunkAOConfig._instance is not None: SplunkAOConfig._instance.reset() with fast_config_validation(): @@ -353,3 +362,26 @@ def test_config_file_path_resolves_to_splunk_ao_config(tmp_path) -> None: config = SplunkAOConfig.model_construct(home_dir=tmp_path) assert config.config_file == tmp_path / "splunk-ao-config.json" + + +def test_home_dir_default_is_dot_splunk() -> None: + assert SplunkAOConfig.model_fields["home_dir"].default_factory() == Path.home() / ".splunk" + + +def test_set_home_dir_creates_missing_directory(tmp_path) -> None: + # Given: a nested path that does not yet exist + target = tmp_path / "nested" / ".splunk" + # When: it is passed as home_dir + result = SplunkAOConfig.set_home_dir(target) + # Then: the directory is created and the resolved path is returned + assert result == target + assert target.is_dir() + + +def test_set_home_dir_rejects_non_directory(tmp_path) -> None: + # Given: an existing file (not a directory) + a_file = tmp_path / "not-a-dir" + a_file.touch() + # When/Then: passing it as home_dir raises ValueError + with pytest.raises(ValueError): + SplunkAOConfig.set_home_dir(a_file)