diff --git a/.envrc b/.envrc index 3550a30..20dea28 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,3 @@ use flake +# Load local credentials (gitignored; see .env.example). No-op if .env is absent. +dotenv_if_exists diff --git a/AGENTS.md b/AGENTS.md index 45de619..fad0470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,29 +22,45 @@ supported variable, and [README.md](./README.md) for connecting to a server. ## Architecture -The package separates a Click-free core from a thin CLI shell (the "functional -core, imperative shell" pattern): - -- `src/vct_splunk/core/` — plain functions and typed errors. **Never imports - Click.** This is the reusable, unit-testable library: `client` (transport, - auth, retries, pagination, dry-run), `auth` (session login), `profiles` - (INI profile loading), `errors`, `audit`, `namespace` - (owner/app resolution), `resource` (the generic CRUD engine: `Spec`/`Field`/ - `CrudResource`), `backends` + `acs/` (Splunk Cloud ACS support), and one - module per hand-written operation (`server`, `api`, `jobs`, `search`, - `saved_searches` for dispatch, `health`). +The package follows the same API-endpoint framework as `vct-cribl-cli`, with a +Click-free library under `api/` + `auth/` + `config/` + `utils/` and a thin CLI +shell in `commands/` (the "functional core, imperative shell" pattern). See +[docs/architecture.md](./docs/architecture.md) for the full map. + +- `src/vct_splunk/api/` — the reusable REST layer. **Never imports Click.** + `client.py` holds the layered httpx transport stack (`AuthTransport` injects + the Authorization header per request, `RetryTransport` retries 429/503) and + the envelope-aware `SplunkClient`; `endpoint_factory.py` is the generic CRUD + engine (`EndpointConfig`/`Field`/`Endpoints`); `endpoints/` holds one module + per hand-written operation (`server`, `search`, `jobs`, `saved_searches`, + `kvstore`, `hec`, `apps`, `cluster`, `license`, `deploy`, `lookups`, + `datamodel`, `health`, `raw`); `acs/` is the Splunk Cloud ACS + management-plane client. +- `src/vct_splunk/auth/` — `session.py`: credential resolution + (token / session key / username+password login) with cribl-style caching, + called per request by the auth transport. +- `src/vct_splunk/config/` — `types.py` (`SplunkConfig`) and `loader.py` + (INI profiles, env vars, flag merging: flag > env > profile > default). +- `src/vct_splunk/utils/` — typed `errors`, `redact`, `namespace` (owner/app + resolution), `path`, `validation`, `audit`, `backends` (Cloud deduction). +- `src/vct_splunk/output/` — `formatter.py`: JSON/table rendering and the + error envelope. - `src/vct_splunk/commands/` — Click adapters, one module per hand-written command group (`server`, `api`, `auth`, `search`, `health`, `inspect`, plus `saved_search`'s `run`), plus shared plumbing: `context` (the `command` - decorator and `Ctx`), `output` (rendering, error envelope), `write` (the - single gated write path), `dispatch` (routes a few reads to Cloud ACS), and - `registry` + `factory` (resource specs declared as data, turned into - generated CRUD groups — `index`, `saved-search`, `user`, `role`, `macro`, - the data inputs/outputs, and friends). + decorator and `Ctx`), `write` (the single gated write path), `dispatch` + (routes a few reads to Cloud ACS), and `registry` + `command_factory` + (resource configs declared as data, turned into generated CRUD groups — + `index`, `saved-search`, `user`, `role`, `macro`, the data inputs/outputs, + and friends). - `src/vct_splunk/cli.py` assembles the root group and the `splunk` entry point; `__main__.py` enables `python -m vct_splunk`. -Dependencies flow one way: `commands` import `core`, never the reverse. +Dependencies flow one way: `commands` import the library packages, never the +reverse. A downstream application embeds the library surface directly: +`config.loader.load_config()` → `api.client.create_client()` → the +`api.endpoints.*` / `api.endpoint_factory.Endpoints` functions (plus the +cribl-style `api.client.set_client()`/`get_client()` process-wide hook). Two cross-cutting ideas to know about: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..37c962f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,180 @@ +# Architecture + +Developer guide to the vct-splunk-cli codebase. The layout mirrors +`vct-cribl-cli` so downstream applications (e.g. the vizzy web interface) can +embed both API layers the same way. + +## Project structure + +```text +vct_splunk/ + __init__.py # Package marker + __version__ + __main__.py # Entry point for `python -m vct_splunk` + cli.py # Click command tree assembly, `splunk` entry point + + api/ + client.py # httpx transport stack (AuthTransport, RetryTransport), + # SplunkClient, create_client, get/set_client singletons + endpoint_factory.py # Generic CRUD Endpoints engine, EndpointConfig/Field + endpoints/ # Hand-written endpoint modules (14 files) + server.py, search.py, jobs.py, saved_searches.py, kvstore.py, + hec.py, apps.py, cluster.py, license.py, deploy.py, lookups.py, + datamodel.py, health.py, raw.py + acs/ # Splunk Cloud ACS management-plane client (read-only) + client.py, operations.py + + auth/ + session.py # Credential resolution + session login, cached per process + + commands/ + command_factory.py # Generates Click CRUD subcommands from the registry + registry.py # Declarative list of factory-generated resources + context.py # Shared `command` decorator + Ctx (builds clients) + write.py # The single gated write path (confirm + audit) + dispatch.py # Routes index/role/hec-token list to ACS on Cloud + server.py, api.py, auth.py, search.py, saved_search.py, health.py, + kvstore.py, hec.py, apps.py, cluster.py, shcluster.py, license.py, + deploy.py, lookup.py, datamodel.py, inspect.py + + config/ + loader.py # INI profiles + env var + CLI flag merging + types.py # SplunkConfig, AuthStatus dataclasses + + output/ + formatter.py # JSON / table formatting, error envelope, prompts + + utils/ + errors.py # Typed SplunkError hierarchy with exit codes + redact.py # Secret redaction by field name; safe_target for URLs + namespace.py # /servicesNS/// path building + policy + path.py # Path-segment validation/encoding (traversal-safe) + validation.py # KEY=VALUE parsing + audit.py # Append-only local audit log for writes + backends.py # Enterprise-vs-Cloud deduction from SPLUNK_URL + +tests/ + cli_catalog.py # Single catalog of every command leaf + unit/ # pytest + httpx.MockTransport (no mock package) + integration/ # Live suites, gated behind env vars +``` + +## Key patterns + +### HTTP client transport stack + +```text +Request + -> AuthTransport (injects Authorization header, per request) + -> RetryTransport (retries 429/503, honors Retry-After) + -> httpx.HTTPTransport (sends request; owns TLS verify settings) +``` + +`AuthTransport` resolves the credential on every request via +`auth.session.get_auth_header()`: + +- `SPLUNK_TOKEN` (a JWT) → `Authorization: Bearer ` +- `SPLUNK_SESSION_KEY` → `Authorization: Splunk ` +- `SPLUNK_USERNAME`/`SPLUNK_PASSWORD` → a session key minted via + `/services/auth/login`, cached for 55 minutes and re-minted transparently — + so a long-running embedding process survives session expiry. + +`SplunkClient` wraps the stack with Splunk's envelope concerns: the +`output_mode=json` parameter, `entry[].content` handling hand-off, pagination +(`get_collection`), typed error mapping (401/403 → `AuthError`, 404 → +`NotFoundError`, else `APIError`), and the dry-run gate — `write()` / +`write_json()` return a structured preview and send nothing when +`config.dry_run` is set. + +### Embedding (vizzy-style) + +```python +from vct_splunk.config.loader import load_config +from vct_splunk.api.client import create_client, set_client, get_client +from vct_splunk.api.endpoints import server, search + +cfg = load_config() # env + profile merging, no network I/O +client = create_client(cfg) # auth resolved lazily, per request +set_client(client) # optional process-wide hook + +info = server.get_server_info(get_client()) +hits = search.run_search(get_client(), "index=_internal | head 5") +``` + +The CLI itself builds a client per command (via `commands/context.py`) instead +of the singleton, so `--help` and config commands never touch the network. + +### Two-tier endpoint design + +**Factory endpoints** (`api/endpoint_factory.py`) — a generic `Endpoints` class +with `list/get/create/update/delete` (+ `enable`/`disable`) driven by a +declarative `EndpointConfig`. Two URL scopes: + +| Scope | URL pattern | +|--------------|--------------------------------------| +| `global` | `/services/{path}` (path absolute) | +| `namespaced` | `/servicesNS/{owner}/{app}/{path}` | + +`Field` entries map friendly CLI options to Splunk form keys (with typing, +scaling, and secret handling) and drive the generated Click options. + +**Hand-written endpoints** (`api/endpoints/*.py`) — for resources that need +special logic: search (oneshot jobs, NDJSON export parsing), the KV Store +document store (JSON bodies, no envelope), HEC token rotation (mints a secret), +health checks (multi-dimension verdicts), data model acceleration +(read-modify-write of a JSON sub-document), app install, deployment server, +cluster/license reads, and the raw read-only escape hatch (`raw.py`). + +### Command registration + +`cli.py` adds the hand-written groups, then loops over +`commands/registry.py:REGISTRY` — a flat list of `EndpointConfig` entries — +and generates a Click group per resource via +`commands/command_factory.py:build_group()`. + +To add a standard CRUD resource: add one `EndpointConfig` to `registry.py`. +To add a hand-written command: create `api/endpoints/.py` + +`commands/.py`, then register the group in `cli.py`. + +### Config priority chain + +```text +CLI flags (--base-url, --profile, --app, ...) + > Environment variables (SPLUNK_URL, SPLUNK_TOKEN, ...) + > Active profile in $XDG_CONFIG_HOME/vct-splunk/config + > Built-in defaults +``` + +### Write safety + +Every mutation funnels through `commands/write.py:do_write()`: `--dry-run` +previews the exact request and sends nothing; otherwise it confirms on a TTY or +requires `--yes` when non-interactive, then appends a record to the local audit +log. Reads redact secret-named fields by default; only commands whose purpose +is to mint a credential reveal one. Splunk Cloud targets refuse writes and +route supported reads through ACS. + +### Error handling + +The library raises typed errors (`utils/errors.py`); the `command` decorator in +`commands/context.py` renders them once as a JSON envelope on stderr with the +documented exit codes: 0 ok, 1 API/transport, 2 usage/config, 3 auth, +4 not found, 5 health-check findings. + +## Development + +```bash +python3 -m venv .venv # use a Python linked against OpenSSL +.venv/bin/python -m pip install -e ".[dev]" + +.venv/bin/pytest # unit tests (httpx.MockTransport) +.venv/bin/ruff check . && .venv/bin/ruff format . +.venv/bin/pyright + +.venv/bin/splunk server info # run the CLI +``` + +> Note: macOS's system Python 3.9 links LibreSSL 2.8, whose TLS handshake +> stalls against Splunk 10's TLS configuration. Use a Homebrew/python.org +> interpreter (OpenSSL 1.1.1+). + +Live suites are documented in [tests/TESTING.md](../tests/TESTING.md). diff --git a/src/vct_splunk/api/__init__.py b/src/vct_splunk/api/__init__.py new file mode 100644 index 0000000..42c4d01 --- /dev/null +++ b/src/vct_splunk/api/__init__.py @@ -0,0 +1,14 @@ +"""The Splunk REST API layer: client, endpoint factory, and endpoint modules. + +Mirrors the cribl-cli ``api/`` package. Nothing here imports Click — this is +the reusable library surface a downstream application (e.g. a web backend) +embeds directly: + +* :mod:`vct_splunk.api.client` — the httpx transport stack (auth, retries) and + the envelope-aware :class:`~vct_splunk.api.client.SplunkClient`. +* :mod:`vct_splunk.api.endpoint_factory` — the generic CRUD engine driven by + declarative :class:`~vct_splunk.api.endpoint_factory.EndpointConfig` entries. +* :mod:`vct_splunk.api.endpoints` — hand-written endpoint modules for + resources that do not fit the CRUD shape. +* :mod:`vct_splunk.api.acs` — the Splunk Cloud ACS management-plane client. +""" diff --git a/src/vct_splunk/api/acs/__init__.py b/src/vct_splunk/api/acs/__init__.py new file mode 100644 index 0000000..3fe49bf --- /dev/null +++ b/src/vct_splunk/api/acs/__init__.py @@ -0,0 +1 @@ +"""Read-only Splunk Cloud ACS (adminconfig/v2) client and operations.""" diff --git a/src/vct_splunk/core/acs/client.py b/src/vct_splunk/api/acs/client.py similarity index 95% rename from src/vct_splunk/core/acs/client.py rename to src/vct_splunk/api/acs/client.py index 8a2a40f..08cf519 100644 --- a/src/vct_splunk/core/acs/client.py +++ b/src/vct_splunk/api/acs/client.py @@ -3,7 +3,7 @@ ACS is a different surface from splunkd: a different base URL (``https://admin.splunk.com//adminconfig/v2``), a stack auth token, and plain JSON responses (not the form-encoded ``entry[].content`` shape). So it gets -its own small client rather than reusing :class:`~vct_splunk.core.client.SplunkClient`. +its own small client rather than reusing :class:`~vct_splunk.api.client.SplunkClient`. Writes are intentionally absent this release. """ @@ -17,8 +17,8 @@ import httpx -from ..errors import APIError, AuthError, NotFoundError, TransportError, UsageError -from ..redact import safe_target +from ...utils.errors import APIError, AuthError, NotFoundError, TransportError, UsageError +from ...utils.redact import safe_target ACS_BASE_URL = "https://admin.splunk.com" _STACK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*$") diff --git a/src/vct_splunk/core/acs/operations.py b/src/vct_splunk/api/acs/operations.py similarity index 96% rename from src/vct_splunk/core/acs/operations.py rename to src/vct_splunk/api/acs/operations.py index 7da389c..c263617 100644 --- a/src/vct_splunk/core/acs/operations.py +++ b/src/vct_splunk/api/acs/operations.py @@ -4,8 +4,8 @@ from typing import Any -from ..errors import APIError -from ..redact import redact_secrets +from ...utils.errors import APIError +from ...utils.redact import redact_secrets from .client import AcsClient # ACS read paths and their official success envelopes. This is the runtime diff --git a/src/vct_splunk/api/client.py b/src/vct_splunk/api/client.py new file mode 100644 index 0000000..3dcc2f9 --- /dev/null +++ b/src/vct_splunk/api/client.py @@ -0,0 +1,268 @@ +"""HTTP client with auth interceptor and retry logic. Click-free. + +Mirrors the cribl-cli ``api/client`` design: a layered httpx transport stack +owns the cross-cutting transport concerns, so every request behaves the same +no matter which endpoint module sends it:: + + Request + -> AuthTransport (injects the Authorization header, per request) + -> RetryTransport (retries 429/503 honoring Retry-After) + -> httpx.HTTPTransport (sends the request) + +Auth is resolved *per request* by :class:`AuthTransport` via +:func:`vct_splunk.auth.session.get_auth_header` — a static token passes +straight through, while a username/password login is minted lazily and cached, +so an embedding process (a web backend) survives session expiry without +restarting. The credential only ever lives in a header and is never logged. + +:class:`SplunkClient` is the thin envelope-aware wrapper the endpoint modules +(:mod:`vct_splunk.api.endpoints`) take as their first argument: it owns +Splunk's ``output_mode=json`` parameter, the ``entry[].content`` decoding +hand-off, pagination, typed error mapping, and the dry-run gate for mutating +requests (previews are returned as data and nothing is sent). + +Module-level ``get_client`` / ``set_client`` allow an embedding application to +initialize one client up front, cribl-style; the CLI itself builds a client per +command instead. +""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx + +from ..auth.session import clear_session_cache, get_auth_header, is_mintable +from ..config.types import SplunkConfig +from ..utils.errors import APIError, AuthError, NotFoundError, TransportError +from ..utils.redact import safe_target + +_RETRY_STATUS = {429, 503} +_MAX_RETRIES = 3 + +_client: SplunkClient | None = None +_config_error: str | None = None + + +class AuthTransport(httpx.BaseTransport): + """Transport wrapper that injects the Authorization header into every request. + + Resolution happens here — per request, not at client construction — so a + lazily minted session key can be refreshed transparently when it expires. + + When the credential is a username/password (a session key we can re-mint), a + 401 is treated as a possibly-stale session rather than a hard failure: Splunk + invalidates session keys server-side on events like a restart. In that case + the cache is dropped, a fresh login is performed, and the request is retried + once. A static token or session key is not refreshable, so its 401 passes + straight through as a genuine auth error. + """ + + def __init__(self, transport: httpx.BaseTransport, config: SplunkConfig) -> None: + self._transport = transport + self._config = config + + def handle_request(self, request: httpx.Request) -> httpx.Response: + request.headers["Authorization"] = get_auth_header(self._config) + response = self._transport.handle_request(request) + if response.status_code == 401 and is_mintable(self._config): + response.close() + clear_session_cache() + request.headers["Authorization"] = get_auth_header(self._config) + response = self._transport.handle_request(request) + return response + + +class RetryTransport(httpx.BaseTransport): + """Transport that retries requests on 429/503, honoring ``Retry-After``.""" + + def __init__(self, transport: httpx.BaseTransport) -> None: + self._transport = transport + + def handle_request(self, request: httpx.Request) -> httpx.Response: + response = self._transport.handle_request(request) + for attempt in range(_MAX_RETRIES): + if response.status_code not in _RETRY_STATUS: + return response + response.close() + time.sleep(_retry_after(response, attempt)) + response = self._transport.handle_request(request) + return response + + +def _retry_after(resp: httpx.Response, attempt: int) -> float: + hdr = resp.headers.get("Retry-After") + if hdr and hdr.isdigit(): + return float(hdr) + return min(2.0**attempt, 8.0) + + +class SplunkClient: + """Envelope-aware Splunk REST client over the layered transport stack. + + Args: + config: The resolved connection/credential settings. + transport: Optional innermost transport, for tests + (``httpx.MockTransport``). The auth and retry layers still wrap it, + so tests exercise the real stack. + """ + + def __init__( + self, config: SplunkConfig, *, transport: httpx.BaseTransport | None = None + ) -> None: + self.config = config + # TLS settings live on the innermost transport: httpx ignores `verify` + # when a custom transport chain is supplied to the Client. + inner = transport if transport is not None else httpx.HTTPTransport(verify=config.verify) + stack: httpx.BaseTransport = AuthTransport(RetryTransport(inner), config) + self._http = httpx.Client( + base_url=config.base_url, + timeout=config.timeout, + transport=stack, + ) + + def __enter__(self) -> SplunkClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + self._http.close() + + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + """GET an endpoint and return its parsed JSON response.""" + return self._request("GET", path, params=params) + + def post( + self, path: str, data: dict[str, Any], *, timeout: float | None = None + ) -> dict[str, Any]: + """Non-mutating POST (e.g. a search job). Never gated by dry-run.""" + return self._request("POST", path, data=data, timeout=timeout) + + def write(self, method: str, path: str, data: dict[str, Any]) -> dict[str, Any]: + """Mutating request. When dry_run is set, sends nothing and returns a preview.""" + if self.config.dry_run: + return { + "dry_run": True, + "request": {"method": method, "path": "/" + path.lstrip("/"), "body": data}, + "target": safe_target(self.config.base_url), + } + return self._request(method, path, data=data) + + def write_json(self, method: str, path: str, body: Any) -> Any: + """Mutating request with a JSON body (Content-Type: application/json). + + The KV Store *data* endpoints are a JSON document store, not the Splunk + ``entry[].content`` envelope: requests carry a JSON body and responses are + plain JSON objects/arrays. This is the JSON-body sibling of :meth:`write`; + it is dry-run gated the same way and returns the parsed JSON otherwise. + """ + if self.config.dry_run: + return { + "dry_run": True, + "request": {"method": method, "path": "/" + path.lstrip("/"), "body": body}, + "target": safe_target(self.config.base_url), + } + return self._request(method, path, json_body=body) + + def get_collection( + self, path: str, params: dict[str, Any] | None = None, *, page: int = 200 + ) -> list[dict[str, Any]]: + """Auto-paginate a Splunk collection endpoint and return every entry.""" + base = dict(params or {}) + offset: int = 0 + out: list[dict[str, Any]] = [] + while True: + body = self._request("GET", path, params={**base, "count": page, "offset": offset}) + entries = body.get("entry") or [] + out.extend(entries) + total = (body.get("paging") or {}).get("total") + offset += len(entries) + if not entries or len(entries) < page or (total is not None and offset >= total): + return out + + def _request( + self, method, path, *, params=None, data=None, json_body=None, timeout=None + ) -> Any: + # The classic Splunk endpoints speak the entry/content envelope and need + # output_mode=json; the KV Store data store is already JSON, so a JSON-body + # request skips that param and sends application/json instead of form data. + params = dict(params or {}) + if json_body is None: + params["output_mode"] = "json" + url = "/" + path.lstrip("/") + try: + resp = self._http.request( + method, + url, + params=params, + data=data, + json=json_body, + # Only None means "unset" — an explicit timeout (even 0) is honored. + timeout=self.config.timeout if timeout is None else timeout, + ) + except httpx.HTTPError as exc: + raise TransportError( + f"Could not reach Splunk at {safe_target(self.config.base_url)}: {exc}" + ) from exc + return _handle(resp, method, url) + + +def create_client( + config: SplunkConfig, *, transport: httpx.BaseTransport | None = None +) -> SplunkClient: + """Create a :class:`SplunkClient` over the auth/retry transport stack.""" + return SplunkClient(config, transport=transport) + + +def get_client() -> SplunkClient: + """Return the process-wide client set via :func:`set_client`. + + This is the cribl-style embedding hook: an application (e.g. a web backend) + initializes one client at startup and endpoint calls share it. The CLI does + not use it — each command builds and closes its own client. + """ + if _client is None: + if _config_error: + raise TransportError(_config_error) + raise TransportError("API client not initialized") + return _client + + +def set_client(client: SplunkClient | None) -> None: + """Install (or clear) the process-wide client returned by :func:`get_client`.""" + global _client + _client = client + + +def set_config_error(msg: str) -> None: + """Record why client initialization failed, surfaced by :func:`get_client`.""" + global _config_error + _config_error = msg + + +def _handle(resp: httpx.Response, method: str, url: str) -> Any: + if resp.status_code == 401: + raise AuthError("Authentication failed (401). Check SPLUNK_TOKEN or SPLUNK_SESSION_KEY.") + if resp.status_code == 403: + raise AuthError(f"Permission denied (403) for {method} {url}.") + if resp.status_code == 404: + raise NotFoundError(f"Not found: {url}") + if resp.status_code >= 400: + raise APIError( + f"Splunk returned {resp.status_code} for {method} {url}", + status=resp.status_code, + details=_safe_body(resp), + ) + if not resp.content: + return {} + try: + return resp.json() + except ValueError: + return {"raw": resp.text} + + +def _safe_body(resp: httpx.Response) -> Any: + try: + return resp.json() + except ValueError: + return resp.text[:500] diff --git a/src/vct_splunk/core/resource.py b/src/vct_splunk/api/endpoint_factory.py similarity index 70% rename from src/vct_splunk/core/resource.py rename to src/vct_splunk/api/endpoint_factory.py index 2941390..74a4123 100644 --- a/src/vct_splunk/core/resource.py +++ b/src/vct_splunk/api/endpoint_factory.py @@ -1,14 +1,25 @@ -"""A declarative CRUD engine for the many Splunk resources that share one shape. - -Most Splunk admin resources are plain REST collections: list / get / create / -update / delete under a path, with form-encoded settings and an -``entry[].content`` body. Rather than hand-write a near-identical module per -resource, we describe each one as data (:class:`Spec`) and bind it to a single -generic :class:`CrudResource`. The command layer turns the same spec into a Click -group (see :mod:`vct_splunk.commands.factory`). - -This is Click-free core. Resources that do NOT fit this shape (document stores, -secret-returning creates, action-only endpoints) stay hand-written. +"""Generic CRUD endpoint factory for Splunk REST resources. Click-free. + +Mirrors the cribl-cli ``api/endpoint_factory`` design: most admin resources are +plain REST collections — list / get / create / update / delete under a path, +with form-encoded settings and an ``entry[].content`` response body. Rather +than hand-write a near-identical module per resource, each one is described as +data (:class:`EndpointConfig`) and bound to a single generic :class:`Endpoints` +class. The command layer turns the same config into a Click group (see +:mod:`vct_splunk.commands.command_factory`). + +Two URL scopes exist (the Splunk analogue of cribl's group/global scopes): + +============ ========================================================= +Scope URL pattern +============ ========================================================= +``global`` ``/services/{path}`` (path is given absolute) +``namespaced`` ``/servicesNS/{owner}/{app}/{path}`` +============ ========================================================= + +Resources that do NOT fit this shape (document stores, secret-returning +creates, action-only endpoints) stay hand-written in +:mod:`vct_splunk.api.endpoints`. """ from __future__ import annotations @@ -16,12 +27,13 @@ from dataclasses import dataclass, field from typing import Any, Literal +from ..utils.errors import NotFoundError +from ..utils.namespace import ns_path +from ..utils.path import absolute_path_segment, path_segment +from ..utils.redact import redact_secrets from .client import SplunkClient -from .errors import NotFoundError -from .namespace import ns_path -from .path import absolute_path_segment, path_segment -from .redact import redact_secrets +Scope = Literal["global", "namespaced"] Verb = Literal["list", "get", "create", "update", "delete", "enable", "disable"] FieldType = Literal["str", "int", "float", "bool"] @@ -51,19 +63,20 @@ class Field: @dataclass(frozen=True) -class Spec: +class EndpointConfig: """A declarative description of one CRUD-shaped Splunk resource. Attributes: name: The singular noun / command group, e.g. ``user``. - path: The REST path. For a global resource this is the full - ``/services/...`` path; for a ``namespaced`` resource it is the suffix - under ``/servicesNS///`` (e.g. ``configs/conf-macros``). + path: The REST path. For a ``global`` resource this is the full + ``/services/...`` path; for a ``namespaced`` resource it is the + suffix under ``/servicesNS///`` (e.g. + ``configs/conf-macros``). help: Group help text. verbs: The verbs to expose; a read-only resource passes ``("list",)``. fields: The create/update settings. out_map: Splunk-content-key -> output-key. Empty means pass content through. - namespaced: True for objects under ``/servicesNS///``. + scope: ``"global"`` or ``"namespaced"`` (see the module docstring). absolute_name: True when Splunk identifies the resource by an absolute server path, as monitor inputs do. mints_secret: True when ``create`` produces a credential the caller cannot @@ -78,20 +91,20 @@ class Spec: verbs: tuple[Verb, ...] = ("list", "get", "create", "update", "delete") fields: tuple[Field, ...] = () out_map: dict[str, str] = field(default_factory=dict) - namespaced: bool = False + scope: Scope = "global" absolute_name: bool = False mints_secret: bool = False -class CrudResource: - """The generic CRUD operations for one :class:`Spec`, bound at construction. +class Endpoints: + """The generic CRUD operations for one :class:`EndpointConfig`. Namespaced resources take an ``owner`` and ``app`` (resolved by the command layer); global resources ignore them. """ - def __init__(self, spec: Spec) -> None: - self.spec = spec + def __init__(self, config: EndpointConfig) -> None: + self.config = config def list( self, client: SplunkClient, *, owner: str | None = None, app: str | None = None @@ -104,7 +117,7 @@ def get( encoded = self.validate_name(name) entries = client.get(f"{self._base(owner, app)}/{encoded}").get("entry") or [] if not entries: - raise NotFoundError(f"{self.spec.name.capitalize()} {name!r} not found.") + raise NotFoundError(f"{self.config.name.capitalize()} {name!r} not found.") return self._out(entries[0]) def create( @@ -120,12 +133,12 @@ def create( data = self._body(fields, sets) self.validate_name(name) data["name"] = name - # Only a spec that mints a credential may show one, and only here: the - # value exists nowhere else afterwards. A spec that merely accepts a + # Only a config that mints a credential may show one, and only here: the + # value exists nowhere else afterwards. A config that merely accepts a # secret, as `user` does with a password, must not have it echoed back. return self._unwrap( client.write("POST", self._base(owner, app), data), - reveal_secrets=self.spec.mints_secret, + reveal_secrets=self.config.mints_secret, ) def update( @@ -167,20 +180,20 @@ def control( ) def _base(self, owner: str | None, app: str | None) -> str: - if self.spec.namespaced: - # The command layer always resolves owner/app for namespaced specs. - return ns_path(self.spec.path, owner=owner or "-", app=app or "-") - return self.spec.path + if self.config.scope == "namespaced": + # The command layer always resolves owner/app for namespaced configs. + return ns_path(self.config.path, owner=owner or "-", app=app or "-") + return self.config.path def validate_name(self, name: str) -> str: - """Validate and encode the spec's supported identifier shape.""" - if self.spec.absolute_name: - return absolute_path_segment(name, label=f"{self.spec.name} name") - return path_segment(name, label=f"{self.spec.name} name") + """Validate and encode the config's supported identifier shape.""" + if self.config.absolute_name: + return absolute_path_segment(name, label=f"{self.config.name} name") + return path_segment(name, label=f"{self.config.name} name") def _body(self, fields: dict[str, Any], sets: dict[str, str] | None) -> dict[str, Any]: """Map provided options to Splunk form keys, then merge raw --set pairs.""" - by_opt = {f.opt: f for f in self.spec.fields} + by_opt = {f.opt: f for f in self.config.fields} data: dict[str, Any] = {} for opt, value in fields.items(): if value is None or value == (): @@ -214,14 +227,19 @@ def _out(self, entry: dict[str, Any], *, reveal_secrets: bool = False) -> dict[s # the content merge keeps the entry name authoritative over any content # key of the same name. out: dict[str, Any] = {"name": entry.get("name")} - if self.spec.out_map: - for splunk_key, out_key in self.spec.out_map.items(): + if self.config.out_map: + for splunk_key, out_key in self.config.out_map.items(): out[out_key] = content.get(splunk_key) else: out.update(content) out["name"] = entry.get("name") - if self.spec.namespaced: + if self.config.scope == "namespaced": out["app"] = acl.get("app") out["owner"] = acl.get("owner") out["sharing"] = acl.get("sharing") return out + + +def create_endpoints(config: EndpointConfig) -> Endpoints: + """Bind one :class:`EndpointConfig` to the generic CRUD engine.""" + return Endpoints(config) diff --git a/src/vct_splunk/api/endpoints/__init__.py b/src/vct_splunk/api/endpoints/__init__.py new file mode 100644 index 0000000..a0a4775 --- /dev/null +++ b/src/vct_splunk/api/endpoints/__init__.py @@ -0,0 +1,9 @@ +"""Hand-written endpoint modules for resources the CRUD factory cannot express. + +Each module is plain functions taking a +:class:`~vct_splunk.api.client.SplunkClient` first, mirroring the cribl-cli +``api/endpoints/`` layout: search/jobs (dispatch + lifecycle), server info and +settings, health checks, the KV Store document store, HEC token rotation, app +install, cluster/license/deployment reads, lookup upload, data model +acceleration, and the raw read-only escape hatch. +""" diff --git a/src/vct_splunk/core/apps.py b/src/vct_splunk/api/endpoints/apps.py similarity index 92% rename from src/vct_splunk/core/apps.py rename to src/vct_splunk/api/endpoints/apps.py index f54bda6..4842068 100644 --- a/src/vct_splunk/core/apps.py +++ b/src/vct_splunk/api/endpoints/apps.py @@ -1,4 +1,4 @@ -"""App install from a local file or a URL. Click-free core. +"""App install from a local file or a URL. Click-free. The app CRUD surface (list/get/delete/enable/disable) is factory-generated from the ``app`` spec. Install is the one operation that does not fit that shape, so @@ -12,7 +12,7 @@ from typing import Any -from .client import SplunkClient +from ..client import SplunkClient _PATH = "/services/apps/local" diff --git a/src/vct_splunk/core/cluster.py b/src/vct_splunk/api/endpoints/cluster.py similarity index 95% rename from src/vct_splunk/core/cluster.py rename to src/vct_splunk/api/endpoints/cluster.py index e48c26e..7b759ac 100644 --- a/src/vct_splunk/core/cluster.py +++ b/src/vct_splunk/api/endpoints/cluster.py @@ -1,4 +1,4 @@ -"""Cluster status reads: indexer cluster and search-head cluster. Click-free core. +"""Cluster status reads: indexer cluster and search-head cluster. Click-free. These are system-level (not namespaced) read endpoints that summarize cluster manager/peer health and search-head cluster membership. Shapes vary by Splunk @@ -9,8 +9,8 @@ from typing import Any -from .client import SplunkClient -from .errors import APIError, NotFoundError +from ...utils.errors import APIError, NotFoundError +from ..client import SplunkClient def cluster_status(client: SplunkClient) -> dict[str, Any]: diff --git a/src/vct_splunk/core/config.py b/src/vct_splunk/api/endpoints/config.py similarity index 91% rename from src/vct_splunk/core/config.py rename to src/vct_splunk/api/endpoints/config.py index 67f1d6a..a5a78e4 100644 --- a/src/vct_splunk/core/config.py +++ b/src/vct_splunk/api/endpoints/config.py @@ -4,10 +4,10 @@ from typing import Any -from .client import SplunkClient -from .namespace import ns_path -from .path import path_segment -from .redact import REDACTED, is_secret_key, redact_secrets +from ...utils.namespace import ns_path +from ...utils.path import path_segment +from ...utils.redact import REDACTED, is_secret_key, redact_secrets +from ..client import SplunkClient def list_files(client: SplunkClient, *, owner: str, app: str) -> list[dict[str, Any]]: diff --git a/src/vct_splunk/core/datamodel.py b/src/vct_splunk/api/endpoints/datamodel.py similarity index 89% rename from src/vct_splunk/core/datamodel.py rename to src/vct_splunk/api/endpoints/datamodel.py index a75e8be..b7b71d4 100644 --- a/src/vct_splunk/core/datamodel.py +++ b/src/vct_splunk/api/endpoints/datamodel.py @@ -1,4 +1,4 @@ -"""Data model acceleration toggle. Click-free core. +"""Data model acceleration toggle. Click-free. Data model list/get/create/update is factory-generated from the ``datamodel`` spec. Toggling acceleration does not fit the CRUD shape and lives here. @@ -9,10 +9,10 @@ import json from typing import Any -from .client import SplunkClient -from .errors import APIError, NotFoundError -from .namespace import ns_path -from .path import path_segment +from ...utils.errors import APIError, NotFoundError +from ...utils.namespace import ns_path +from ...utils.path import path_segment +from ..client import SplunkClient _MODEL = "datamodel/model" diff --git a/src/vct_splunk/core/deploy.py b/src/vct_splunk/api/endpoints/deploy.py similarity index 95% rename from src/vct_splunk/core/deploy.py rename to src/vct_splunk/api/endpoints/deploy.py index e7e429e..c34b566 100644 --- a/src/vct_splunk/core/deploy.py +++ b/src/vct_splunk/api/endpoints/deploy.py @@ -1,4 +1,4 @@ -"""Deployment server operations: clients, server classes, reload. Click-free core. +"""Deployment server operations: clients, server classes, reload. Click-free. The deployment server hands out app bundles to deployment clients. These are system-level endpoints under ``/services/deployment/server`` -- not namespaced, @@ -10,9 +10,9 @@ from typing import Any -from .client import SplunkClient -from .errors import NotFoundError -from .path import path_segment +from ...utils.errors import NotFoundError +from ...utils.path import path_segment +from ..client import SplunkClient _CLIENTS = "/services/deployment/server/clients" _SERVERCLASSES = "/services/deployment/server/serverclasses" diff --git a/src/vct_splunk/core/health.py b/src/vct_splunk/api/endpoints/health.py similarity index 98% rename from src/vct_splunk/core/health.py rename to src/vct_splunk/api/endpoints/health.py index bc78bee..560f60e 100644 --- a/src/vct_splunk/core/health.py +++ b/src/vct_splunk/api/endpoints/health.py @@ -1,4 +1,4 @@ -"""Health checks over native REST endpoints. Click-free core. +"""Health checks over native REST endpoints. Click-free. Each verdict reports three independent dimensions so "unknown" never silently reads as "healthy": @@ -13,8 +13,8 @@ from dataclasses import asdict, dataclass from typing import Any -from .client import SplunkClient -from .errors import AuthError, NotFoundError, SplunkError +from ...utils.errors import AuthError, NotFoundError, SplunkError +from ..client import SplunkClient from .search import run_search _FINDING = {"green": "pass", "yellow": "warn", "red": "fail"} diff --git a/src/vct_splunk/core/hec.py b/src/vct_splunk/api/endpoints/hec.py similarity index 90% rename from src/vct_splunk/core/hec.py rename to src/vct_splunk/api/endpoints/hec.py index 36a9f31..d1a2d02 100644 --- a/src/vct_splunk/core/hec.py +++ b/src/vct_splunk/api/endpoints/hec.py @@ -6,16 +6,16 @@ * token rotation -- minting a fresh token value for an existing stanza, and * the global HEC input toggle (``http``) that enables/disables HEC as a whole. -This is Click-free core: plain functions taking a :class:`SplunkClient`. +This is Click-free: plain functions taking a :class:`SplunkClient`. """ from __future__ import annotations from typing import Any -from .client import SplunkClient -from .errors import APIError -from .path import path_segment +from ...utils.errors import APIError +from ...utils.path import path_segment +from ..client import SplunkClient _HTTP = "/services/data/inputs/http" diff --git a/src/vct_splunk/core/jobs.py b/src/vct_splunk/api/endpoints/jobs.py similarity index 94% rename from src/vct_splunk/core/jobs.py rename to src/vct_splunk/api/endpoints/jobs.py index b5dd978..d9f15ea 100644 --- a/src/vct_splunk/core/jobs.py +++ b/src/vct_splunk/api/endpoints/jobs.py @@ -1,4 +1,4 @@ -"""Search job lifecycle over ``/services/search/jobs``. Click-free core. +"""Search job lifecycle over ``/services/search/jobs``. Click-free. Jobs are addressed by their SID, not by a namespace. ``list`` and ``get`` are reads; ``cancel`` is a mutation and goes through the client's gated ``write``. @@ -8,9 +8,9 @@ from typing import Any -from .client import SplunkClient -from .errors import NotFoundError -from .path import path_segment +from ...utils.errors import NotFoundError +from ...utils.path import path_segment +from ..client import SplunkClient from .search import JOBS_PATH diff --git a/src/vct_splunk/core/kvstore.py b/src/vct_splunk/api/endpoints/kvstore.py similarity index 95% rename from src/vct_splunk/core/kvstore.py rename to src/vct_splunk/api/endpoints/kvstore.py index fc71fee..8bc6a35 100644 --- a/src/vct_splunk/core/kvstore.py +++ b/src/vct_splunk/api/endpoints/kvstore.py @@ -1,4 +1,4 @@ -"""KV Store data records: a namespaced JSON document store. Click-free core. +"""KV Store data records: a namespaced JSON document store. Click-free. KV Store *data* is unlike the rest of the REST API. Records live under ``/servicesNS///storage/collections/data/`` and are a @@ -15,10 +15,10 @@ from typing import Any -from .client import SplunkClient -from .errors import NotFoundError -from .namespace import ns_path -from .path import path_segment +from ...utils.errors import NotFoundError +from ...utils.namespace import ns_path +from ...utils.path import path_segment +from ..client import SplunkClient _DATA = "storage/collections/data" diff --git a/src/vct_splunk/core/license.py b/src/vct_splunk/api/endpoints/license.py similarity index 90% rename from src/vct_splunk/core/license.py rename to src/vct_splunk/api/endpoints/license.py index b394486..bd13952 100644 --- a/src/vct_splunk/core/license.py +++ b/src/vct_splunk/api/endpoints/license.py @@ -1,4 +1,4 @@ -"""Licensing reads: installed licenses and usage. Click-free core. +"""Licensing reads: installed licenses and usage. Click-free. System-level (not namespaced) reads over ``/services/licenser/*``. Response shapes vary by Splunk version, so fields are pulled defensively with ``.get``. @@ -8,9 +8,9 @@ from typing import Any -from .client import SplunkClient -from .errors import NotFoundError -from .path import path_segment +from ...utils.errors import NotFoundError +from ...utils.path import path_segment +from ..client import SplunkClient _LICENSES = "/services/licenser/licenses" _POOLS = "/services/licenser/pools" diff --git a/src/vct_splunk/core/lookups.py b/src/vct_splunk/api/endpoints/lookups.py similarity index 87% rename from src/vct_splunk/core/lookups.py rename to src/vct_splunk/api/endpoints/lookups.py index d000fad..1eb31a9 100644 --- a/src/vct_splunk/core/lookups.py +++ b/src/vct_splunk/api/endpoints/lookups.py @@ -1,4 +1,4 @@ -"""Lookup table file upload. Click-free core. +"""Lookup table file upload. Click-free. Lookup *definitions* (the transforms.conf stanza) are factory-generated from the ``lookup-definition`` spec. Uploading the CSV table file itself is a namespaced @@ -9,8 +9,8 @@ from typing import Any -from .client import SplunkClient -from .namespace import ns_path +from ...utils.namespace import ns_path +from ..client import SplunkClient _FILES = "data/lookup-table-files" diff --git a/src/vct_splunk/core/api.py b/src/vct_splunk/api/endpoints/raw.py similarity index 92% rename from src/vct_splunk/core/api.py rename to src/vct_splunk/api/endpoints/raw.py index d0a3a70..6c6b70f 100644 --- a/src/vct_splunk/core/api.py +++ b/src/vct_splunk/api/endpoints/raw.py @@ -1,4 +1,4 @@ -"""GET-only raw REST escape hatch. Click-free core. +"""GET-only raw REST escape hatch. Click-free. Exposes the whole Splunk read API to callers without a typed command per endpoint. Writes are intentionally *not* reachable here — they go through gated commands only. @@ -10,8 +10,8 @@ from typing import Any -from .client import SplunkClient -from .errors import UsageError +from ...utils.errors import UsageError +from ..client import SplunkClient def api_get(client: SplunkClient, path: str, query: dict[str, str] | None = None) -> Any: diff --git a/src/vct_splunk/core/saved_searches.py b/src/vct_splunk/api/endpoints/saved_searches.py similarity index 86% rename from src/vct_splunk/core/saved_searches.py rename to src/vct_splunk/api/endpoints/saved_searches.py index 7628618..c6a4b5d 100644 --- a/src/vct_splunk/core/saved_searches.py +++ b/src/vct_splunk/api/endpoints/saved_searches.py @@ -1,11 +1,11 @@ -"""Saved-search dispatch. Click-free core. +"""Saved-search dispatch. Click-free. -Saved-search CRUD rides the generic :mod:`vct_splunk.core.resource` engine (see +Saved-search CRUD rides the generic :mod:`vct_splunk.api.endpoint_factory` engine (see the ``SAVED_SEARCH`` spec in :mod:`vct_splunk.commands.registry`); only dispatch lives here, because running a saved search is an action, not CRUD. Saved searches are **namespaced**: every call takes an explicit ``owner`` and ``app`` (the command layer resolves them via -:func:`vct_splunk.core.namespace.resolve_ns`, which keeps writes out of the +:func:`vct_splunk.utils.namespace.resolve_ns`, which keeps writes out of the default ``search`` app). """ @@ -13,9 +13,9 @@ from typing import Any -from .client import SplunkClient -from .namespace import ns_path -from .path import path_segment +from ...utils.namespace import ns_path +from ...utils.path import path_segment +from ..client import SplunkClient _SUFFIX = "saved/searches" diff --git a/src/vct_splunk/core/search.py b/src/vct_splunk/api/endpoints/search.py similarity index 98% rename from src/vct_splunk/core/search.py rename to src/vct_splunk/api/endpoints/search.py index 6c88984..c924644 100644 --- a/src/vct_splunk/core/search.py +++ b/src/vct_splunk/api/endpoints/search.py @@ -1,4 +1,4 @@ -"""Bounded SPL search via a oneshot job. Click-free core. +"""Bounded SPL search via a oneshot job. Click-free. Conservative defaults (time window, row cap, timeout) keep an agent from launching an unbounded export by accident. @@ -12,7 +12,7 @@ import json from typing import Any -from .client import SplunkClient +from ..client import SplunkClient # Splunk REST endpoint that accepts search jobs. JOBS_PATH = "/services/search/jobs" diff --git a/src/vct_splunk/core/server.py b/src/vct_splunk/api/endpoints/server.py similarity index 92% rename from src/vct_splunk/core/server.py rename to src/vct_splunk/api/endpoints/server.py index c6ab078..8d94e3c 100644 --- a/src/vct_splunk/core/server.py +++ b/src/vct_splunk/api/endpoints/server.py @@ -1,12 +1,12 @@ -"""`server info` operation. Click-free core.""" +"""`server info` operation. Click-free.""" from __future__ import annotations from typing import Any -from .client import SplunkClient -from .errors import APIError, UsageError -from .redact import redact_secrets +from ...utils.errors import APIError, UsageError +from ...utils.redact import redact_secrets +from ..client import SplunkClient def get_server_info(client: SplunkClient) -> dict[str, Any]: diff --git a/src/vct_splunk/auth/__init__.py b/src/vct_splunk/auth/__init__.py new file mode 100644 index 0000000..6df1f15 --- /dev/null +++ b/src/vct_splunk/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication: credential resolution, session login, and caching.""" diff --git a/src/vct_splunk/auth/session.py b/src/vct_splunk/auth/session.py new file mode 100644 index 0000000..c6f1987 --- /dev/null +++ b/src/vct_splunk/auth/session.py @@ -0,0 +1,152 @@ +"""Credential resolution and session login. Click-free. + +Mirrors the cribl-cli ``auth/oauth`` role for Splunk's two REST auth schemes: + +* A JWT (``SPLUNK_TOKEN``) is sent as ``Authorization: Bearer ``. +* A session key (``SPLUNK_SESSION_KEY``, or one minted here from a + username/password login) is sent as ``Authorization: Splunk ``. + +:func:`get_auth_header` is called **per request** by the client's +:class:`~vct_splunk.api.client.AuthTransport`, so a long-running consumer (a +web backend embedding this package) re-logs-in transparently when a cached +session key expires. Static credentials pass straight through; only the +username/password path caches, with an expiry margin like the cribl CLI's +token cache. + +:func:`login` is the one REST call that does **not** carry an Authorization +header: the credentials travel in the form body, and Splunk hands back a +session key. +""" + +from __future__ import annotations + +import time + +import httpx + +from ..config.types import SplunkConfig +from ..utils.errors import APIError, AuthError, TransportError +from ..utils.redact import safe_target + +#: How long a minted session key is reused before re-login. Splunk's default +#: session timeout is 60 minutes; refreshing five minutes early keeps a +#: long-lived process from ever sending a just-expired key. +SESSION_TTL_SECONDS = 55 * 60 + +_cached_session: dict | None = ( + None # {"key": (base_url, username), "header": str, "expires_at": float} +) + + +def clear_session_cache() -> None: + """Drop any cached login session (used by tests and re-auth flows).""" + global _cached_session + _cached_session = None + + +def is_mintable(config: SplunkConfig) -> bool: + """True when the credential is a username/password we can re-mint on demand. + + A static token or session key cannot be refreshed — a 401 from one is a real + authentication failure. A username/password, by contrast, mints a session key + that Splunk can invalidate server-side (notably on a restart), so a 401 there + is recoverable by logging in again. The client uses this to decide whether to + drop the cache and retry once after a 401. + """ + return not config.token and not config.session_key and bool(config.username and config.password) + + +def get_auth_header(config: SplunkConfig) -> str: + """Return the ``Authorization`` header value for *config*. + + A token or session key is used as-is. With only a username/password, a + session key is minted via :func:`login` and cached until + :data:`SESSION_TTL_SECONDS` elapses. + + Raises: + AuthError: If *config* carries no credential source. + """ + global _cached_session + if config.token: + return f"Bearer {config.token}" + if config.session_key: + return f"Splunk {config.session_key}" + if config.username and config.password: + cache_key = (config.base_url, config.username) + if ( + _cached_session + and _cached_session["key"] == cache_key + and time.time() < _cached_session["expires_at"] + ): + return _cached_session["header"] + key = login( + config.base_url, + config.username, + config.password, + verify=config.verify, + timeout=config.timeout, + ) + header = f"Splunk {key}" + _cached_session = { + "key": cache_key, + "header": header, + "expires_at": time.time() + SESSION_TTL_SECONDS, + } + return header + raise AuthError( + "No auth. Set SPLUNK_TOKEN (a JWT) or SPLUNK_SESSION_KEY " + "(a session key from /services/auth/login)." + ) + + +def login( + url: str, + username: str, + password: str, + *, + verify: bool | str = True, + timeout: float = 30.0, + transport: httpx.BaseTransport | None = None, +) -> str: + """Exchange a username/password for a Splunk session key. + + POSTs ``username`` / ``password`` (form-encoded, ``output_mode=json``) to + ``{url}/services/auth/login`` with no Authorization header, and returns the + ``sessionKey`` from the JSON response (``{"sessionKey": "..."}``). + + Args: + url: The Splunk management base URL (e.g. ``https://host:8089``). + username: The Splunk account name. + password: The account password (read from env/prompt, never a flag). + verify: TLS verification — True/False or a CA-bundle path. + timeout: Request timeout in seconds. + transport: An optional httpx transport, for tests (``MockTransport``). + + Returns: + The session key string. + + Raises: + AuthError: On a 401/403 (bad credentials) or a missing ``sessionKey``. + APIError: On any other non-2xx response. + TransportError: If Splunk cannot be reached. + """ + endpoint = f"{url.rstrip('/')}/services/auth/login" + try: + with httpx.Client(verify=verify, timeout=timeout, transport=transport) as http: + resp = http.post( + endpoint, + data={"username": username, "password": password, "output_mode": "json"}, + ) + except httpx.HTTPError as exc: + raise TransportError(f"Could not reach Splunk at {safe_target(url)}: {exc}") from exc + if resp.status_code in {401, 403}: + raise AuthError(f"Login failed ({resp.status_code}). Check the username and password.") + if resp.status_code >= 400: + raise APIError(f"Splunk returned {resp.status_code} for POST /services/auth/login") + try: + key = resp.json().get("sessionKey") + except ValueError: + key = None + if not key: + raise AuthError("Login response did not include a sessionKey.") + return key diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index 97bb445..c3c2718 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -9,10 +9,10 @@ from .commands.apps import app_install from .commands.auth import auth from .commands.cluster import cluster +from .commands.command_factory import build_group from .commands.config import config from .commands.datamodel import datamodel_accelerate from .commands.deploy import deploy_client, deploy_server -from .commands.factory import build_group from .commands.health import health from .commands.hec import hec from .commands.inspect import inspect diff --git a/src/vct_splunk/commands/api.py b/src/vct_splunk/commands/api.py index 38c8449..8b49e0b 100644 --- a/src/vct_splunk/commands/api.py +++ b/src/vct_splunk/commands/api.py @@ -4,9 +4,9 @@ import click -from ..core import api as core -from ..core.errors import UsageError -from . import output as out +from ..api.endpoints import raw as core +from ..output import formatter as out +from ..utils.errors import UsageError from .context import command diff --git a/src/vct_splunk/commands/apps.py b/src/vct_splunk/commands/apps.py index 8449e0c..1f548be 100644 --- a/src/vct_splunk/commands/apps.py +++ b/src/vct_splunk/commands/apps.py @@ -11,9 +11,9 @@ import click -from ..core import apps as core -from ..core.errors import UsageError -from . import output as out +from ..api.endpoints import apps as core +from ..output import formatter as out +from ..utils.errors import UsageError from .context import command from .write import do_write diff --git a/src/vct_splunk/commands/auth.py b/src/vct_splunk/commands/auth.py index 4299f6b..01b01fe 100644 --- a/src/vct_splunk/commands/auth.py +++ b/src/vct_splunk/commands/auth.py @@ -13,11 +13,11 @@ import click -from ..core import auth as core -from ..core.client import auth_status_from_env -from ..core.errors import UsageError -from ..core.redact import safe_target -from . import output as out +from ..auth import session as core +from ..config.loader import auth_status +from ..output import formatter as out +from ..utils.errors import UsageError +from ..utils.redact import safe_target from .context import command @@ -94,7 +94,7 @@ def login(ctx, username: str | None) -> None: @command def status(ctx) -> None: """Report the resolved target URL and active auth scheme (no secret shown).""" - resolved = auth_status_from_env(ctx.base_url, profile=ctx.profile) + resolved = auth_status(ctx.base_url, profile=ctx.profile) out.emit( {"target": safe_target(resolved.base_url), "auth_scheme": resolved.auth_scheme}, ctx.output_mode, diff --git a/src/vct_splunk/commands/cluster.py b/src/vct_splunk/commands/cluster.py index 3029c7a..4b76554 100644 --- a/src/vct_splunk/commands/cluster.py +++ b/src/vct_splunk/commands/cluster.py @@ -4,8 +4,8 @@ import click -from ..core import cluster as core -from . import output as out +from ..api.endpoints import cluster as core +from ..output import formatter as out from .context import command diff --git a/src/vct_splunk/commands/factory.py b/src/vct_splunk/commands/command_factory.py similarity index 88% rename from src/vct_splunk/commands/factory.py rename to src/vct_splunk/commands/command_factory.py index 751f011..f5d596e 100644 --- a/src/vct_splunk/commands/factory.py +++ b/src/vct_splunk/commands/command_factory.py @@ -1,4 +1,5 @@ -"""Build a Click command group from a declarative resource :class:`Spec`. +"""Build a Click command group from a declarative resource +:class:`~vct_splunk.api.endpoint_factory.EndpointConfig`. One :func:`build_group` turns a spec into a ``list`` / ``get`` / ``create`` / ``update`` / ``delete`` (and optional ``enable`` / ``disable``) group, wired to @@ -16,11 +17,11 @@ import click -from ..core.errors import UsageError -from ..core.namespace import resolve_ns -from ..core.parsing import parse_key_value_pairs -from ..core.resource import CrudResource, Field, Spec -from . import output as out +from ..api.endpoint_factory import EndpointConfig, Endpoints, Field +from ..output import formatter as out +from ..utils.errors import UsageError +from ..utils.namespace import resolve_ns +from ..utils.validation import parse_key_value_pairs from .context import AliasedGroup, command from .dispatch import dispatch_list, has_cloud_list from .write import do_write, refuse_cloud_write @@ -28,12 +29,12 @@ _VERB_ALIASES = {"add": "create", "edit": "update", "remove": "delete"} -def _help_for(spec: Spec, verb: str) -> str: +def _help_for(spec: EndpointConfig, verb: str) -> str: """One-line help for a generated command, in the hand-written commands' style.""" noun = spec.name.replace("-", " ") a = f"an {noun}" if noun[0] in "aeiou" else f"a {noun}" gated = " Gated write (--dry-run previews; --yes when non-interactive)." - ns = " Requires an app (--app or $SPLUNK_APP)." if spec.namespaced else "" + ns = " Requires an app (--app or $SPLUNK_APP)." if spec.scope == "namespaced" else "" texts = { "list": f"List every {noun}.", "get": f"Show one {noun}.", @@ -46,7 +47,9 @@ def _help_for(spec: Spec, verb: str) -> str: return texts[verb] -def _gate_args(spec: Spec, verb: str, name: str, owner, app) -> tuple[str, dict[str, Any]]: +def _gate_args( + spec: EndpointConfig, verb: str, name: str, owner, app +) -> tuple[str, dict[str, Any]]: """The confirmation phrase and audit event for one gated write. Namespaced resources name the target app in the prompt and record the @@ -54,15 +57,15 @@ def _gate_args(spec: Spec, verb: str, name: str, owner, app) -> tuple[str, dict[ """ action = f"{verb} {spec.name} '{name}'" event: dict[str, Any] = {"action": f"{spec.name}.{verb}", "name": name} - if spec.namespaced: + if spec.scope == "namespaced": action += f" in app '{app}'" event.update({"app": app, "owner": owner}) return action, event -def build_group(spec: Spec) -> click.Group: +def build_group(spec: EndpointConfig) -> click.Group: """Return the Click group for *spec*, exposing only the verbs it declares.""" - res = CrudResource(spec) + res = Endpoints(spec) aliases = {a: t for a, t in _VERB_ALIASES.items() if t in spec.verbs} @click.group(name=spec.name, cls=AliasedGroup, aliases=aliases, help=spec.help) @@ -163,7 +166,7 @@ def _delete(ctx, name) -> None: return grp -def _add_control(grp: click.Group, spec: Spec, res: CrudResource, verb: str) -> None: +def _add_control(grp: click.Group, spec: EndpointConfig, res: Endpoints, verb: str) -> None: """Register an enable/disable control command (kept in a helper to bind *verb*).""" @grp.command(verb, help=_help_for(spec, verb)) @@ -182,7 +185,7 @@ def _control(ctx, name) -> None: out.emit(result, ctx.output_mode, ctx.meta()) -def _field_options(spec: Spec, *, for_create: bool = False): +def _field_options(spec: EndpointConfig, *, for_create: bool = False): """A decorator that adds one Click option per (non-secret) field, plus --set. ``required`` fields are enforced only on create; update always sends just @@ -229,7 +232,9 @@ def _option_for(f: Field, *, required: bool = False): return click.option(f"--{dashed}", f.opt, **kwargs) -def _collect_fields(spec: Spec, opts: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]: +def _collect_fields( + spec: EndpointConfig, opts: dict[str, Any] +) -> tuple[dict[str, Any], dict[str, str]]: """Split Click options into (field values, --set pairs), resolving secrets.""" values = dict(opts) sets = parse_key_value_pairs(values.pop("_set", ())) @@ -245,7 +250,9 @@ def _collect_fields(spec: Spec, opts: dict[str, Any]) -> tuple[dict[str, Any], d return values, sets -def _ns(ctx: Any, spec: Spec, *, write_verb: str | None = None) -> tuple[str | None, str | None]: +def _ns( + ctx: Any, spec: EndpointConfig, *, write_verb: str | None = None +) -> tuple[str | None, str | None]: """Resolve (owner, app) for a namespaced spec; global specs ignore them. A `write_verb` marks this as a mutation, which a Cloud target refuses here @@ -253,6 +260,6 @@ def _ns(ctx: Any, spec: Spec, *, write_verb: str | None = None) -> tuple[str | N """ if write_verb is not None: refuse_cloud_write(ctx, spec.name, write_verb) - if not spec.namespaced: + if spec.scope != "namespaced": return (None, None) return resolve_ns(ctx.owner, ctx.app, for_write=write_verb is not None) diff --git a/src/vct_splunk/commands/config.py b/src/vct_splunk/commands/config.py index f24e5dd..cd99900 100644 --- a/src/vct_splunk/commands/config.py +++ b/src/vct_splunk/commands/config.py @@ -4,9 +4,9 @@ import click -from ..core import config as core -from ..core.namespace import resolve_ns -from . import output as out +from ..api.endpoints import config as core +from ..output import formatter as out +from ..utils.namespace import resolve_ns from .context import command diff --git a/src/vct_splunk/commands/context.py b/src/vct_splunk/commands/context.py index 6ba213d..ad827dd 100644 --- a/src/vct_splunk/commands/context.py +++ b/src/vct_splunk/commands/context.py @@ -26,15 +26,15 @@ import click -from ..core.backends import deduce_backend -from ..core.client import SplunkClient, config_from_env -from ..core.errors import SplunkError -from ..core.profiles import load_profile -from ..core.redact import safe_target -from . import output as out +from ..api.client import SplunkClient, create_client +from ..config.loader import load_config, load_profile +from ..output import formatter as out +from ..utils.backends import deduce_backend +from ..utils.errors import SplunkError +from ..utils.redact import safe_target if TYPE_CHECKING: - from ..core.acs.client import AcsClient + from ..api.acs.client import AcsClient class AliasedGroup(click.Group): @@ -92,7 +92,7 @@ def client(self) -> SplunkClient: """Build a :class:`SplunkClient` from the environment plus this context. Credentials and TLS settings are read from flags, the environment, and - the active profile (see :func:`vct_splunk.core.client.config_from_env`); + the active profile (see :func:`vct_splunk.config.loader.load_config`); the ``dry_run`` flag is carried over from the command line so that writes can be previewed. @@ -103,9 +103,9 @@ def client(self) -> SplunkClient: with ctx.client() as c: ... """ - cfg = config_from_env(self.base_url, profile=self.profile) + cfg = load_config(self.base_url, profile=self.profile) cfg.dry_run = self.dry_run - return SplunkClient(cfg) + return create_client(cfg) def acs_client(self) -> AcsClient: """Build an :class:`AcsClient` for the deduced Cloud stack. @@ -114,8 +114,8 @@ def acs_client(self) -> AcsClient: the cloud host (``$SPLUNK_URL`` or ``--base-url``), and the ACS Bearer token is read from ``$SPLUNK_ACS_TOKEN``. Use as a context manager. """ - from ..core.acs.client import AcsClient, acs_config_from_env - from ..core.backends import cloud_stack_from_url + from ..api.acs.client import AcsClient, acs_config_from_env + from ..utils.backends import cloud_stack_from_url stack = cloud_stack_from_url(self.base_url) return AcsClient(acs_config_from_env(stack)) @@ -137,7 +137,7 @@ def command(fn: Callable) -> Callable: ``--dry-run``/``--yes``/``--base-url``/``--app``/``--owner``), so the command body can focus on its own arguments. Any :class:`SplunkError` raised by the core is caught and rendered to stderr with the correct exit code via - :func:`vct_splunk.commands.output.fail`. + :func:`vct_splunk.output.formatter.fail`. Args: fn: The leaf command implementation, called as ``fn(ctx, **command_args)``. diff --git a/src/vct_splunk/commands/datamodel.py b/src/vct_splunk/commands/datamodel.py index 98747e6..3c7a3e2 100644 --- a/src/vct_splunk/commands/datamodel.py +++ b/src/vct_splunk/commands/datamodel.py @@ -10,10 +10,10 @@ import click -from ..core import datamodel as core -from ..core.namespace import resolve_ns -from ..core.path import path_segment -from . import output as out +from ..api.endpoints import datamodel as core +from ..output import formatter as out +from ..utils.namespace import resolve_ns +from ..utils.path import path_segment from .context import command from .write import do_write, refuse_cloud_write diff --git a/src/vct_splunk/commands/deploy.py b/src/vct_splunk/commands/deploy.py index e385462..f4b5a98 100644 --- a/src/vct_splunk/commands/deploy.py +++ b/src/vct_splunk/commands/deploy.py @@ -9,11 +9,11 @@ import click -from ..core import deploy as core -from ..core.errors import UsageError -from ..core.parsing import parse_key_value_pairs -from ..core.path import path_segment -from . import output as out +from ..api.endpoints import deploy as core +from ..output import formatter as out +from ..utils.errors import UsageError +from ..utils.path import path_segment +from ..utils.validation import parse_key_value_pairs from .context import command from .write import do_write diff --git a/src/vct_splunk/commands/dispatch.py b/src/vct_splunk/commands/dispatch.py index c0e319c..2f82198 100644 --- a/src/vct_splunk/commands/dispatch.py +++ b/src/vct_splunk/commands/dispatch.py @@ -13,8 +13,8 @@ from collections.abc import Callable from typing import Any -from ..core.acs import operations as acs -from ..core.errors import UnsupportedBackendError +from ..api.acs import operations as acs +from ..utils.errors import UnsupportedBackendError #: resource name -> the ACS read op. Only these resources have a Cloud route; the #: REST side is supplied by each call site (it already knows its own path/output). diff --git a/src/vct_splunk/commands/health.py b/src/vct_splunk/commands/health.py index 73b34ef..30b5efc 100644 --- a/src/vct_splunk/commands/health.py +++ b/src/vct_splunk/commands/health.py @@ -4,8 +4,8 @@ import click -from ..core import health as core -from . import output as out +from ..api.endpoints import health as core +from ..output import formatter as out from .context import command diff --git a/src/vct_splunk/commands/hec.py b/src/vct_splunk/commands/hec.py index e5711ab..b6ea257 100644 --- a/src/vct_splunk/commands/hec.py +++ b/src/vct_splunk/commands/hec.py @@ -9,9 +9,9 @@ import click -from ..core import hec as core -from ..core.path import path_segment -from . import output as out +from ..api.endpoints import hec as core +from ..output import formatter as out +from ..utils.path import path_segment from .context import command from .write import do_write diff --git a/src/vct_splunk/commands/inspect.py b/src/vct_splunk/commands/inspect.py index 787a70e..2e9c829 100644 --- a/src/vct_splunk/commands/inspect.py +++ b/src/vct_splunk/commands/inspect.py @@ -4,8 +4,8 @@ import click -from ..core.backends import inspect_report -from . import output as out +from ..output import formatter as out +from ..utils.backends import inspect_report from .context import command diff --git a/src/vct_splunk/commands/kvstore.py b/src/vct_splunk/commands/kvstore.py index c5c43ac..144c377 100644 --- a/src/vct_splunk/commands/kvstore.py +++ b/src/vct_splunk/commands/kvstore.py @@ -15,11 +15,11 @@ import click -from ..core import kvstore as core -from ..core.errors import UsageError -from ..core.namespace import resolve_ns -from ..core.path import path_segment -from . import output as out +from ..api.endpoints import kvstore as core +from ..output import formatter as out +from ..utils.errors import UsageError +from ..utils.namespace import resolve_ns +from ..utils.path import path_segment from .context import command from .write import do_write, refuse_cloud_write diff --git a/src/vct_splunk/commands/license.py b/src/vct_splunk/commands/license.py index 30fc5d5..703eabd 100644 --- a/src/vct_splunk/commands/license.py +++ b/src/vct_splunk/commands/license.py @@ -4,8 +4,8 @@ import click -from ..core import license as core -from . import output as out +from ..api.endpoints import license as core +from ..output import formatter as out from .context import command diff --git a/src/vct_splunk/commands/lookup.py b/src/vct_splunk/commands/lookup.py index 4d862c2..833234a 100644 --- a/src/vct_splunk/commands/lookup.py +++ b/src/vct_splunk/commands/lookup.py @@ -11,9 +11,9 @@ import click -from ..core import lookups as core -from ..core.namespace import resolve_ns -from . import output as out +from ..api.endpoints import lookups as core +from ..output import formatter as out +from ..utils.namespace import resolve_ns from .context import command from .write import do_write, refuse_cloud_write diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index cfe4dbf..6a366be 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -1,8 +1,8 @@ """The flat registry of factory-generated CRUD resources. -Each entry is data -- a :class:`~vct_splunk.core.resource.Spec` describing a +Each entry is data -- a :class:`~vct_splunk.api.endpoint_factory.EndpointConfig` describing a CRUD-shaped Splunk resource. ``cli.py`` loops over :data:`REGISTRY` and builds a -command group per spec via :func:`vct_splunk.commands.factory.build_group`. +command group per spec via :func:`vct_splunk.commands.command_factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. Specs are intentionally thin: a path, help text, and the verbs/flags that shape @@ -14,7 +14,7 @@ from __future__ import annotations -from ..core.resource import Field, Spec +from ..api.endpoint_factory import EndpointConfig, Field # Verb set for inputs/outputs that also support enable/disable control endpoints. # (Plain CRUD is the Spec default, so it does not need a named constant.) @@ -25,7 +25,7 @@ # factory and keeps its curated flags/output, and `saved-search` adds a # hand-written `run` (dispatch) command on top of the generated group. -INDEX = Spec( +INDEX = EndpointConfig( name="index", path="/services/data/indexes", help="Splunk indexes.", @@ -54,12 +54,12 @@ }, ) -SAVED_SEARCH = Spec( +SAVED_SEARCH = EndpointConfig( name="saved-search", path="saved/searches", help="Splunk saved searches (namespaced by owner + app).", verbs=("list", "get", "create", "update", "delete"), - namespaced=True, + scope="namespaced", fields=( Field("search", key="search", required=True, help="SPL for the saved search."), Field("description", key="description", help="Description of the saved search."), @@ -78,7 +78,7 @@ # --- Access (#4) ------------------------------------------------------------- -USER = Spec( +USER = EndpointConfig( name="user", path="/services/authentication/users", help="Splunk users (local authentication).", @@ -92,13 +92,13 @@ ), ) -ROLE = Spec( +ROLE = EndpointConfig( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", ) -CAPABILITY = Spec( +CAPABILITY = EndpointConfig( name="capability", path="/services/authorization/capabilities", help="Authorization capabilities (read-only).", @@ -109,7 +109,7 @@ # Global, under /services/data/inputs|outputs. The HEC token's value is returned # in the create response (the caller needs it); token rotation stays hand-written. -MONITOR_INPUT = Spec( +MONITOR_INPUT = EndpointConfig( name="monitor-input", path="/services/data/inputs/monitor", help="File and directory monitor inputs.", @@ -117,21 +117,21 @@ absolute_name=True, ) -TCP_INPUT = Spec( +TCP_INPUT = EndpointConfig( name="tcp-input", path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, ) -UDP_INPUT = Spec( +UDP_INPUT = EndpointConfig( name="udp-input", path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, ) -SCRIPT_INPUT = Spec( +SCRIPT_INPUT = EndpointConfig( name="script-input", path="/services/data/inputs/script", help="Scripted inputs.", @@ -139,7 +139,7 @@ absolute_name=True, ) -HEC_TOKEN = Spec( +HEC_TOKEN = EndpointConfig( name="hec-token", path="/services/data/inputs/http", help="HTTP Event Collector tokens.", @@ -149,14 +149,14 @@ mints_secret=True, ) -OUTPUT_SERVER = Spec( +OUTPUT_SERVER = EndpointConfig( name="output-server", path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, ) -OUTPUT_GROUP = Spec( +OUTPUT_GROUP = EndpointConfig( name="output-group", path="/services/data/outputs/tcp/group", help="Forwarder output groups.", @@ -166,47 +166,47 @@ # --- Knowledge objects (#8) -------------------------------------------------- # Namespaced. Tags, data models, and lookup-file upload stay hand-written. -MACRO = Spec( +MACRO = EndpointConfig( name="macro", path="configs/conf-macros", help="Search macros.", - namespaced=True, + scope="namespaced", ) -EVENTTYPE = Spec( +EVENTTYPE = EndpointConfig( name="eventtype", path="saved/eventtypes", help="Event types.", - namespaced=True, + scope="namespaced", ) -EXTRACTION = Spec( +EXTRACTION = EndpointConfig( name="extraction", path="data/transforms/extractions", help="Field extractions (transforms).", - namespaced=True, + scope="namespaced", ) -LOOKUP_DEFINITION = Spec( +LOOKUP_DEFINITION = EndpointConfig( name="lookup-definition", path="data/transforms/lookups", help="Lookup definitions (transforms).", - namespaced=True, + scope="namespaced", ) -TAG = Spec( +TAG = EndpointConfig( name="tag", path="saved/fvtags", help="Field-value tags (settings via --set).", - namespaced=True, + scope="namespaced", verbs=("list", "get", "create", "update", "delete"), ) -DATAMODEL = Spec( +DATAMODEL = EndpointConfig( name="datamodel", path="datamodel/model", help="Data models (settings via --set). Acceleration is a separate command.", - namespaced=True, + scope="namespaced", ) # --- KV Store (#9) ----------------------------------------------------------- @@ -214,18 +214,18 @@ # (field.=), so they go through --set. Data records are a document # store and stay hand-written. -KVSTORE_COLLECTION = Spec( +KVSTORE_COLLECTION = EndpointConfig( name="kvstore-collection", path="storage/collections/config", help="KV Store collection schemas (use --set field.= for fields).", - namespaced=True, + scope="namespaced", ) # --- Platform (#10) ---------------------------------------------------------- # Cluster control, restart, and peers are action/read endpoints and stay # hand-written. -MESSAGE = Spec( +MESSAGE = EndpointConfig( name="message", path="/services/messages", help="System bulletin messages.", @@ -236,14 +236,14 @@ # Lifecycle only. Install-from-file/URL is a multipart upload and stays # hand-written. -APP = Spec( +APP = EndpointConfig( name="app", path="/services/apps/local", help="Installed apps (install from file/URL is separate).", verbs=("list", "get", "delete", "enable", "disable"), ) -REGISTRY: list[Spec] = [ +REGISTRY: list[EndpointConfig] = [ USER, ROLE, CAPABILITY, diff --git a/src/vct_splunk/commands/saved_search.py b/src/vct_splunk/commands/saved_search.py index 2734125..98c028f 100644 --- a/src/vct_splunk/commands/saved_search.py +++ b/src/vct_splunk/commands/saved_search.py @@ -11,12 +11,12 @@ import click -from ..core import saved_searches as core -from ..core.errors import UnsupportedBackendError -from ..core.namespace import ns_path, resolve_ns -from . import output as out +from ..api.endpoints import saved_searches as core +from ..output import formatter as out +from ..utils.errors import UnsupportedBackendError +from ..utils.namespace import ns_path, resolve_ns +from .command_factory import build_group from .context import command -from .factory import build_group from .registry import SAVED_SEARCH from .write import do_write diff --git a/src/vct_splunk/commands/search.py b/src/vct_splunk/commands/search.py index fddf615..07765ac 100644 --- a/src/vct_splunk/commands/search.py +++ b/src/vct_splunk/commands/search.py @@ -4,9 +4,9 @@ import click -from ..core import jobs as jobs_core -from ..core import search as core -from . import output as out +from ..api.endpoints import jobs as jobs_core +from ..api.endpoints import search as core +from ..output import formatter as out from .context import command from .write import do_write diff --git a/src/vct_splunk/commands/server.py b/src/vct_splunk/commands/server.py index 688f963..af8c806 100644 --- a/src/vct_splunk/commands/server.py +++ b/src/vct_splunk/commands/server.py @@ -4,10 +4,10 @@ import click -from ..core import server as core -from ..core.errors import UsageError -from ..core.parsing import parse_key_value_pairs -from . import output as out +from ..api.endpoints import server as core +from ..output import formatter as out +from ..utils.errors import UsageError +from ..utils.validation import parse_key_value_pairs from .context import command from .write import do_write diff --git a/src/vct_splunk/commands/shcluster.py b/src/vct_splunk/commands/shcluster.py index e62d309..4242e25 100644 --- a/src/vct_splunk/commands/shcluster.py +++ b/src/vct_splunk/commands/shcluster.py @@ -4,8 +4,8 @@ import click -from ..core import cluster as core -from . import output as out +from ..api.endpoints import cluster as core +from ..output import formatter as out from .context import command diff --git a/src/vct_splunk/commands/write.py b/src/vct_splunk/commands/write.py index 9950417..44f9a33 100644 --- a/src/vct_splunk/commands/write.py +++ b/src/vct_splunk/commands/write.py @@ -15,11 +15,12 @@ from collections.abc import Callable from typing import Any -from ..core import audit -from ..core.client import SplunkClient, config_from_env -from ..core.errors import UnsupportedBackendError -from ..core.redact import safe_target -from . import output as out +from ..api.client import SplunkClient +from ..config.loader import load_config +from ..output import formatter as out +from ..utils import audit +from ..utils.errors import UnsupportedBackendError +from ..utils.redact import safe_target def do_write( @@ -51,7 +52,7 @@ def do_write( resource, _, verb = str(audit_event.get("action", "")).partition(".") refuse_cloud_write(ctx, resource, verb) target = safe_target( - target or config_from_env(ctx.base_url, profile=getattr(ctx, "profile", None)).base_url + target or load_config(ctx.base_url, profile=getattr(ctx, "profile", None)).base_url ) out.confirm_write(ctx, action, target) with ctx.client() as c: diff --git a/src/vct_splunk/config/__init__.py b/src/vct_splunk/config/__init__.py new file mode 100644 index 0000000..8349a8e --- /dev/null +++ b/src/vct_splunk/config/__init__.py @@ -0,0 +1 @@ +"""Configuration loading and typed config objects (profiles, env, flags).""" diff --git a/src/vct_splunk/config/loader.py b/src/vct_splunk/config/loader.py new file mode 100644 index 0000000..446474b --- /dev/null +++ b/src/vct_splunk/config/loader.py @@ -0,0 +1,188 @@ +"""Config loading: INI profiles plus env-var and flag merging. Click-free. + +Mirrors the cribl-cli ``config/loader`` role: one module owns "where do +connection settings come from". A profile is a named ``[section]`` in a plain +INI file with any of these keys: ``url``, ``token``, ``session_key``, ``app``, +``owner``. Resolution order for the file path is ``$VCT_SPLUNK_CONFIG``, else +``$XDG_CONFIG_HOME/vct-splunk/config``, else ``~/.config/vct-splunk/config``. + +Every value applies **flag > env > profile > built-in default**: a profile only +ever fills gaps, never overriding an explicit flag or environment variable. +Reading is best-effort — a missing file is not an error. + +:func:`load_config` merges those sources into a +:class:`~vct_splunk.config.types.SplunkConfig`. It validates that *some* +credential source exists but performs no network I/O — the actual login (when +only a username/password is available) happens lazily in +:mod:`vct_splunk.auth.session`, per request, via the client's auth transport. +""" + +from __future__ import annotations + +import configparser +import os +from pathlib import Path + +from ..utils.errors import UsageError +from .types import AuthStatus, SplunkConfig + +#: The profile keys a section may define. Anything else is ignored. +PROFILE_KEYS = ("url", "token", "session_key", "app", "owner") + + +def config_path() -> Path: + """Return the config-file path, honoring ``$VCT_SPLUNK_CONFIG`` / XDG. + + The file need not exist; this only computes where it *would* live. + """ + override = os.environ.get("VCT_SPLUNK_CONFIG") + if override: + return Path(override) + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "vct-splunk" / "config" + + +def load_profile(name: str | None, *, credentials: bool = True) -> dict[str, str]: + """Return the named profile's keys, or ``{}`` when there is nothing to load. + + Args: + name: The profile (INI section) name, or None to load nothing. + credentials: When False, the secret-bearing keys (``token``, + ``session_key``) are excluded, so a caller that only needs the URL + or namespace cannot accidentally pull a credential. + + Returns: + A dict of the profile's recognized keys (see :data:`PROFILE_KEYS`). + Empty when ``name`` is None, the file is absent or unreadable, or the + section does not exist — a missing file is deliberately not an error. + """ + if not name: + return {} + path = config_path() + if not path.is_file(): + return {} + parser = configparser.ConfigParser(interpolation=None) + try: + parser.read(path) + except UnicodeError as exc: + raise UsageError(f"Profile file {path} is not valid UTF-8.") from exc + except configparser.Error as exc: + raise UsageError(f"Profile file {path} is malformed: {exc}.") from exc + except OSError: + return {} + if not parser.has_section(name): + return {} + section = parser[name] + keys = ( + PROFILE_KEYS + if credentials + else tuple(key for key in PROFILE_KEYS if key not in {"token", "session_key"}) + ) + values = {key: section[key] for key in keys if key in section} + return values + + +def require_private_profile() -> None: + """Require owner-only access before a selected profile credential is used.""" + if os.name != "posix": + return + path = config_path() + try: + mode = path.stat().st_mode & 0o777 + except OSError: + return + if mode & 0o077: + raise UsageError( + f"Profile file {path} contains selected credentials and must have mode 0600." + ) + + +def load_config(base_url: str | None = None, *, profile: str | None = None) -> SplunkConfig: + """Build a :class:`SplunkConfig` from flags, the environment, and a profile. + + Precedence for each value is **flag > env > profile > built-in default**: an + explicit ``base_url`` (from ``--base-url``) wins, then the environment, then + the active config-file profile, then any hard-coded fallback. The profile is + consulted only when the flag and env var are both unset, so callers that set + ``SPLUNK_URL`` / ``SPLUNK_TOKEN`` keep their existing behavior. + + Credential sources, in priority order: ``SPLUNK_TOKEN`` (a JWT, sent as + ``Bearer``), ``SPLUNK_SESSION_KEY`` (sent as ``Splunk``), then + ``SPLUNK_USERNAME``/``SPLUNK_PASSWORD`` (exchanged for a session key lazily, + on the first request). No network I/O happens here. + + Args: + base_url: An explicit management URL from ``--base-url``, or None. + profile: The active profile name (from ``--profile`` / ``$SPLUNK_PROFILE``), + or None for no profile. + + Returns: + A resolved config carrying whichever credential sources were found. + + Raises: + UsageError: If no URL or no credential source can be resolved. + """ + status, prof, verify = _resolve_target(base_url, profile) + env_token = os.environ.get("SPLUNK_TOKEN") + env_session_key = os.environ.get("SPLUNK_SESSION_KEY") + token = env_token or prof.get("token") + session_key = env_session_key or prof.get("session_key") + username = os.environ.get("SPLUNK_USERNAME") + password = os.environ.get("SPLUNK_PASSWORD") + if token: + if not env_token: + require_private_profile() + session_key = username = password = None + elif session_key: + if not env_session_key: + require_private_profile() + username = password = None + elif not (username and password): + raise UsageError( + "No auth. Set SPLUNK_TOKEN (a JWT) or SPLUNK_SESSION_KEY " + "(a session key from /services/auth/login)." + ) + return SplunkConfig( + base_url=status.base_url, + token=token, + session_key=session_key, + username=username, + password=password, + verify=verify, + ) + + +def auth_status(base_url: str | None = None, *, profile: str | None = None) -> AuthStatus: + """Resolve the active auth scheme without exchanging username/password.""" + status, prof, _verify = _resolve_target(base_url, profile) + env_token = os.environ.get("SPLUNK_TOKEN") + env_session_key = os.environ.get("SPLUNK_SESSION_KEY") + if env_token or prof.get("token"): + if not env_token: + require_private_profile() + scheme = "Bearer" + elif env_session_key or prof.get("session_key"): + if not env_session_key: + require_private_profile() + scheme = "Splunk" + elif os.environ.get("SPLUNK_USERNAME") and os.environ.get("SPLUNK_PASSWORD"): + scheme = "Splunk" + else: + scheme = "none" + return AuthStatus(status.base_url, scheme) + + +def _resolve_target( + base_url: str | None, profile: str | None +) -> tuple[AuthStatus, dict[str, str], bool | str]: + """Resolve shared URL, profile, and TLS inputs without authenticating.""" + prof = load_profile(profile) + url = base_url or os.environ.get("SPLUNK_URL") or prof.get("url") + if not url: + raise UsageError("No Splunk URL. Set SPLUNK_URL or pass --base-url.") + ca = os.environ.get("SPLUNK_CA_BUNDLE") + verify = ca or ( + os.environ.get("SPLUNK_VERIFY", "true").strip().lower() not in {"0", "false", "no"} + ) + return AuthStatus(url.rstrip("/"), "none"), prof, verify diff --git a/src/vct_splunk/config/types.py b/src/vct_splunk/config/types.py new file mode 100644 index 0000000..9574428 --- /dev/null +++ b/src/vct_splunk/config/types.py @@ -0,0 +1,48 @@ +"""Typed configuration objects. Click-free. + +:class:`SplunkConfig` is the single bundle of connection and credential +settings that the API client (:mod:`vct_splunk.api.client`) and the auth layer +(:mod:`vct_splunk.auth.session`) consume. It carries credential *sources* +(token, session key, or username/password) rather than a resolved header — +resolution happens lazily, per request, in the auth transport, so a +long-running consumer (a web backend embedding this package) can re-login +transparently when a session expires. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class SplunkConfig: + """Connection and credential settings for one Splunk target. + + Attributes: + base_url: The management URL, e.g. ``https://host:8089``. + token: A Splunk JWT, sent as ``Authorization: Bearer ``. + session_key: A session key from ``/services/auth/login``, sent as + ``Authorization: Splunk ``. Used when no token is set. + username: Login fallback when neither token nor session key is set. + password: Login fallback partner of ``username``. + verify: TLS verification — True/False, or a path to a CA bundle. + timeout: Default per-request timeout in seconds. + dry_run: When True, mutating requests are previewed and never sent. + """ + + base_url: str + token: str | None = None + session_key: str | None = None + username: str | None = None + password: str | None = None + verify: bool | str = True + timeout: float = 30.0 + dry_run: bool = False + + +@dataclass(frozen=True) +class AuthStatus: + """Resolved target and authentication scheme without performing login.""" + + base_url: str + auth_scheme: str diff --git a/src/vct_splunk/core/__init__.py b/src/vct_splunk/core/__init__.py deleted file mode 100644 index 54051a3..0000000 --- a/src/vct_splunk/core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Click-free core: the Splunk REST client and pure operation functions. - -Nothing in this package imports Click. Modules here are plain, importable -functions plus typed errors, so the logic can be reused and unit-tested without -the CLI. The Click adapters that call into this package live in -:mod:`vct_splunk.commands`. -""" diff --git a/src/vct_splunk/core/acs/__init__.py b/src/vct_splunk/core/acs/__init__.py deleted file mode 100644 index 53512c8..0000000 --- a/src/vct_splunk/core/acs/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Minimal, read-only Splunk Cloud ACS (Admin Config Service) support. - -Cloud coverage is read-only until a real canary certifies it against a live -stack. The credential-free integration test compares the live public OpenAPI -to the operation declarations that the client actually uses. -""" diff --git a/src/vct_splunk/core/auth.py b/src/vct_splunk/core/auth.py deleted file mode 100644 index 415fd36..0000000 --- a/src/vct_splunk/core/auth.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Session login against ``/services/auth/login``. Click-free core. - -This is the one REST call that does **not** carry an Authorization header: the -credentials travel in the form body, and Splunk hands back a session key the -caller then uses as ``Authorization: Splunk ``. We keep it apart -from :class:`~vct_splunk.core.client.SplunkClient` (which always attaches a token -header) for exactly that reason. -""" - -from __future__ import annotations - -import httpx - -from .errors import APIError, AuthError, TransportError -from .redact import safe_target - - -def login( - url: str, - username: str, - password: str, - *, - verify: bool | str = True, - timeout: float = 30.0, - transport: httpx.BaseTransport | None = None, -) -> str: - """Exchange a username/password for a Splunk session key. - - POSTs ``username`` / ``password`` (form-encoded, ``output_mode=json``) to - ``{url}/services/auth/login`` with no Authorization header, and returns the - ``sessionKey`` from the JSON response (``{"sessionKey": "..."}``). - - Args: - url: The Splunk management base URL (e.g. ``https://host:8089``). - username: The Splunk account name. - password: The account password (read from env/prompt, never a flag). - verify: TLS verification — True/False or a CA-bundle path. - timeout: Request timeout in seconds. - transport: An optional httpx transport, for tests (``MockTransport``). - - Returns: - The session key string. - - Raises: - AuthError: On a 401/403 (bad credentials) or a missing ``sessionKey``. - APIError: On any other non-2xx response. - TransportError: If Splunk cannot be reached. - """ - endpoint = f"{url.rstrip('/')}/services/auth/login" - try: - with httpx.Client(verify=verify, timeout=timeout, transport=transport) as http: - resp = http.post( - endpoint, - data={"username": username, "password": password, "output_mode": "json"}, - ) - except httpx.HTTPError as exc: - raise TransportError(f"Could not reach Splunk at {safe_target(url)}: {exc}") from exc - if resp.status_code in {401, 403}: - raise AuthError(f"Login failed ({resp.status_code}). Check the username and password.") - if resp.status_code >= 400: - raise APIError(f"Splunk returned {resp.status_code} for POST /services/auth/login") - try: - key = resp.json().get("sessionKey") - except ValueError: - key = None - if not key: - raise AuthError("Login response did not include a sessionKey.") - return key diff --git a/src/vct_splunk/core/client.py b/src/vct_splunk/core/client.py deleted file mode 100644 index 03e1488..0000000 --- a/src/vct_splunk/core/client.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Splunk REST client: auth, TLS, retries, pagination, dry-run. Click-free core. - -One place owns transport concerns so every command behaves consistently. The auth -token lives only in a header (never logged); mutating requests are suppressed (and -previewed) when ``dry_run`` is set. -""" - -from __future__ import annotations - -import os -import time -from dataclasses import dataclass -from typing import Any - -import httpx - -from . import auth -from .errors import APIError, AuthError, NotFoundError, TransportError, UsageError -from .profiles import load_profile, require_private_profile -from .redact import safe_target - -_RETRY_STATUS = {429, 503} -_MAX_RETRIES = 3 - - -@dataclass -class ClientConfig: - base_url: str - token: str - verify: bool | str = True # True/False, or a path to a CA bundle - timeout: float = 30.0 - dry_run: bool = False - # Splunk accepts two REST auth schemes via the Authorization header: a JWT as - # "Bearer " (the default) and a session key as "Splunk ". - auth_scheme: str = "Bearer" - - -@dataclass(frozen=True) -class AuthStatus: - """Resolved target and authentication scheme without performing login.""" - - base_url: str - auth_scheme: str - - -def config_from_env(base_url: str | None = None, *, profile: str | None = None) -> ClientConfig: - """Build a :class:`ClientConfig` from flags, the environment, and a profile. - - Precedence for each value is **flag > env > profile > built-in default**: an - explicit ``base_url`` (from ``--base-url``) wins, then the environment, then - the active config-file profile (see :func:`vct_splunk.core.profiles.load_profile`), - then any hard-coded fallback. The profile is consulted only when the flag and - env var are both unset, so callers that set ``SPLUNK_URL`` / ``SPLUNK_TOKEN`` - keep their existing behavior. - - Args: - base_url: An explicit management URL from ``--base-url``, or None. - profile: The active profile name (from ``--profile`` / ``$SPLUNK_PROFILE``), - or None for no profile. - - Returns: - A resolved config. Auth is ``Bearer`` when a token is present, else - ``Splunk`` when a session key is present. - - Raises: - UsageError: If no URL or no credential can be resolved. - """ - status, prof, verify = _resolve_auth(base_url, profile) - url = status.base_url - # A JWT (SPLUNK_TOKEN) is the primary path; a session key (SPLUNK_SESSION_KEY) is the - # simple alternative; both fall back to the active profile. As a last resort the client - # logs in with SPLUNK_USERNAME/SPLUNK_PASSWORD to get a session key itself -- handy for - # CI, but not a documented or encouraged way to authenticate. - env_token = os.environ.get("SPLUNK_TOKEN") - env_session_key = os.environ.get("SPLUNK_SESSION_KEY") - token = env_token or prof.get("token") - session_key = env_session_key or prof.get("session_key") - if token: - if not env_token: - require_private_profile() - scheme, credential = "Bearer", token - elif session_key: - if not env_session_key: - require_private_profile() - scheme, credential = "Splunk", session_key - elif (username := os.environ.get("SPLUNK_USERNAME")) and ( - password := os.environ.get("SPLUNK_PASSWORD") - ): - scheme, credential = "Splunk", auth.login(url, username, password, verify=verify) - else: - raise UsageError( - "No auth. Set SPLUNK_TOKEN (a JWT) or SPLUNK_SESSION_KEY " - "(a session key from /services/auth/login)." - ) - return ClientConfig(base_url=url, token=credential, verify=verify, auth_scheme=scheme) - - -def auth_status_from_env(base_url: str | None = None, *, profile: str | None = None) -> AuthStatus: - """Resolve the active auth scheme without exchanging username/password.""" - status, prof, _verify = _resolve_auth(base_url, profile) - env_token = os.environ.get("SPLUNK_TOKEN") - env_session_key = os.environ.get("SPLUNK_SESSION_KEY") - if env_token or prof.get("token"): - if not env_token: - require_private_profile() - scheme = "Bearer" - elif env_session_key or prof.get("session_key"): - if not env_session_key: - require_private_profile() - scheme = "Splunk" - elif os.environ.get("SPLUNK_USERNAME") and os.environ.get("SPLUNK_PASSWORD"): - scheme = "Splunk" - else: - scheme = "none" - return AuthStatus(status.base_url, scheme) - - -def _resolve_auth( - base_url: str | None, profile: str | None -) -> tuple[AuthStatus, dict[str, str], bool | str]: - """Resolve shared URL, profile, and TLS inputs without authenticating.""" - prof = load_profile(profile) - url = base_url or os.environ.get("SPLUNK_URL") or prof.get("url") - if not url: - raise UsageError("No Splunk URL. Set SPLUNK_URL or pass --base-url.") - ca = os.environ.get("SPLUNK_CA_BUNDLE") - verify = ca or ( - os.environ.get("SPLUNK_VERIFY", "true").strip().lower() not in {"0", "false", "no"} - ) - return AuthStatus(url.rstrip("/"), "none"), prof, verify - - -class SplunkClient: - def __init__( - self, config: ClientConfig, *, transport: httpx.BaseTransport | None = None - ) -> None: - self.config = config - self._http = httpx.Client( - base_url=config.base_url, - headers={"Authorization": f"{config.auth_scheme} {config.token}"}, - verify=config.verify, - timeout=config.timeout, - transport=transport, - ) - - def __enter__(self) -> SplunkClient: - return self - - def __exit__(self, exc_type: object, exc: object, tb: object) -> None: - self._http.close() - - def get(self, path: str, params: dict[str, Any] | None = None) -> Any: - """GET an endpoint and return its parsed JSON response.""" - return self._request("GET", path, params=params) - - def post( - self, path: str, data: dict[str, Any], *, timeout: float | None = None - ) -> dict[str, Any]: - """Non-mutating POST (e.g. a search job). Never gated by dry-run.""" - return self._request("POST", path, data=data, timeout=timeout) - - def write(self, method: str, path: str, data: dict[str, Any]) -> dict[str, Any]: - """Mutating request. When dry_run is set, sends nothing and returns a preview.""" - if self.config.dry_run: - return { - "dry_run": True, - "request": {"method": method, "path": "/" + path.lstrip("/"), "body": data}, - "target": safe_target(self.config.base_url), - } - return self._request(method, path, data=data) - - def write_json(self, method: str, path: str, body: Any) -> Any: - """Mutating request with a JSON body (Content-Type: application/json). - - The KV Store *data* endpoints are a JSON document store, not the Splunk - ``entry[].content`` envelope: requests carry a JSON body and responses are - plain JSON objects/arrays. This is the JSON-body sibling of :meth:`write`; - it is dry-run gated the same way and returns the parsed JSON otherwise. - """ - if self.config.dry_run: - return { - "dry_run": True, - "request": {"method": method, "path": "/" + path.lstrip("/"), "body": body}, - "target": safe_target(self.config.base_url), - } - return self._request(method, path, json_body=body) - - def get_collection( - self, path: str, params: dict[str, Any] | None = None, *, page: int = 200 - ) -> list[dict[str, Any]]: - """Auto-paginate a Splunk collection endpoint and return every entry.""" - base = dict(params or {}) - offset: int = 0 - out: list[dict[str, Any]] = [] - while True: - body = self._request("GET", path, params={**base, "count": page, "offset": offset}) - entries = body.get("entry") or [] - out.extend(entries) - total = (body.get("paging") or {}).get("total") - offset += len(entries) - if not entries or len(entries) < page or (total is not None and offset >= total): - return out - - def _request( - self, method, path, *, params=None, data=None, json_body=None, timeout=None - ) -> Any: - # The classic Splunk endpoints speak the entry/content envelope and need - # output_mode=json; the KV Store data store is already JSON, so a JSON-body - # request skips that param and sends application/json instead of form data. - params = dict(params or {}) - if json_body is None: - params["output_mode"] = "json" - url = "/" + path.lstrip("/") - for attempt in range(_MAX_RETRIES + 1): - try: - resp = self._http.request( - method, - url, - params=params, - data=data, - json=json_body, - # Only None means "unset" — an explicit timeout (even 0) is honored. - timeout=self.config.timeout if timeout is None else timeout, - ) - except httpx.HTTPError as exc: - raise TransportError( - f"Could not reach Splunk at {safe_target(self.config.base_url)}: {exc}" - ) from exc - if resp.status_code in _RETRY_STATUS and attempt < _MAX_RETRIES: - time.sleep(_retry_after(resp, attempt)) - continue - return _handle(resp, method, url) - raise TransportError("retries exhausted") # pragma: no cover - - -def _retry_after(resp: httpx.Response, attempt: int) -> float: - hdr = resp.headers.get("Retry-After") - if hdr and hdr.isdigit(): - return float(hdr) - return min(2.0**attempt, 8.0) - - -def _handle(resp: httpx.Response, method: str, url: str) -> Any: - if resp.status_code == 401: - raise AuthError("Authentication failed (401). Check SPLUNK_TOKEN or SPLUNK_SESSION_KEY.") - if resp.status_code == 403: - raise AuthError(f"Permission denied (403) for {method} {url}.") - if resp.status_code == 404: - raise NotFoundError(f"Not found: {url}") - if resp.status_code >= 400: - raise APIError( - f"Splunk returned {resp.status_code} for {method} {url}", - status=resp.status_code, - details=_safe_body(resp), - ) - if not resp.content: - return {} - try: - return resp.json() - except ValueError: - return {"raw": resp.text} - - -def _safe_body(resp: httpx.Response) -> Any: - try: - return resp.json() - except ValueError: - return resp.text[:500] diff --git a/src/vct_splunk/core/profiles.py b/src/vct_splunk/core/profiles.py deleted file mode 100644 index 8d7acae..0000000 --- a/src/vct_splunk/core/profiles.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Config-file profiles (stdlib ``configparser``). Click-free core. - -A profile is a named bundle of connection settings so a user doesn't have to -export the same environment every session. The file is a plain INI; each -``[section]`` is one profile with any of these keys: ``url``, ``token``, -``session_key``, ``app``, ``owner``. - -Resolution order for the file path is ``$VCT_SPLUNK_CONFIG``, else -``$XDG_CONFIG_HOME/vct-splunk/config``, else ``~/.config/vct-splunk/config``. - -A profile only ever *fills gaps*: every consumer applies flag > env > profile > -default, so a profile never overrides an explicit flag or environment variable. -Reading is best-effort — a missing file is not an error. -""" - -from __future__ import annotations - -import configparser -import os -from pathlib import Path - -from .errors import UsageError - -#: The profile keys a section may define. Anything else is ignored. -PROFILE_KEYS = ("url", "token", "session_key", "app", "owner") - - -def config_path() -> Path: - """Return the config-file path, honoring ``$VCT_SPLUNK_CONFIG`` / XDG. - - The file need not exist; this only computes where it *would* live. - """ - override = os.environ.get("VCT_SPLUNK_CONFIG") - if override: - return Path(override) - xdg = os.environ.get("XDG_CONFIG_HOME") - base = Path(xdg) if xdg else Path.home() / ".config" - return base / "vct-splunk" / "config" - - -def load_profile(name: str | None, *, credentials: bool = True) -> dict[str, str]: - """Return the named profile's keys, or ``{}`` when there is nothing to load. - - Args: - name: The profile (INI section) name, or None to load nothing. - - Returns: - A dict of the profile's recognized keys (see :data:`PROFILE_KEYS`). - Empty when ``name`` is None, the file is absent or unreadable, or the - section does not exist — a missing file is deliberately not an error. - """ - if not name: - return {} - path = config_path() - if not path.is_file(): - return {} - parser = configparser.ConfigParser(interpolation=None) - try: - parser.read(path) - except UnicodeError as exc: - raise UsageError(f"Profile file {path} is not valid UTF-8.") from exc - except configparser.Error as exc: - raise UsageError(f"Profile file {path} is malformed: {exc}.") from exc - except OSError: - return {} - if not parser.has_section(name): - return {} - section = parser[name] - keys = ( - PROFILE_KEYS - if credentials - else tuple(key for key in PROFILE_KEYS if key not in {"token", "session_key"}) - ) - values = {key: section[key] for key in keys if key in section} - return values - - -def require_private_profile() -> None: - """Require owner-only access before a selected profile credential is used.""" - if os.name != "posix": - return - path = config_path() - try: - mode = path.stat().st_mode & 0o777 - except OSError: - return - if mode & 0o077: - raise UsageError( - f"Profile file {path} contains selected credentials and must have mode 0600." - ) diff --git a/src/vct_splunk/output/__init__.py b/src/vct_splunk/output/__init__.py new file mode 100644 index 0000000..3589cb5 --- /dev/null +++ b/src/vct_splunk/output/__init__.py @@ -0,0 +1 @@ +"""Output formatting: JSON/table rendering and the error envelope.""" diff --git a/src/vct_splunk/commands/output.py b/src/vct_splunk/output/formatter.py similarity index 98% rename from src/vct_splunk/commands/output.py rename to src/vct_splunk/output/formatter.py index 4b6d3b9..98f6907 100644 --- a/src/vct_splunk/commands/output.py +++ b/src/vct_splunk/output/formatter.py @@ -12,7 +12,7 @@ import click -from ..core.errors import SplunkError, UsageError +from ..utils.errors import SplunkError, UsageError def resolve_mode(output: str | None, table: bool) -> str: diff --git a/src/vct_splunk/utils/__init__.py b/src/vct_splunk/utils/__init__.py new file mode 100644 index 0000000..2c598ba --- /dev/null +++ b/src/vct_splunk/utils/__init__.py @@ -0,0 +1,4 @@ +"""Cross-cutting helpers: typed errors, redaction, namespacing, validation. + +Click-free. Mirrors the cribl-cli ``utils/`` package. +""" diff --git a/src/vct_splunk/core/audit.py b/src/vct_splunk/utils/audit.py similarity index 93% rename from src/vct_splunk/core/audit.py rename to src/vct_splunk/utils/audit.py index 48c2503..123b84f 100644 --- a/src/vct_splunk/core/audit.py +++ b/src/vct_splunk/utils/audit.py @@ -1,4 +1,4 @@ -"""Append-only local audit log for writes. Click-free core. +"""Append-only local audit log for writes. Click-free. Location: $VCT_SPLUNK_AUDIT, else $XDG_STATE_HOME/vct-splunk/audit.log, else ~/.local/state/vct-splunk/audit.log. diff --git a/src/vct_splunk/core/backends.py b/src/vct_splunk/utils/backends.py similarity index 97% rename from src/vct_splunk/core/backends.py rename to src/vct_splunk/utils/backends.py index 5f955ed..d896791 100644 --- a/src/vct_splunk/core/backends.py +++ b/src/vct_splunk/utils/backends.py @@ -66,7 +66,7 @@ def cloud_stack_from_url(url: str | None = None) -> str | None: """Derive the ACS stack from a cloud host: ``.splunkcloud.com`` -> ````. Returns None for a non-cloud host. ``SPLUNK_ACS_STACK`` (if set) overrides this - in :func:`vct_splunk.core.acs.client.acs_config_from_env`, not here. + in :func:`vct_splunk.api.acs.client.acs_config_from_env`, not here. """ raw = url if url is not None else os.environ.get("SPLUNK_URL") host = _host(raw) diff --git a/src/vct_splunk/core/errors.py b/src/vct_splunk/utils/errors.py similarity index 100% rename from src/vct_splunk/core/errors.py rename to src/vct_splunk/utils/errors.py diff --git a/src/vct_splunk/core/namespace.py b/src/vct_splunk/utils/namespace.py similarity index 98% rename from src/vct_splunk/core/namespace.py rename to src/vct_splunk/utils/namespace.py index 04a3425..a147d34 100644 --- a/src/vct_splunk/core/namespace.py +++ b/src/vct_splunk/utils/namespace.py @@ -1,4 +1,4 @@ -"""Splunk namespace (owner + app) helpers. Click-free core. +"""Splunk namespace (owner + app) helpers. Click-free. Most knowledge and search objects live under a namespace path:: diff --git a/src/vct_splunk/core/path.py b/src/vct_splunk/utils/path.py similarity index 100% rename from src/vct_splunk/core/path.py rename to src/vct_splunk/utils/path.py diff --git a/src/vct_splunk/core/redact.py b/src/vct_splunk/utils/redact.py similarity index 100% rename from src/vct_splunk/core/redact.py rename to src/vct_splunk/utils/redact.py diff --git a/src/vct_splunk/core/parsing.py b/src/vct_splunk/utils/validation.py similarity index 100% rename from src/vct_splunk/core/parsing.py rename to src/vct_splunk/utils/validation.py diff --git a/tests/cli_catalog.py b/tests/cli_catalog.py index 25cd26c..82fe75b 100644 --- a/tests/cli_catalog.py +++ b/tests/cli_catalog.py @@ -179,7 +179,7 @@ def _generated_cases() -> tuple[Case, ...]: name = _LIVE_MISSING_NAMES.get(spec.name, _LIVE_MISSING_NAME) live_exit_codes = (4,) live_argv = (name,) - if spec.namespaced: + if spec.scope == "namespaced": live_argv = (*live_argv, "--app", "search", "--owner", "nobody") cases.append( Case( diff --git a/tests/conftest.py b/tests/conftest.py index c6feb0b..7e2c750 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,8 @@ import pytest from click.testing import CliRunner -from vct_splunk.core.client import ClientConfig, SplunkClient +from vct_splunk.api.client import SplunkClient +from vct_splunk.config.types import SplunkConfig _TEST_URL = "https://splunk.test:8089" @@ -28,7 +29,7 @@ def cli_runner() -> CliRunner: def make_client(handler: Callable, *, dry_run: bool = False) -> SplunkClient: - cfg = ClientConfig(base_url=_TEST_URL, token="TESTTOKEN", dry_run=dry_run) + cfg = SplunkConfig(base_url=_TEST_URL, token="TESTTOKEN", dry_run=dry_run) return SplunkClient(cfg, transport=httpx.MockTransport(handler)) @@ -67,7 +68,7 @@ def patch_client(monkeypatch) -> Callable: def _patch(handler: Callable) -> None: def make(self): - cfg = ClientConfig(base_url=_TEST_URL, token="T", dry_run=self.dry_run) + cfg = SplunkConfig(base_url=_TEST_URL, token="T", dry_run=self.dry_run) return SplunkClient(cfg, transport=httpx.MockTransport(handler)) monkeypatch.setattr("vct_splunk.commands.context.Ctx.client", make) diff --git a/tests/fuzz/fuzz_redact.py b/tests/fuzz/fuzz_redact.py index a1a3d10..c5dc6be 100644 --- a/tests/fuzz/fuzz_redact.py +++ b/tests/fuzz/fuzz_redact.py @@ -31,7 +31,7 @@ import atheris with atheris.instrument_imports(): - from vct_splunk.core.redact import safe_target + from vct_splunk.utils.redact import safe_target #: Spliced in as the password of every generated target. A fixed marker is what #: makes the leak check decidable — the fuzzer shapes the URL around it, and any diff --git a/tests/integration/cloud/conftest.py b/tests/integration/cloud/conftest.py index eacc825..ee51101 100644 --- a/tests/integration/cloud/conftest.py +++ b/tests/integration/cloud/conftest.py @@ -11,7 +11,7 @@ import pytest -from vct_splunk.core.backends import deduce_backend +from vct_splunk.utils.backends import deduce_backend @pytest.fixture(scope="session", autouse=True) diff --git a/tests/integration/cloud/read/test_acs_operations.py b/tests/integration/cloud/read/test_acs_operations.py index 565ec52..6e7693a 100644 --- a/tests/integration/cloud/read/test_acs_operations.py +++ b/tests/integration/cloud/read/test_acs_operations.py @@ -10,9 +10,9 @@ import pytest -from vct_splunk.core.acs import operations -from vct_splunk.core.acs.client import AcsClient, acs_config_from_env -from vct_splunk.core.backends import cloud_stack_from_url +from vct_splunk.api.acs import operations +from vct_splunk.api.acs.client import AcsClient, acs_config_from_env +from vct_splunk.utils.backends import cloud_stack_from_url pytestmark = [ pytest.mark.integration, diff --git a/tests/integration/test_acs_public_spec.py b/tests/integration/test_acs_public_spec.py index 63d6641..5a64e88 100644 --- a/tests/integration/test_acs_public_spec.py +++ b/tests/integration/test_acs_public_spec.py @@ -7,7 +7,7 @@ import httpx import pytest -from vct_splunk.core.acs.operations import LIST_ENVELOPES +from vct_splunk.api.acs.operations import LIST_ENVELOPES pytestmark = [ pytest.mark.integration, diff --git a/tests/unit/test_acs.py b/tests/unit/test_acs.py index 8f39e1c..61a1a47 100644 --- a/tests/unit/test_acs.py +++ b/tests/unit/test_acs.py @@ -14,12 +14,13 @@ import pytest from click.testing import CliRunner +from vct_splunk.api.acs import operations +from vct_splunk.api.acs.client import AcsClient, AcsConfig, acs_config_from_env +from vct_splunk.api.client import SplunkClient from vct_splunk.cli import cli -from vct_splunk.core import backends, redact -from vct_splunk.core.acs import operations -from vct_splunk.core.acs.client import AcsClient, AcsConfig, acs_config_from_env -from vct_splunk.core.client import ClientConfig, SplunkClient -from vct_splunk.core.errors import ( +from vct_splunk.config.types import SplunkConfig +from vct_splunk.utils import backends, redact +from vct_splunk.utils.errors import ( APIError, AuthError, NotFoundError, @@ -49,7 +50,7 @@ def _patch_rest(monkeypatch, handler) -> None: monkeypatch.setattr( "vct_splunk.commands.context.Ctx.client", lambda self: SplunkClient( - ClientConfig(base_url=ENTERPRISE_URL, token="T"), transport=httpx.MockTransport(handler) + SplunkConfig(base_url=ENTERPRISE_URL, token="T"), transport=httpx.MockTransport(handler) ), ) @@ -132,7 +133,7 @@ def test_acs_404_maps_not_found(): def test_acs_5xx_maps_api_error(monkeypatch): - monkeypatch.setattr("vct_splunk.core.acs.client.time.sleep", lambda delay: None) + monkeypatch.setattr("vct_splunk.api.acs.client.time.sleep", lambda delay: None) with pytest.raises(APIError): operations.list_cloud_roles(_acs(lambda req: httpx.Response(500, json={}))) @@ -154,7 +155,7 @@ def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(status, headers={"Retry-After": "7"}, json={}) return httpx.Response(200, json={"roles": []}) - monkeypatch.setattr("vct_splunk.core.acs.client.time.sleep", sleeps.append) + monkeypatch.setattr("vct_splunk.api.acs.client.time.sleep", sleeps.append) assert operations.list_cloud_roles(_acs(handler)) == [] assert calls == 3 assert sleeps == [7.0, 7.0] diff --git a/tests/unit/test_acs_loopback.py b/tests/unit/test_acs_loopback.py index 946d6d5..38ce064 100644 --- a/tests/unit/test_acs_loopback.py +++ b/tests/unit/test_acs_loopback.py @@ -31,8 +31,8 @@ import pytest from click.testing import CliRunner +from vct_splunk.api.acs import operations from vct_splunk.cli import cli -from vct_splunk.core.acs import operations STACK = "acme" TOKEN = "acs-test-token" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index e9e9b30..5a63367 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -10,9 +10,9 @@ from click.testing import CliRunner from conftest import cli_runner +from vct_splunk.auth import session as core from vct_splunk.cli import cli -from vct_splunk.core import auth as core -from vct_splunk.core.errors import APIError, AuthError +from vct_splunk.utils.errors import APIError, AuthError def _clear_auth_env(monkeypatch): @@ -206,7 +206,7 @@ def test_auth_status_reports_username_password_without_logging_in(monkeypatch): monkeypatch.setenv("SPLUNK_USERNAME", "admin") monkeypatch.setenv("SPLUNK_PASSWORD", "secret") monkeypatch.setattr( - "vct_splunk.core.auth.login", + "vct_splunk.auth.session.login", lambda *a, **k: pytest.fail("status must not log in"), ) result = CliRunner().invoke(cli, ["auth", "status", "--output", "json"]) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index c4e8f05..e48668e 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -3,8 +3,10 @@ import httpx import pytest -from vct_splunk.core.client import ClientConfig, SplunkClient, config_from_env -from vct_splunk.core.errors import ( +from vct_splunk.api.client import SplunkClient +from vct_splunk.config.loader import load_config +from vct_splunk.config.types import SplunkConfig +from vct_splunk.utils.errors import ( APIError, AuthError, NotFoundError, @@ -83,7 +85,7 @@ def test_non_json_200_returns_raw_text(client_for): def test_retries_429_then_succeeds(client_for, monkeypatch): sleeps: list[float] = [] - monkeypatch.setattr("vct_splunk.core.client.time.sleep", sleeps.append) + monkeypatch.setattr("vct_splunk.api.client.time.sleep", sleeps.append) calls = {"n": 0} def handler(req: httpx.Request) -> httpx.Response: @@ -100,7 +102,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_retries_503_with_backoff_then_gives_up(client_for, monkeypatch): sleeps: list[float] = [] - monkeypatch.setattr("vct_splunk.core.client.time.sleep", sleeps.append) + monkeypatch.setattr("vct_splunk.api.client.time.sleep", sleeps.append) calls = {"n": 0} def handler(req: httpx.Request) -> httpx.Response: @@ -117,7 +119,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_400_is_not_retried(client_for, monkeypatch): monkeypatch.setattr( - "vct_splunk.core.client.time.sleep", + "vct_splunk.api.client.time.sleep", lambda s: pytest.fail("must not sleep on a non-retryable status"), ) calls = {"n": 0} @@ -168,7 +170,7 @@ def handler(req: httpx.Request) -> httpx.Response: seen["auth"] = req.headers.get("authorization", "") return httpx.Response(200, json={"entry": []}) - cfg = ClientConfig(base_url="https://splunk.test:8089", token="SK", auth_scheme="Splunk") + cfg = SplunkConfig(base_url="https://splunk.test:8089", session_key="SK") SplunkClient(cfg, transport=httpx.MockTransport(handler)).get("/services/server/info") assert seen["auth"] == "Splunk SK" @@ -190,23 +192,23 @@ def test_config_from_env_token_stays_bearer(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") monkeypatch.setenv("SPLUNK_TOKEN", "T") - cfg = config_from_env() - assert (cfg.auth_scheme, cfg.token) == ("Bearer", "T") + cfg = load_config() + assert (cfg.token, cfg.session_key) == ("T", None) def test_config_from_env_session_key_uses_splunk_scheme(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") monkeypatch.setenv("SPLUNK_SESSION_KEY", "SK") - cfg = config_from_env() - assert (cfg.auth_scheme, cfg.token) == ("Splunk", "SK") + cfg = load_config() + assert (cfg.token, cfg.session_key) == (None, "SK") def test_config_from_env_no_credential_raises_usage(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") with pytest.raises(UsageError): - config_from_env() + load_config() def test_config_from_env_profile_fills_url_when_env_unset(monkeypatch, tmp_path): @@ -216,7 +218,7 @@ def test_config_from_env_profile_fills_url_when_env_unset(monkeypatch, tmp_path) cfgfile.write_text("[prod]\nurl = https://from-profile:8089\n") monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) monkeypatch.setenv("SPLUNK_TOKEN", "T") - cfg = config_from_env(profile="prod") + cfg = load_config(profile="prod") assert cfg.base_url == "https://from-profile:8089" @@ -227,7 +229,7 @@ def test_config_from_env_env_url_wins_over_profile(monkeypatch, tmp_path): monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) monkeypatch.setenv("SPLUNK_URL", "https://from-env:8089") monkeypatch.setenv("SPLUNK_TOKEN", "T") - cfg = config_from_env(profile="prod") + cfg = load_config(profile="prod") assert cfg.base_url == "https://from-env:8089" @@ -237,5 +239,5 @@ def test_config_from_env_profile_only_resolves_url_and_token(monkeypatch, tmp_pa cfgfile.write_text("[prod]\nurl = https://from-profile:8089\ntoken = T%PROFILE\n") cfgfile.chmod(0o600) monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) - cfg = config_from_env(profile="prod") + cfg = load_config(profile="prod") assert (cfg.base_url, cfg.token) == ("https://from-profile:8089", "T%PROFILE") diff --git a/tests/unit/test_cloud_write_refusal.py b/tests/unit/test_cloud_write_refusal.py index 688dd5a..f5b4435 100644 --- a/tests/unit/test_cloud_write_refusal.py +++ b/tests/unit/test_cloud_write_refusal.py @@ -24,7 +24,7 @@ from cli_catalog import CATALOG, Case from vct_splunk.cli import cli -from vct_splunk.core.errors import UnsupportedBackendError +from vct_splunk.utils.errors import UnsupportedBackendError WRITE_CASES = tuple(case for case in CATALOG if case.kind == "write") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 0efb940..0658dd9 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -8,9 +8,9 @@ import pytest from click.testing import CliRunner +from vct_splunk.api.endpoints import config from vct_splunk.cli import cli -from vct_splunk.core import config -from vct_splunk.core.errors import UsageError +from vct_splunk.utils.errors import UsageError def _entry(name: str, content: object | None = None) -> dict[str, object]: diff --git a/tests/unit/test_contract.py b/tests/unit/test_contract.py index e377bd0..21b8241 100644 --- a/tests/unit/test_contract.py +++ b/tests/unit/test_contract.py @@ -16,7 +16,7 @@ from click.testing import CliRunner from vct_splunk.cli import cli -from vct_splunk.core import errors +from vct_splunk.utils import errors def test_exit_codes_match_documented_contract(): diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index c619551..0b747b9 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -54,8 +54,12 @@ def test_password_prompted_on_a_tty_when_env_unset(cli_env, patch_client, monkey monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(tmp_path / "audit.log")) # Rebind the module's `sys` (CliRunner swaps the real sys.stdin mid-invoke). tty = SimpleNamespace(isatty=lambda: True) - monkeypatch.setattr("vct_splunk.commands.factory.sys", SimpleNamespace(stdin=tty, stderr=tty)) - monkeypatch.setattr("vct_splunk.commands.factory.click.prompt", lambda *a, **k: "fromprompt") + monkeypatch.setattr( + "vct_splunk.commands.command_factory.sys", SimpleNamespace(stdin=tty, stderr=tty) + ) + monkeypatch.setattr( + "vct_splunk.commands.command_factory.click.prompt", lambda *a, **k: "fromprompt" + ) seen: dict[str, str] = {} def handler(req: httpx.Request) -> httpx.Response: diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index ce06838..da7411f 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -7,7 +7,7 @@ import httpx import pytest -from vct_splunk.core import health +from vct_splunk.api.endpoints import health def test_health_maps_findings(client_for): diff --git a/tests/unit/test_hec_knowledge_contracts.py b/tests/unit/test_hec_knowledge_contracts.py index f41329a..5305abc 100644 --- a/tests/unit/test_hec_knowledge_contracts.py +++ b/tests/unit/test_hec_knowledge_contracts.py @@ -8,9 +8,9 @@ import pytest from click.testing import CliRunner +from vct_splunk.api.endpoints.hec import rotate_token from vct_splunk.cli import cli -from vct_splunk.core.errors import APIError -from vct_splunk.core.hec import rotate_token +from vct_splunk.utils.errors import APIError def test_hec_rotate_exact_contract_and_official_response(cli_env, patch_client): diff --git a/tests/unit/test_jobs.py b/tests/unit/test_jobs.py index eb9242d..57a7ded 100644 --- a/tests/unit/test_jobs.py +++ b/tests/unit/test_jobs.py @@ -3,8 +3,8 @@ import httpx import pytest -from vct_splunk.core import jobs -from vct_splunk.core.errors import NotFoundError +from vct_splunk.api.endpoints import jobs +from vct_splunk.utils.errors import NotFoundError def test_list_jobs_normalizes(client_for): diff --git a/tests/unit/test_kvstore.py b/tests/unit/test_kvstore.py index 320e837..c90e055 100644 --- a/tests/unit/test_kvstore.py +++ b/tests/unit/test_kvstore.py @@ -11,10 +11,8 @@ import pytest from click.testing import CliRunner -from vct_splunk.cli import cli -from vct_splunk.core.client import ClientConfig, SplunkClient -from vct_splunk.core.errors import UsageError -from vct_splunk.core.kvstore import ( +from vct_splunk.api.client import SplunkClient +from vct_splunk.api.endpoints.kvstore import ( delete_all, delete_record, get_record, @@ -22,6 +20,9 @@ list_records, update_record, ) +from vct_splunk.cli import cli +from vct_splunk.config.types import SplunkConfig +from vct_splunk.utils.errors import UsageError def _env(monkeypatch): @@ -34,7 +35,7 @@ def _env(monkeypatch): def _patch_client(monkeypatch, handler): def make(self): - cfg = ClientConfig(base_url="https://splunk.test:8089", token="T", dry_run=self.dry_run) + cfg = SplunkConfig(base_url="https://splunk.test:8089", token="T", dry_run=self.dry_run) return SplunkClient(cfg, transport=httpx.MockTransport(handler)) monkeypatch.setattr("vct_splunk.commands.context.Ctx.client", make) diff --git a/tests/unit/test_namespace.py b/tests/unit/test_namespace.py index 15572c5..c4a6f51 100644 --- a/tests/unit/test_namespace.py +++ b/tests/unit/test_namespace.py @@ -2,8 +2,8 @@ import pytest -from vct_splunk.core.errors import UsageError -from vct_splunk.core.namespace import ns_path, resolve_ns +from vct_splunk.utils.errors import UsageError +from vct_splunk.utils.namespace import ns_path, resolve_ns def test_ns_path_builds_servicesns(): diff --git a/tests/unit/test_output.py b/tests/unit/test_output.py index 76514bb..5fc8dc5 100644 --- a/tests/unit/test_output.py +++ b/tests/unit/test_output.py @@ -4,8 +4,8 @@ import pytest -from vct_splunk.commands import output as out -from vct_splunk.core.errors import UsageError +from vct_splunk.output import formatter as out +from vct_splunk.utils.errors import UsageError def test_resolve_mode_explicit(): diff --git a/tests/unit/test_platform_controls.py b/tests/unit/test_platform_controls.py index 3879ab3..d056f3c 100644 --- a/tests/unit/test_platform_controls.py +++ b/tests/unit/test_platform_controls.py @@ -9,8 +9,8 @@ from click.testing import CliRunner from vct_splunk.cli import cli -from vct_splunk.core.errors import UsageError -from vct_splunk.core.parsing import parse_key_value_pairs +from vct_splunk.utils.errors import UsageError +from vct_splunk.utils.validation import parse_key_value_pairs def test_cluster_status_uses_manager_info(cli_env, patch_client): diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index a5c333e..38b9e3f 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -6,9 +6,8 @@ import pytest -from vct_splunk.core.client import config_from_env -from vct_splunk.core.errors import UsageError -from vct_splunk.core.profiles import config_path, load_profile +from vct_splunk.config.loader import config_path, load_config, load_profile +from vct_splunk.utils.errors import UsageError def test_load_profile_none_returns_empty(tmp_path, monkeypatch): @@ -76,7 +75,7 @@ def test_secret_profile_rejects_group_or_world_access(tmp_path, monkeypatch): monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") with pytest.raises(UsageError, match="mode 0600"): - config_from_env(profile="prod") + load_config(profile="prod") @pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits required") @@ -87,7 +86,7 @@ def test_insecure_profile_credential_is_ignored_when_env_token_wins(tmp_path, mo monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) monkeypatch.setenv("SPLUNK_URL", "https://env:8089") monkeypatch.setenv("SPLUNK_TOKEN", "env-token") - cfg = config_from_env(profile="prod") + cfg = load_config(profile="prod") assert (cfg.base_url, cfg.token) == ("https://env:8089", "env-token") diff --git a/tests/unit/test_resource_factory.py b/tests/unit/test_resource_factory.py index a4aa2d7..7ee31c9 100644 --- a/tests/unit/test_resource_factory.py +++ b/tests/unit/test_resource_factory.py @@ -10,11 +10,11 @@ import httpx import pytest +from vct_splunk.api.endpoint_factory import EndpointConfig, Endpoints, Field from vct_splunk.commands.registry import INDEX, REGISTRY, SAVED_SEARCH -from vct_splunk.core.errors import NotFoundError, UsageError -from vct_splunk.core.resource import CrudResource, Field, Spec +from vct_splunk.utils.errors import NotFoundError, UsageError -GLOBAL_SPEC = Spec( +GLOBAL_SPEC = EndpointConfig( name="widget", path="/services/data/widgets", help="Widgets.", @@ -27,15 +27,15 @@ # A namespaced spec sharing GLOBAL_SPEC's `color` field, so one parametrized # test can assert a property against both a global and a namespaced resource. -NS_SPEC = Spec( +NS_SPEC = EndpointConfig( name="gadget", path="configs/conf-gadgets", help="Gadgets.", - namespaced=True, + scope="namespaced", fields=(Field("color", key="color"),), ) -PATH_SPEC = Spec( +PATH_SPEC = EndpointConfig( name="monitor", path="/services/data/inputs/monitor", help="Monitor inputs.", @@ -52,7 +52,7 @@ def handler(req: httpx.Request) -> httpx.Response: seen["body"] = req.content.decode() return httpx.Response(201, json={"entry": [{"name": "w1", "content": {}}]}) - CrudResource(GLOBAL_SPEC).create( + Endpoints(GLOBAL_SPEC).create( client_for(handler), "w1", fields={"size_gb": 2, "color": "red"}, @@ -72,7 +72,7 @@ def test_out_map_renames_and_drops_unmapped(client_for): "entry": [{"name": "w1", "content": {"sizeMB": 1024, "color": "blue", "junk": "x"}}], "paging": {"total": 1}, } - rows = CrudResource(GLOBAL_SPEC).list(client_for(lambda req: httpx.Response(200, json=body))) + rows = Endpoints(GLOBAL_SPEC).list(client_for(lambda req: httpx.Response(200, json=body))) assert rows[0] == {"size_mb": 1024, "color": "blue", "name": "w1"} # 'junk' dropped @@ -90,9 +90,9 @@ def handler(req: httpx.Request) -> httpx.Response: seen["body"] = req.content.decode() return httpx.Response(200, json={"entry": [{"name": "thing", "content": {}}]}) - owner = "nobody" if spec.namespaced else None - app = "my_app" if spec.namespaced else None - CrudResource(spec).update( + owner = "nobody" if spec.scope == "namespaced" else None + app = "my_app" if spec.scope == "namespaced" else None + Endpoints(spec).update( client_for(handler), "thing", fields={"color": "blue"}, owner=owner, app=app ) @@ -112,7 +112,7 @@ def test_namespaced_list_surfaces_the_acl_block(client_for): ], "paging": {"total": 1}, } - rows = CrudResource(NS_SPEC).list( + rows = Endpoints(NS_SPEC).list( client_for(lambda req: httpx.Response(200, json=body)), owner="-", app="-" ) @@ -128,13 +128,13 @@ def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path return httpx.Response(200, json={"entry": [], "paging": {"total": 0}}) - CrudResource(NS_SPEC).list(client_for(handler), owner="nobody", app="my_app") + Endpoints(NS_SPEC).list(client_for(handler), owner="nobody", app="my_app") assert seen["path"] == "/servicesNS/nobody/my_app/configs/conf-gadgets" def test_get_missing_raises_notfound(client_for): with pytest.raises(NotFoundError): - CrudResource(GLOBAL_SPEC).get( + Endpoints(GLOBAL_SPEC).get( client_for(lambda req: httpx.Response(200, json={"entry": []})), "nope" ) @@ -148,11 +148,13 @@ def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path return httpx.Response(200, json={"entry": [], "paging": {"total": 0}}) - owner = "nobody" if spec.namespaced else None - app = "my_app" if spec.namespaced else None - assert CrudResource(spec).list(client_for(handler), owner=owner, app=app) == [] + owner = "nobody" if spec.scope == "namespaced" else None + app = "my_app" if spec.scope == "namespaced" else None + assert Endpoints(spec).list(client_for(handler), owner=owner, app=app) == [] expected = ( - f"/servicesNS/nobody/my_app/{spec.path.lstrip('/')}" if spec.namespaced else spec.path + f"/servicesNS/nobody/my_app/{spec.path.lstrip('/')}" + if spec.scope == "namespaced" + else spec.path ) assert seen == {"method": "GET", "path": expected} @@ -165,7 +167,7 @@ def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.raw_path.decode().partition("?")[0] return httpx.Response(200, json={"entry": [{"name": "east west", "content": {}}]}) - resource = CrudResource(GLOBAL_SPEC) + resource = Endpoints(GLOBAL_SPEC) client = client_for(handler) if operation == "get": resource.get(client, "east west") @@ -190,7 +192,7 @@ def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.raw_path.decode().partition("?")[0] return httpx.Response(200, json={}) - CrudResource(PATH_SPEC).delete(client_for(handler), "/var/tmp/input.log") + Endpoints(PATH_SPEC).delete(client_for(handler), "/var/tmp/input.log") assert seen["path"] == "/services/data/inputs/monitor/%2Fvar%2Ftmp%2Finput.log" @@ -205,7 +207,7 @@ def handler(req: httpx.Request) -> httpx.Response: ) def test_path_identifiers_refuse_wrong_shape_and_traversal(client_for, spec, name): requests: list[httpx.Request] = [] - resource = CrudResource(spec) + resource = Endpoints(spec) client = client_for(lambda req: requests.append(req) or httpx.Response(200, json={})) with pytest.raises(UsageError): @@ -218,7 +220,7 @@ def test_path_identifiers_refuse_wrong_shape_and_traversal(client_for, spec, nam @pytest.mark.parametrize("name", ["..", "a/b", "a\\b", "%252fetc", "a\nb"]) def test_dynamic_name_traversal_sends_no_request(client_for, operation, name): requests: list[httpx.Request] = [] - resource = CrudResource(GLOBAL_SPEC) + resource = Endpoints(GLOBAL_SPEC) client = client_for(lambda req: requests.append(req) or httpx.Response(200, json={"entry": []})) with pytest.raises(UsageError): diff --git a/tests/unit/test_saved_searches.py b/tests/unit/test_saved_searches.py index 1210b1c..2e55597 100644 --- a/tests/unit/test_saved_searches.py +++ b/tests/unit/test_saved_searches.py @@ -14,8 +14,8 @@ import pytest from click.testing import CliRunner +from vct_splunk.api.endpoints import saved_searches as ss from vct_splunk.cli import cli -from vct_splunk.core import saved_searches as ss ARGV = ["saved-search", "run", "nightly", "--app", "my_app"] diff --git a/tests/unit/test_search.py b/tests/unit/test_search.py index 16adce0..11806ee 100644 --- a/tests/unit/test_search.py +++ b/tests/unit/test_search.py @@ -3,8 +3,8 @@ import httpx from click.testing import CliRunner +from vct_splunk.api.endpoints import search from vct_splunk.cli import cli -from vct_splunk.core import search def test_normalize_spl_prefixes_bare_query(): diff --git a/tests/unit/test_secret_redaction.py b/tests/unit/test_secret_redaction.py index e96c03b..180779c 100644 --- a/tests/unit/test_secret_redaction.py +++ b/tests/unit/test_secret_redaction.py @@ -20,10 +20,11 @@ import pytest from click.testing import CliRunner +from vct_splunk.api.client import SplunkClient from vct_splunk.cli import cli from vct_splunk.commands import context -from vct_splunk.core import redact -from vct_splunk.core.client import ClientConfig, SplunkClient +from vct_splunk.config.types import SplunkConfig +from vct_splunk.utils import redact SECRET = "s3cret-token-value" URL_PASSWORD = "url-password-must-not-appear" @@ -63,7 +64,7 @@ def handler(request: httpx.Request) -> httpx.Response: context.Ctx, "client", lambda self: SplunkClient( - ClientConfig(base_url="https://sh.corp:8089", token="T"), + SplunkConfig(base_url="https://sh.corp:8089", token="T"), transport=httpx.MockTransport(handler), ), ) diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 0269f85..e5b8126 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -3,8 +3,8 @@ import httpx import pytest -from vct_splunk.core.errors import UsageError -from vct_splunk.core.server import get_server_info +from vct_splunk.api.endpoints.server import get_server_info +from vct_splunk.utils.errors import UsageError def test_get_server_info_normalizes(client_for): diff --git a/tests/unit/test_session_key_auth.py b/tests/unit/test_session_key_auth.py index b65773c..125ae74 100644 --- a/tests/unit/test_session_key_auth.py +++ b/tests/unit/test_session_key_auth.py @@ -3,8 +3,10 @@ import httpx import pytest -from vct_splunk.core.client import SplunkClient, config_from_env -from vct_splunk.core.errors import APIError, AuthError, TransportError, UsageError +from vct_splunk.api.client import SplunkClient +from vct_splunk.auth import session +from vct_splunk.config.loader import load_config +from vct_splunk.utils.errors import APIError, AuthError, TransportError, UsageError @pytest.fixture @@ -24,16 +26,22 @@ def _clean_env(monkeypatch): monkeypatch.delenv(var, raising=False) -def test_session_key_scheme_and_header(monkeypatch): - # Only SPLUNK_SESSION_KEY set: config picks the "Splunk " scheme and the - # client sends `Authorization: Splunk ` (vs the default Bearer/JWT path). - monkeypatch.delenv("SPLUNK_TOKEN", raising=False) +@pytest.fixture(autouse=True) +def _clear_session_cache(): + session.clear_session_cache() + yield + session.clear_session_cache() + + +def test_session_key_scheme_and_header(_clean_env, monkeypatch): + # Only SPLUNK_SESSION_KEY set: the auth transport sends + # `Authorization: Splunk ` (vs the default Bearer/JWT path). monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") monkeypatch.setenv("SPLUNK_SESSION_KEY", "SESSIONKEY") - cfg = config_from_env() - assert cfg.auth_scheme == "Splunk" - assert cfg.token == "SESSIONKEY" + cfg = load_config() + assert cfg.session_key == "SESSIONKEY" + assert cfg.token is None seen: dict[str, str] = {} @@ -45,77 +53,145 @@ def handler(req: httpx.Request) -> httpx.Response: assert seen["auth"] == "Splunk SESSIONKEY" -def test_username_password_login(monkeypatch): - # No token or session key: the client logs in with SPLUNK_USERNAME/SPLUNK_PASSWORD - # via /services/auth/login and uses the returned session key (the CI fallback; not - # an encouraged path, so it is intentionally undocumented). - monkeypatch.delenv("SPLUNK_TOKEN", raising=False) - monkeypatch.delenv("SPLUNK_SESSION_KEY", raising=False) +def test_username_password_login_is_lazy(_clean_env, monkeypatch): + # No token or session key: the auth transport logs in with + # SPLUNK_USERNAME/SPLUNK_PASSWORD via /services/auth/login on the *first + # request* (not at config time) and sends the returned session key. monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") monkeypatch.setenv("SPLUNK_USERNAME", "admin") monkeypatch.setenv("SPLUNK_PASSWORD", "secret") seen: dict[str, object] = {} - def fake_login(url, username, password, *, verify): + def fake_login(url, username, password, *, verify, timeout): seen.update({"url": url, "username": username, "password": password, "verify": verify}) return "LOGGEDIN" - monkeypatch.setattr("vct_splunk.core.client.auth.login", fake_login) + monkeypatch.setattr("vct_splunk.auth.session.login", fake_login) + + cfg = load_config() + assert cfg.username == "admin" + assert not seen # config loading performed no login - cfg = config_from_env() - assert cfg.auth_scheme == "Splunk" - assert cfg.token == "LOGGEDIN" + headers: dict[str, str] = {} + + def handler(req: httpx.Request) -> httpx.Response: + headers["auth"] = req.headers.get("authorization", "") + return httpx.Response(200, json={"entry": []}) + + SplunkClient(cfg, transport=httpx.MockTransport(handler)).get("/services/server/info") + assert headers["auth"] == "Splunk LOGGEDIN" assert seen["url"] == "https://splunk.test:8089" assert seen["username"] == "admin" -def _login_env(monkeypatch): +def test_login_session_is_cached_across_requests(_clean_env, monkeypatch): + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_USERNAME", "admin") + monkeypatch.setenv("SPLUNK_PASSWORD", "secret") + + calls = {"n": 0} + + def fake_login(url, username, password, *, verify, timeout): + calls["n"] += 1 + return "LOGGEDIN" + + monkeypatch.setattr("vct_splunk.auth.session.login", fake_login) + + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"entry": []}) + + client = SplunkClient(load_config(), transport=httpx.MockTransport(handler)) + client.get("/services/server/info") + client.get("/services/server/info") + assert calls["n"] == 1 # the minted session key is reused + + +def test_stale_session_key_reauths_on_401(_clean_env, monkeypatch): + # A minted session key can be invalidated server-side (e.g. a Splunk restart). + # On the next request Splunk answers 401 with the stale key; the client must + # drop the cache, log in again, and retry once so the call still succeeds -- + # this is what lets `server info` reconnect after `server restart`. monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") monkeypatch.setenv("SPLUNK_USERNAME", "admin") monkeypatch.setenv("SPLUNK_PASSWORD", "secret") + keys = iter(["STALE", "FRESH"]) + logins = {"n": 0} + + def fake_login(url, username, password, *, verify, timeout): + logins["n"] += 1 + return next(keys) + + monkeypatch.setattr("vct_splunk.auth.session.login", fake_login) + + seen: list[str] = [] + + def handler(req: httpx.Request) -> httpx.Response: + auth = req.headers.get("authorization", "") + seen.append(auth) + # The stale key is rejected once; the freshly minted one is accepted. + if auth == "Splunk STALE": + return httpx.Response(401, json={}) + return httpx.Response(200, json={"entry": [{"content": {"version": "10.4"}}]}) + + client = SplunkClient(load_config(), transport=httpx.MockTransport(handler)) + body = client.get("/services/server/info") + + assert logins["n"] == 2 # stale login, then a re-login after the 401 + assert seen == ["Splunk STALE", "Splunk FRESH"] # retried with the fresh key + assert body["entry"][0]["content"]["version"] == "10.4" + + +def test_static_token_401_is_not_retried(_clean_env, monkeypatch): + # A static token cannot be re-minted, so a 401 is a genuine failure and must + # surface immediately rather than looping on a re-auth that cannot help. + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_TOKEN", "BADJWT") + + calls = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(401, json={}) + + client = SplunkClient(load_config(), transport=httpx.MockTransport(handler)) + with pytest.raises(AuthError): + client.get("/services/server/info") + assert calls["n"] == 1 # no re-auth retry for a non-mintable credential + @pytest.mark.parametrize( - "response,expected", - [ - (httpx.Response(401, json={}), AuthError), - (httpx.Response(403, json={}), AuthError), - (httpx.Response(500, json={"messages": ["boom"]}), APIError), - (httpx.Response(200, json={}), AuthError), # 200 but no sessionKey in the body - ], + "expected", + [AuthError, APIError, TransportError], ) -def test_login_error_responses_map_typed(_clean_env, monkeypatch, response, expected): - _login_env(monkeypatch) +def test_login_errors_surface_on_first_request(_clean_env, monkeypatch, expected): + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_USERNAME", "admin") + monkeypatch.setenv("SPLUNK_PASSWORD", "secret") def fail_login(*args, **kwargs): raise expected("login failed") - monkeypatch.setattr("vct_splunk.core.client.auth.login", fail_login) - with pytest.raises(expected): - config_from_env() - - -def test_login_unreachable_raises_transport_error(_clean_env, monkeypatch): - _login_env(monkeypatch) + monkeypatch.setattr("vct_splunk.auth.session.login", fail_login) - def raise_connect(*a, **k): - raise TransportError("connection refused") + def handler(req: httpx.Request) -> httpx.Response: # pragma: no cover - never reached + return httpx.Response(200, json={"entry": []}) - monkeypatch.setattr("vct_splunk.core.client.auth.login", raise_connect) - with pytest.raises(TransportError): - config_from_env() + client = SplunkClient(load_config(), transport=httpx.MockTransport(handler)) + with pytest.raises(expected): + client.get("/services/server/info") def test_config_requires_a_url(_clean_env): with pytest.raises(UsageError, match="SPLUNK_URL"): - config_from_env() + load_config() def test_config_requires_some_auth(_clean_env, monkeypatch): monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") with pytest.raises(UsageError, match="SPLUNK_TOKEN"): - config_from_env() + load_config() def test_ca_bundle_becomes_verify_path(_clean_env, monkeypatch): @@ -123,12 +199,12 @@ def test_ca_bundle_becomes_verify_path(_clean_env, monkeypatch): monkeypatch.setenv("SPLUNK_TOKEN", "T") monkeypatch.setenv("SPLUNK_CA_BUNDLE", "/path/ca.pem") monkeypatch.setenv("SPLUNK_VERIFY", "false") # CA bundle takes precedence - assert config_from_env().verify == "/path/ca.pem" + assert load_config().verify == "/path/ca.pem" def test_verify_flag_parsing(_clean_env, monkeypatch): monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") monkeypatch.setenv("SPLUNK_TOKEN", "T") - assert config_from_env().verify is True # default: verify + assert load_config().verify is True # default: verify monkeypatch.setenv("SPLUNK_VERIFY", "false") - assert config_from_env().verify is False + assert load_config().verify is False diff --git a/tests/unit/test_write.py b/tests/unit/test_write.py index a83c189..6ead1ee 100644 --- a/tests/unit/test_write.py +++ b/tests/unit/test_write.py @@ -9,9 +9,10 @@ import httpx import pytest +from vct_splunk.api.client import SplunkClient from vct_splunk.commands.write import do_write -from vct_splunk.core.client import ClientConfig, SplunkClient -from vct_splunk.core.errors import UsageError +from vct_splunk.config.types import SplunkConfig +from vct_splunk.utils.errors import UsageError class _Ctx: @@ -23,7 +24,7 @@ def __init__(self, *, dry_run: bool = False, yes: bool = False) -> None: self.yes = yes def client(self) -> SplunkClient: - cfg = ClientConfig(base_url=self.base_url, token="T", dry_run=self.dry_run) + cfg = SplunkConfig(base_url=self.base_url, token="T", dry_run=self.dry_run) return SplunkClient( cfg, transport=httpx.MockTransport(lambda req: httpx.Response(200, json={})) ) @@ -103,7 +104,7 @@ def test_interactive_confirm_gates_the_write(monkeypatch, tmp_path): monkeypatch.setenv("SPLUNK_TOKEN", "T") monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(tmp_path / "audit.log")) tty = SimpleNamespace(isatty=lambda: True) - monkeypatch.setattr("vct_splunk.commands.output.sys", SimpleNamespace(stdin=tty, stderr=tty)) + monkeypatch.setattr("vct_splunk.output.formatter.sys", SimpleNamespace(stdin=tty, stderr=tty)) prompts: list[str] = [] @@ -111,7 +112,7 @@ def deny(message, **kwargs): prompts.append(message) raise click.Abort() - monkeypatch.setattr("vct_splunk.commands.output.click.confirm", deny) + monkeypatch.setattr("vct_splunk.output.formatter.click.confirm", deny) with pytest.raises(click.Abort): do_write( _Ctx(), @@ -121,13 +122,13 @@ def deny(message, **kwargs): ) assert "delete index 'web'" in prompts[0] - monkeypatch.setattr("vct_splunk.commands.output.click.confirm", lambda *a, **k: True) + monkeypatch.setattr("vct_splunk.output.formatter.click.confirm", lambda *a, **k: True) result = do_write(_Ctx(), action="a", audit_event={"action": "x"}, run=lambda c: {"ok": True}) assert result == {"ok": True} def test_audit_falls_back_to_xdg_state_home(monkeypatch, tmp_path): - from vct_splunk.core import audit + from vct_splunk.utils import audit monkeypatch.delenv("VCT_SPLUNK_AUDIT", raising=False) monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path))