Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions splunk-ao-migration-tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion src/splunk_ao/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Comment thread
ridhima-splunk marked this conversation as resolved.
]
Comment thread
fercor-cisco marked this conversation as resolved.


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,
)
Comment thread
fercor-cisco marked this conversation as resolved.
# 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
Comment thread
ridhima-splunk marked this conversation as resolved.

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_*
Expand Down
84 changes: 58 additions & 26 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Loading