From 354aaa8e002c0b1f462adb66a7326b7f8b59ec2e Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:15:44 -0400 Subject: [PATCH 01/46] feat: deeper health checks shipped as versioned data (#11) Adds checks beyond reachability/splunkd, each independent (one failing check cannot crash the rest): - resource usage (CPU, memory) from /services/server/status/resource-usage/hostwide - per-partition disk space from /services/server/status/partitions-space - a clean-room internal-error-rate check over _internal (original SPL, not the proprietary Monitoring Console searches) Thresholds are named constants (calibration knobs). Checks carry a version (HEALTH_CHECKS_VERSION) so they can evolve as data. Closes #11 --- CHANGELOG.md | 2 + src/vct_splunk/core/health.py | 139 +++++++++++++++++++++++++++++++++- tests/unit/test_health.py | 64 ++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5a6a8..1c01c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ This is the 0.2.0 development line (version bumped from 0.0.1). running real create -> verify -> cleanup, with integration coverage for the namespaced saved-search and factory user lifecycles (#14). - A Nix flake dev shell (`nix develop` / direnv) per the workspace convention (#15). +- Deeper health checks (resource usage, disk space, internal-error rate) shipped as + versioned check data (#11). ### Fixed diff --git a/src/vct_splunk/core/health.py b/src/vct_splunk/core/health.py index 5189ff6..b0d3c5c 100644 --- a/src/vct_splunk/core/health.py +++ b/src/vct_splunk/core/health.py @@ -14,9 +14,21 @@ from .client import SplunkClient from .errors import SplunkError +from .search import run_search _FINDING = {"green": "pass", "yellow": "warn", "red": "fail"} +# Health checks ship as versioned data so a consumer can tell which generation of +# thresholds and SPL produced a verdict. Bump when a check's meaning changes. +HEALTH_CHECKS_VERSION = "1" + +# Calibration knobs for the resource/introspection checks. They are named module +# constants (not magic numbers) so they read as the single place to retune. +_CPU_WARN_PCT = 90.0 # warn when combined system+user CPU exceeds this percentage +_MEM_WARN_PCT = 90.0 # warn when used memory exceeds this percentage of total +_DISK_WARN_FREE_PCT = 10.0 # warn when a partition's free space drops below this +_ERROR_WARN_COUNT = 100 # warn when splunkd ERROR events in the window exceed this + @dataclass class Verdict: @@ -28,7 +40,14 @@ class Verdict: def check_health(client: SplunkClient) -> list[dict[str, Any]]: - verdicts = [_reachable(client), *_splunkd(client)] + verdicts = [ + Verdict("checks_version", "applicable", "completed", "pass", HEALTH_CHECKS_VERSION), + _reachable(client), + *_splunkd(client), + *_resource_usage(client), + *_disk_space(client), + *_internal_errors(client), + ] return [asdict(v) for v in verdicts] @@ -74,3 +93,121 @@ def _splunkd(client: SplunkClient) -> list[Verdict]: ) ) return out + + +def _resource_usage(client: SplunkClient) -> list[Verdict]: + """CPU and memory verdicts from host-wide introspection. + + Reads ``/services/server/status/resource-usage/hostwide`` and emits one CPU + verdict (combined system+user) and one memory verdict (used / total). Both + warn past their calibration thresholds, else pass. Any transport/API failure + collapses into a single error verdict so a missing endpoint never crashes the + rest of the report. + """ + try: + content = ( + client.get("/services/server/status/resource-usage/hostwide").get("entry") or [{}] + )[0].get("content", {}) + except SplunkError as exc: + return [Verdict("resource_usage", "unknown", "error", "fail", exc.message)] + + cpu_pct = _to_float(content.get("cpu_system_pct")) + _to_float(content.get("cpu_user_pct")) + load = _to_float(content.get("normalized_load_avg_1min")) + cpu = Verdict( + "resource_cpu", + "applicable", + "completed", + "warn" if cpu_pct > _CPU_WARN_PCT else "pass", + f"cpu={cpu_pct:.1f}% (warn>{_CPU_WARN_PCT:g}%), load_1min={load:.2f}", + ) + + mem_total = _to_float(content.get("mem")) + mem_used = _to_float(content.get("mem_used")) + mem_pct = (mem_used / mem_total * 100.0) if mem_total > 0 else 0.0 + mem = Verdict( + "resource_memory", + "applicable", + "completed", + "warn" if mem_pct > _MEM_WARN_PCT else "pass", + f"mem={mem_pct:.1f}% used ({mem_used:.0f}/{mem_total:.0f} MB, warn>{_MEM_WARN_PCT:g}%)", + ) + return [cpu, mem] + + +def _disk_space(client: SplunkClient) -> list[Verdict]: + """One verdict per filesystem partition from ``partitions-space``. + + Each entry carries a ``mount_point`` with ``capacity`` and ``free`` (MB). A + partition warns when its free percentage drops below the threshold. A failure + to read the endpoint collapses into a single error verdict. + """ + try: + entries = client.get("/services/server/status/partitions-space").get("entry") or [] + except SplunkError as exc: + return [Verdict("disk_space", "unknown", "error", "fail", exc.message)] + + out: list[Verdict] = [] + for entry in entries: + content = (entry or {}).get("content", {}) + mount = content.get("mount_point") or (entry or {}).get("name") or "?" + capacity = _to_float(content.get("capacity")) + free = _to_float(content.get("free")) + free_pct = (free / capacity * 100.0) if capacity > 0 else 0.0 + evidence = ( + f"free={free_pct:.1f}% ({free:.0f}/{capacity:.0f} MB, warn<{_DISK_WARN_FREE_PCT:g}%)" + ) + out.append( + Verdict( + f"disk:{mount}", + "applicable", + "completed", + "warn" if free_pct < _DISK_WARN_FREE_PCT else "pass", + evidence, + ) + ) + return out + + +def _internal_errors(client: SplunkClient) -> list[Verdict]: + """Recent splunkd ERROR-rate verdict via a clean-room SPL search. + + The SPL is written here from scratch (a plain ``stats count``) and is + deliberately *not* derived from Splunk's proprietary Monitoring Console + searches. It counts splunkd ERROR events in a short trailing window; the count + warns past the threshold. ``error_count`` can arrive as a string, so it is + coerced. A search failure collapses into a single error verdict. + """ + try: + body = run_search( + client, + "index=_internal sourcetype=splunkd log_level=ERROR | stats count as error_count", + earliest="-15m", + latest="now", + max_rows=1, + ) + except SplunkError as exc: + return [Verdict("internal_errors", "unknown", "error", "fail", exc.message)] + + results = body.get("results") or [] + count = int(_to_float(results[0].get("error_count"))) if results else 0 + return [ + Verdict( + "internal_errors", + "applicable", + "completed", + "warn" if count > _ERROR_WARN_COUNT else "pass", + f"{count} splunkd ERROR events in 15m (warn>{_ERROR_WARN_COUNT})", + ) + ] + + +def _to_float(value: Any) -> float: + """Coerce a Splunk field to float, treating missing/garbage as 0.0. + + Splunk returns numeric introspection fields as JSON strings (e.g. ``"42.5"``), + so every threshold comparison routes through this instead of assuming a type. + """ + try: + return float(value) + except (TypeError, ValueError): + return 0.0 diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index bb3b578..1b64791 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -1,5 +1,8 @@ from __future__ import annotations +from collections.abc import Callable +from typing import Any + import httpx from vct_splunk.core import health @@ -33,3 +36,64 @@ def handler(req: httpx.Request) -> httpx.Response: assert verdicts["splunkd_overall"]["finding"] == "pass" assert verdicts["feature:Indexing"]["finding"] == "warn" assert verdicts["feature:Indexing"]["applicability"] == "applicable" + # Checks ship as versioned data, surfaced as its own verdict. + assert verdicts["checks_version"]["evidence"] == health.HEALTH_CHECKS_VERSION + + +def _resource_handler(content: dict[str, Any]) -> Callable[[httpx.Request], httpx.Response]: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"entry": [{"content": content}]}) + + return handler + + +def test_resource_usage_high_cpu_warns(client_for): + handler = _resource_handler( + {"cpu_system_pct": "60.0", "cpu_user_pct": "45.0", "mem": "16000", "mem_used": "4000"} + ) + verdicts = {v.check: v for v in health._resource_usage(client_for(handler))} + assert verdicts["resource_cpu"].finding == "warn" # 105% > 90% threshold + assert verdicts["resource_memory"].finding == "pass" # 25% used + + +def test_resource_usage_normal_passes(client_for): + handler = _resource_handler( + {"cpu_system_pct": "5.0", "cpu_user_pct": "10.0", "mem": "16000", "mem_used": "4000"} + ) + verdicts = {v.check: v for v in health._resource_usage(client_for(handler))} + assert verdicts["resource_cpu"].finding == "pass" + assert verdicts["resource_memory"].finding == "pass" + + +def test_disk_space_low_free_warns(client_for): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "entry": [ + {"content": {"mount_point": "/opt", "capacity": "1000", "free": "50"}}, + {"content": {"mount_point": "/var", "capacity": "1000", "free": "800"}}, + ] + }, + ) + + verdicts = {v.check: v for v in health._disk_space(client_for(handler))} + assert verdicts["disk:/opt"].finding == "warn" # 5% free < 10% threshold + assert verdicts["disk:/var"].finding == "pass" # 80% free + + +def test_internal_errors_high_count_warns(client_for): + # error_count comes back from Splunk as a string; the check must coerce it. + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{"error_count": "500"}]}) + + verdicts = {v.check: v for v in health._internal_errors(client_for(handler))} + assert verdicts["internal_errors"].finding == "warn" # 500 > 100 threshold + + +def test_internal_errors_low_count_passes(client_for): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{"error_count": "3"}]}) + + verdicts = {v.check: v for v in health._internal_errors(client_for(handler))} + assert verdicts["internal_errors"].finding == "pass" From 39d35e44879efd4d2782f26181ab4ca0df2dee80 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:41:03 -0400 Subject: [PATCH 02/46] feat: session-key auth, auth login/status, and config profiles (#13) - The client now supports `Authorization: Splunk ` alongside Bearer tokens. config_from_env: SPLUNK_TOKEN -> Bearer (unchanged); else SPLUNK_SESSION_KEY -> the Splunk scheme. - `splunk auth login` mints a session key via /services/auth/login (username + password from env or a no-echo prompt, never a flag) and prints it; `auth status` reports the active scheme and target without revealing the secret. - Config-file profiles (stdlib INI): `--profile` / SPLUNK_PROFILE selects a [section] supplying url / token / session_key / app / owner. Precedence is flag > env > profile > default, applied per value. With no config file there is no change -- fully backward compatible. Closes #13 --- .env.example | 14 +++ CHANGELOG.md | 10 +++ README.md | 4 + src/vct_splunk/cli.py | 3 +- src/vct_splunk/commands/auth.py | 96 +++++++++++++++++++++ src/vct_splunk/commands/context.py | 32 +++++-- src/vct_splunk/core/auth.py | 67 +++++++++++++++ src/vct_splunk/core/client.py | 53 +++++++++--- src/vct_splunk/core/profiles.py | 63 ++++++++++++++ tests/unit/test_auth.py | 133 +++++++++++++++++++++++++++++ tests/unit/test_client.py | 65 +++++++++++++- tests/unit/test_profiles.py | 53 ++++++++++++ 12 files changed, 572 insertions(+), 21 deletions(-) create mode 100644 src/vct_splunk/commands/auth.py create mode 100644 src/vct_splunk/core/auth.py create mode 100644 src/vct_splunk/core/profiles.py create mode 100644 tests/unit/test_auth.py create mode 100644 tests/unit/test_profiles.py diff --git a/.env.example b/.env.example index 06e09d4..6c54419 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,17 @@ SPLUNK_TOKEN= # Which backend `splunk inspect` reports as active: enterprise (default) or cloud. # SPLUNK_BACKEND=enterprise + +# --- Session login + config profiles (#13) ----------------------------------- +# Alternative to SPLUNK_TOKEN: a session key sent as `Authorization: Splunk `. +# Mint one with `splunk auth login`. SPLUNK_TOKEN (Bearer) wins if both are set. +# SPLUNK_SESSION_KEY= + +# For `splunk auth login`. The password is never a flag; it prompts if unset. +# SPLUNK_USERNAME= +# SPLUNK_PASSWORD= + +# Config-file profiles: choose an INI [section] (precedence flag > env > profile). +# SPLUNK_PROFILE= +# Override the config path (else $XDG_CONFIG_HOME/vct-splunk/config, ~/.config/vct-splunk/config). +# VCT_SPLUNK_CONFIG= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c01c61..b8463fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,16 @@ This is the 0.2.0 development line (version bumped from 0.0.1). - A Nix flake dev shell (`nix develop` / direnv) per the workspace convention (#15). - Deeper health checks (resource usage, disk space, internal-error rate) shipped as versioned check data (#11). +- Session-key auth: a credential in `SPLUNK_SESSION_KEY` is sent as + `Authorization: Splunk ` (alongside the existing `SPLUNK_TOKEN` -> + `Authorization: Bearer `), plus `auth login` (exchange a + username/password for a session key) and `auth status` (report the resolved + target and active scheme without revealing the secret) (#13). +- Config-file profiles: a `--profile` option (and `$SPLUNK_PROFILE`) selects a + named section in an INI file (`$VCT_SPLUNK_CONFIG`, else + `$XDG_CONFIG_HOME/vct-splunk/config`) supplying `url` / `token` / + `session_key` / `app` / `owner`. Precedence is flag > env > profile > default, + so a profile only fills gaps and never overrides an explicit flag or env (#13). ### Fixed diff --git a/README.md b/README.md index 2e9a087..60681b4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ export SPLUNK_VERIFY="true" # TLS verification (default true) > off entirely (e.g. a self-signed lab cert), leave `SPLUNK_CA_BUNDLE` unset and > set `SPLUNK_VERIFY=false`. +You can also authenticate with a **session key** instead of a token (`splunk auth login` +mints one; set `SPLUNK_SESSION_KEY`), and keep per-target settings in a config-file +**profile** chosen with `--profile` / `SPLUNK_PROFILE` (precedence: flag > env > profile). + Commands (singular-noun → verb): ```bash diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index 3e019dc..c131f56 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -6,6 +6,7 @@ from . import __version__ from .commands.api import api +from .commands.auth import auth from .commands.cloud import cloud from .commands.factory import build_group from .commands.health import health @@ -23,7 +24,7 @@ def cli() -> None: """Read, search, health-check, and safely administer Splunk Enterprise over its REST API.""" -for _group in (server, api, index, search, saved_search, health, cloud): +for _group in (server, api, index, search, saved_search, health, cloud, auth): cli.add_command(_group) cli.add_command(inspect) diff --git a/src/vct_splunk/commands/auth.py b/src/vct_splunk/commands/auth.py new file mode 100644 index 0000000..466b343 --- /dev/null +++ b/src/vct_splunk/commands/auth.py @@ -0,0 +1,96 @@ +"""`splunk auth` commands: session login and auth status. Shell layer (imports Click). + +``auth login`` exchanges a username/password for a Splunk session key and prints +it (a secret hint goes to stderr; nothing is written to disk). ``auth status`` +reports the resolved target and which auth scheme is active, without revealing +any secret value. +""" + +from __future__ import annotations + +import os +import sys + +import click + +from ..core import auth as core +from ..core.errors import UsageError +from ..core.profiles import load_profile +from . import output as out +from .context import command + + +def _resolve_url(base_url: str | None, profile: str | None) -> str: + """Resolve the management URL by flag > env > profile (no credential needed).""" + url = base_url or os.environ.get("SPLUNK_URL") or load_profile(profile).get("url") + if not url: + raise UsageError("No Splunk URL. Set SPLUNK_URL or pass --base-url.") + return url.rstrip("/") + + +def _verify() -> bool | str: + """TLS verification from the environment, matching the main client.""" + ca = os.environ.get("SPLUNK_CA_BUNDLE") + on = os.environ.get("SPLUNK_VERIFY", "true").strip().lower() not in {"0", "false", "no"} + return ca or on + + +def _resolve_username(username: str | None) -> str: + """Return the username from the flag, ``$SPLUNK_USERNAME``, or a TTY prompt.""" + username = username or os.environ.get("SPLUNK_USERNAME") + if username: + return username + if not sys.stdin.isatty(): + raise UsageError("No username. Set SPLUNK_USERNAME or pass --username.") + return click.prompt("Username", err=True) + + +def _resolve_password() -> str: + """Return the password from ``$SPLUNK_PASSWORD`` or a no-echo TTY prompt. + + Never a flag — a secret on the command line would leak into shell history and + process listings. + """ + password = os.environ.get("SPLUNK_PASSWORD") + if password: + return password + if not sys.stdin.isatty(): + raise UsageError("No password. Set SPLUNK_PASSWORD (run interactively to be prompted).") + return click.prompt("Password", hide_input=True, err=True) + + +@click.group() +def auth() -> None: + """Authenticate to Splunk and inspect the active auth scheme.""" + + +@auth.command("login") +@click.option("--username", default=None, help="Splunk username (or $SPLUNK_USERNAME; prompts).") +@command +def login(ctx, username: str | None) -> None: + """Exchange a username/password for a session key (printed to stdout). + + The password is read from ``$SPLUNK_PASSWORD`` or a no-echo prompt — never a + flag. The session key is printed as data; export it as ``SPLUNK_SESSION_KEY`` + to use it (this command does not persist it). + """ + url = _resolve_url(ctx.base_url, ctx.profile) + key = core.login(url, _resolve_username(username), _resolve_password(), verify=_verify()) + # The key itself is data on stdout; the usage hint is a diagnostic on stderr. + click.echo(f"export SPLUNK_SESSION_KEY={key}", err=True) + out.emit({"session_key": key}, ctx.output_mode, ctx.meta()) + + +@auth.command("status") +@command +def status(ctx) -> None: + """Report the resolved target URL and active auth scheme (no secret shown).""" + prof = load_profile(ctx.profile) + url = ctx.base_url or os.environ.get("SPLUNK_URL") or prof.get("url") + if os.environ.get("SPLUNK_TOKEN") or prof.get("token"): + scheme = "Bearer" + elif os.environ.get("SPLUNK_SESSION_KEY") or prof.get("session_key"): + scheme = "Splunk" + else: + scheme = "none" + out.emit({"target": url, "auth_scheme": scheme}, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/context.py b/src/vct_splunk/commands/context.py index aa2c0b9..6ee0f41 100644 --- a/src/vct_splunk/commands/context.py +++ b/src/vct_splunk/commands/context.py @@ -28,6 +28,7 @@ from ..core.client import SplunkClient, config_from_env from ..core.errors import SplunkError +from ..core.profiles import load_profile from . import output as out @@ -65,6 +66,9 @@ class Ctx: Used by namespaced resources (e.g. saved searches); ignored by system-level ones (e.g. indexes). owner: The owner namespace from ``--owner`` / ``$SPLUNK_OWNER``, or None. + profile: The active config-file profile name from ``--profile`` / + ``$SPLUNK_PROFILE``, or None. Supplies connection settings only where + a flag and env var leave them unset (flag > env > profile > default). """ output_mode: str @@ -73,13 +77,15 @@ class Ctx: base_url: str | None app: str | None = None owner: str | None = None + profile: str | None = None def client(self) -> SplunkClient: """Build a :class:`SplunkClient` from the environment plus this context. - Credentials and TLS settings are read from the environment (see - :func:`vct_splunk.core.client.config_from_env`); the ``dry_run`` flag is - carried over from the command line so that writes can be previewed. + Credentials and TLS settings are read from flags, the environment, and + the active profile (see :func:`vct_splunk.core.client.config_from_env`); + the ``dry_run`` flag is carried over from the command line so that writes + can be previewed. Returns: A ready-to-use client. Use it as a context manager so its underlying @@ -88,7 +94,7 @@ def client(self) -> SplunkClient: with ctx.client() as c: ... """ - cfg = config_from_env(self.base_url) + cfg = config_from_env(self.base_url, profile=self.profile) cfg.dry_run = self.dry_run return SplunkClient(cfg) @@ -119,19 +125,22 @@ def command(fn: Callable) -> Callable: """ @functools.wraps(fn) - def wrapper(output, table, dry_run, yes, base_url, app, owner, **kwargs: Any) -> Any: + def wrapper(output, table, dry_run, yes, base_url, app, owner, profile, **kwargs: Any) -> Any: # Click passes every option to the callback by name. The shared options # are named explicitly here; the command's own arguments arrive untouched # in **kwargs and are forwarded straight through to fn. + profile = profile or os.environ.get("SPLUNK_PROFILE") + prof = load_profile(profile) ctx = Ctx( out.resolve_mode(output, table), dry_run, yes, base_url, - # Flag wins over env; either may stay None, in which case the - # namespace policy (core.namespace.resolve_ns) supplies a safe default. - app=app or os.environ.get("SPLUNK_APP"), - owner=owner or os.environ.get("SPLUNK_OWNER"), + # Flag > env > profile; any may stay None, in which case the namespace + # policy (core.namespace.resolve_ns) supplies a safe default. + app=app or os.environ.get("SPLUNK_APP") or prof.get("app"), + owner=owner or os.environ.get("SPLUNK_OWNER") or prof.get("owner"), + profile=profile, ) try: return fn(ctx, **kwargs) @@ -143,6 +152,11 @@ def wrapper(output, table, dry_run, yes, base_url, app, owner, **kwargs: Any) -> # Click applies decorators bottom-up, so this list ends up reading in reverse # order in --help. The order is purely cosmetic. options = [ + click.option( + "--profile", + default=None, + help="Config-file profile name (overridden by flags/env; or $SPLUNK_PROFILE).", + ), click.option( "--owner", default=None, diff --git a/src/vct_splunk/core/auth.py b/src/vct_splunk/core/auth.py new file mode 100644 index 0000000..119d0af --- /dev/null +++ b/src/vct_splunk/core/auth.py @@ -0,0 +1,67 @@ +"""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 + + +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 (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 {url}: {exc}") from exc + if resp.status_code == 401: + raise AuthError("Login failed (401). 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 index 3eecd28..e544578 100644 --- a/src/vct_splunk/core/client.py +++ b/src/vct_splunk/core/client.py @@ -15,6 +15,7 @@ import httpx from .errors import APIError, AuthError, NotFoundError, TransportError, UsageError +from .profiles import load_profile _RETRY_STATUS = {429, 503} _MAX_RETRIES = 3 @@ -27,18 +28,50 @@ class ClientConfig: verify: bool | str = True # True/False, or a path to a CA bundle timeout: float = 30.0 dry_run: bool = False - - -def config_from_env(base_url: str | None = None) -> ClientConfig: - url = base_url or os.environ.get("SPLUNK_URL") + # 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" + + +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. + """ + 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.") - token = os.environ.get("SPLUNK_TOKEN") - if not token: - raise UsageError("No auth token. Set SPLUNK_TOKEN.") + token = os.environ.get("SPLUNK_TOKEN") or prof.get("token") + session_key = os.environ.get("SPLUNK_SESSION_KEY") or prof.get("session_key") + if token: + scheme, credential = "Bearer", token + elif session_key: + scheme, credential = "Splunk", session_key + else: + raise UsageError("No auth credential. Set SPLUNK_TOKEN or SPLUNK_SESSION_KEY.") ca = os.environ.get("SPLUNK_CA_BUNDLE") verify_env = os.environ.get("SPLUNK_VERIFY", "true").strip().lower() not in {"0", "false", "no"} - return ClientConfig(base_url=url.rstrip("/"), token=token, verify=ca or verify_env) + return ClientConfig( + base_url=url.rstrip("/"), token=credential, verify=ca or verify_env, auth_scheme=scheme + ) class SplunkClient: @@ -48,7 +81,7 @@ def __init__( self.config = config self._http = httpx.Client( base_url=config.base_url, - headers={"Authorization": f"Bearer {config.token}"}, + headers={"Authorization": f"{config.auth_scheme} {config.token}"}, verify=config.verify, timeout=config.timeout, transport=transport, @@ -128,7 +161,7 @@ def _retry_after(resp: httpx.Response, attempt: int) -> float: def _handle(resp: httpx.Response, method: str, url: str) -> dict[str, Any]: if resp.status_code == 401: - raise AuthError("Authentication failed (401). Check SPLUNK_TOKEN.") + 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: diff --git a/src/vct_splunk/core/profiles.py b/src/vct_splunk/core/profiles.py new file mode 100644 index 0000000..4e18ac9 --- /dev/null +++ b/src/vct_splunk/core/profiles.py @@ -0,0 +1,63 @@ +"""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 + +#: 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) -> 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() + try: + parser.read(path) + except (OSError, configparser.Error): + return {} + if not parser.has_section(name): + return {} + section = parser[name] + return {key: section[key] for key in PROFILE_KEYS if key in section} diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..a7a0f73 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,133 @@ +"""Unit tests for session login + the `splunk auth` commands (#13).""" + +from __future__ import annotations + +import httpx +import pytest +from click.testing import CliRunner + +from vct_splunk.cli import cli +from vct_splunk.core import auth as core +from vct_splunk.core.errors import APIError, AuthError + + +def _clear_auth_env(monkeypatch): + for var in ( + "SPLUNK_URL", + "SPLUNK_TOKEN", + "SPLUNK_SESSION_KEY", + "SPLUNK_USERNAME", + "SPLUNK_PASSWORD", + "SPLUNK_PROFILE", + "VCT_SPLUNK_CONFIG", + ): + monkeypatch.delenv(var, raising=False) + + +# --- core.auth.login (mocked transport, no network) ------------------------- + + +def test_login_returns_session_key(): + seen_path = "" + seen_auth: str | None = "sentinel" + seen_body = "" + + def handler(req: httpx.Request) -> httpx.Response: + nonlocal seen_path, seen_auth, seen_body + seen_path = req.url.path + seen_auth = req.headers.get("authorization") + seen_body = req.content.decode() + return httpx.Response(200, json={"sessionKey": "SK123"}) + + key = core.login( + "https://splunk.test:8089/", + "admin", + "secret", + transport=httpx.MockTransport(handler), + ) + assert key == "SK123" + assert seen_path == "/services/auth/login" + # This call authenticates via the body, so it must carry no bearer header. + assert seen_auth is None + assert "username=admin" in seen_body + + +def test_login_401_raises_auth(): + with pytest.raises(AuthError): + core.login( + "https://splunk.test:8089", + "admin", + "bad", + transport=httpx.MockTransport(lambda req: httpx.Response(401, json={})), + ) + + +def test_login_missing_session_key_raises_auth(): + with pytest.raises(AuthError): + core.login( + "https://splunk.test:8089", + "admin", + "secret", + transport=httpx.MockTransport(lambda req: httpx.Response(200, json={})), + ) + + +def test_login_500_raises_api(): + with pytest.raises(APIError): + core.login( + "https://splunk.test:8089", + "admin", + "secret", + transport=httpx.MockTransport(lambda req: httpx.Response(500, text="boom")), + ) + + +# --- splunk auth login / status (CliRunner) --------------------------------- + + +def test_auth_login_echoes_session_key(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_USERNAME", "admin") + monkeypatch.setenv("SPLUNK_PASSWORD", "secret") + monkeypatch.setattr("vct_splunk.commands.auth.core.login", lambda *a, **k: "SK-FROM-LOGIN") + result = CliRunner().invoke(cli, ["auth", "login", "--output", "json"]) + assert result.exit_code == 0 + assert '"session_key": "SK-FROM-LOGIN"' in result.output + + +def test_auth_login_refuses_without_password_noninteractive(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_USERNAME", "admin") + # No SPLUNK_PASSWORD and CliRunner stdin is not a TTY -> clean refusal. + result = CliRunner().invoke(cli, ["auth", "login", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_auth_status_reports_bearer(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_TOKEN", "T") + result = CliRunner().invoke(cli, ["auth", "status", "--output", "json"]) + assert result.exit_code == 0 + assert '"auth_scheme": "Bearer"' in result.output + assert '"target": "https://splunk.test:8089"' in result.output + + +def test_auth_status_reports_session_key_scheme(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_SESSION_KEY", "SK") + result = CliRunner().invoke(cli, ["auth", "status", "--output", "json"]) + assert result.exit_code == 0 + assert '"auth_scheme": "Splunk"' in result.output + + +def test_auth_status_reports_none_when_unset(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + result = CliRunner().invoke(cli, ["auth", "status", "--output", "json"]) + assert result.exit_code == 0 + assert '"auth_scheme": "none"' in result.output diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 91e7dde..054e3ce 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -3,7 +3,8 @@ import httpx import pytest -from vct_splunk.core.errors import AuthError, NotFoundError +from vct_splunk.core.client import ClientConfig, SplunkClient, config_from_env +from vct_splunk.core.errors import AuthError, NotFoundError, UsageError def test_auth_header_and_json_mode(client_for): @@ -71,3 +72,65 @@ def spy(method, url, **kwargs): client.post("/services/search/jobs", {"q": "1"}, timeout=0) client.get("/services/server/info") assert seen == [0, 30.0] # explicit 0 honored; None -> ClientConfig default + + +def test_session_key_scheme_sets_splunk_auth_header(): + seen: dict[str, str] = {} + + 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") + SplunkClient(cfg, transport=httpx.MockTransport(handler)).get("/services/server/info") + assert seen["auth"] == "Splunk SK" + + +def _clear_auth_env(monkeypatch): + for var in ("SPLUNK_URL", "SPLUNK_TOKEN", "SPLUNK_SESSION_KEY", "VCT_SPLUNK_CONFIG"): + monkeypatch.delenv(var, raising=False) + + +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") + + +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") + + +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() + + +def test_config_from_env_profile_fills_url_when_env_unset(monkeypatch, tmp_path): + # With SPLUNK_URL unset, the profile's url is used; env still supplies the token. + _clear_auth_env(monkeypatch) + cfgfile = tmp_path / "config" + 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") + assert cfg.base_url == "https://from-profile:8089" + + +def test_config_from_env_env_url_wins_over_profile(monkeypatch, tmp_path): + _clear_auth_env(monkeypatch) + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod]\nurl = https://from-profile:8089\n") + 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") + assert cfg.base_url == "https://from-env:8089" diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py new file mode 100644 index 0000000..dbfb301 --- /dev/null +++ b/tests/unit/test_profiles.py @@ -0,0 +1,53 @@ +"""Unit tests for config-file profiles (#13).""" + +from __future__ import annotations + +from vct_splunk.core.profiles import config_path, load_profile + + +def test_load_profile_none_returns_empty(tmp_path, monkeypatch): + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(tmp_path / "config")) + assert load_profile(None) == {} + + +def test_load_profile_missing_file_is_not_an_error(tmp_path, monkeypatch): + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(tmp_path / "absent")) + assert load_profile("prod") == {} + + +def test_load_profile_reads_recognized_keys(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_text( + "[prod]\n" + "url = https://sh:8089\n" + "session_key = SK\n" + "app = my_app\n" + "owner = nobody\n" + "ignored = nope\n" + ) + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + got = load_profile("prod") + assert got == { + "url": "https://sh:8089", + "session_key": "SK", + "app": "my_app", + "owner": "nobody", + } + + +def test_load_profile_unknown_section_returns_empty(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod]\nurl = https://sh:8089\n") + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + assert load_profile("staging") == {} + + +def test_config_path_prefers_override(tmp_path, monkeypatch): + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(tmp_path / "explicit")) + assert config_path() == tmp_path / "explicit" + + +def test_config_path_falls_back_to_xdg(tmp_path, monkeypatch): + monkeypatch.delenv("VCT_SPLUNK_CONFIG", raising=False) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert config_path() == tmp_path / "vct-splunk" / "config" From e30c94418caf4520416954a8ab8d7d6787b9e2f9 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:08:57 -0400 Subject: [PATCH 03/46] feat: KV Store data records as a namespaced JSON document store (#9) Add a 'kvstore' command group over a collection's data records -- records/get/insert/update/delete/purge -- treating KV Store data as a plain JSON document store under /servicesNS///storage/ collections/data/. This is distinct from the collection *schema*, which stays in the 'kvstore-collection' factory group. Reads default to the '-' namespace wildcard; writes require an explicit app (never the default 'search') and route through do_write for the dry-run/confirm/audit gate. Reuses the client's get_json/write_json so JSON bodies and array responses bypass the Splunk entry[].content envelope. Closes #9 --- CHANGELOG.md | 2 + src/vct_splunk/cli.py | 3 +- src/vct_splunk/commands/kvstore.py | 141 +++++++++++++++++++++++++++++ src/vct_splunk/core/client.py | 39 +++++++- src/vct_splunk/core/kvstore.py | 93 +++++++++++++++++++ tests/unit/test_kvstore.py | 106 ++++++++++++++++++++++ 6 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 src/vct_splunk/commands/kvstore.py create mode 100644 src/vct_splunk/core/kvstore.py create mode 100644 tests/unit/test_kvstore.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8463fa..4d7c9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ This is the 0.2.0 development line (version bumped from 0.0.1). - Many admin resources as generated CRUD groups: data inputs and outputs, search macros, event types, field extractions, lookup definitions, KV Store collection schemas, system messages, and app lifecycle (#5, #6, #8, #9, #10). +- `kvstore records` / `get` / `insert` / `update` / `delete` / `purge` manage KV + Store data records as a namespaced JSON document store; writes require an app (#9). - An explicit additive-only output-contract statement plus a contract test pinning the JSON envelopes, the documented exit codes, and prompt-injection safety (#16). - A minimal, read-only Splunk Cloud (ACS) slice: `cloud indexes` / `hec-tokens` / diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index c131f56..e580e6c 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -12,6 +12,7 @@ from .commands.health import health from .commands.index import index from .commands.inspect import inspect +from .commands.kvstore import kvstore from .commands.registry import REGISTRY from .commands.saved_search import saved_search from .commands.search import search @@ -24,7 +25,7 @@ def cli() -> None: """Read, search, health-check, and safely administer Splunk Enterprise over its REST API.""" -for _group in (server, api, index, search, saved_search, health, cloud, auth): +for _group in (server, api, index, search, saved_search, health, cloud, auth, kvstore): cli.add_command(_group) cli.add_command(inspect) diff --git a/src/vct_splunk/commands/kvstore.py b/src/vct_splunk/commands/kvstore.py new file mode 100644 index 0000000..51e78a5 --- /dev/null +++ b/src/vct_splunk/commands/kvstore.py @@ -0,0 +1,141 @@ +"""`splunk kvstore` commands for KV Store *data records*. Shell layer (imports Click). + +This group operates on the records *inside* a KV Store collection -- a JSON +document store. The collection *schema* (creating the collection and its fields) +is a separate group, ``kvstore-collection``. + +Records are namespaced: reads default to the ``-`` wildcard (all owners/apps); +writes require an explicit ``--app`` (or ``$SPLUNK_APP``) so a record is never +written into the default ``search`` app by accident. +""" + +from __future__ import annotations + +import json +from typing import Any + +import click + +from ..core import kvstore as core +from ..core.errors import UsageError +from ..core.namespace import resolve_ns +from . import output as out +from .context import command +from .write import do_write + + +@click.group(name="kvstore") +def kvstore() -> None: + """KV Store data records, a JSON document store (schema lives in 'kvstore-collection').""" + + +def _parse_doc(data: str) -> dict[str, Any]: + """Parse a ``--data`` JSON object, raising a clean UsageError on bad input.""" + try: + doc = json.loads(data) + except json.JSONDecodeError as exc: + raise UsageError(f"--data is not valid JSON: {exc}") from exc + if not isinstance(doc, dict): + raise UsageError("--data must be a JSON object (a single record).") + return doc + + +@kvstore.command("records") +@click.argument("collection") +@click.option("--query", default=None, help="JSON filter (MongoDB-style) to match records.") +@click.option("--limit", type=int, default=None, help="Maximum number of records to return.") +@command +def records(ctx, collection, query, limit) -> None: + """List records in a collection (use --query/--limit to narrow).""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=False) + with ctx.client() as c: + data = core.list_records(c, collection, owner=owner, app=app, query=query, limit=limit) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@kvstore.command("get") +@click.argument("collection") +@click.argument("key") +@command +def get(ctx, collection, key) -> None: + """Show one record by its _key.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=False) + with ctx.client() as c: + data = core.get_record(c, collection, key, owner=owner, app=app) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@kvstore.command("insert") +@click.argument("collection") +@click.option("--data", "data", required=True, help="The record as a JSON object.") +@command +def insert(ctx, collection, data) -> None: + """Insert a record (JSON document). Gated write; requires an app.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) + document = _parse_doc(data) + result = do_write( + ctx, + action=f"insert a record into '{collection}' in app '{app}'", + audit_event={"action": "kvstore.insert", "collection": collection, "app": app}, + run=lambda c: core.insert_record(c, collection, document, owner=owner, app=app), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@kvstore.command("update") +@click.argument("collection") +@click.argument("key") +@click.option("--data", "data", required=True, help="The replacement record as a JSON object.") +@command +def update(ctx, collection, key, data) -> None: + """Replace a record by its _key (JSON document). Gated write; requires an app.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) + document = _parse_doc(data) + result = do_write( + ctx, + action=f"update record '{key}' in '{collection}' in app '{app}'", + audit_event={ + "action": "kvstore.update", + "collection": collection, + "key": key, + "app": app, + }, + run=lambda c: core.update_record(c, collection, key, document, owner=owner, app=app), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@kvstore.command("delete") +@click.argument("collection") +@click.argument("key") +@command +def delete(ctx, collection, key) -> None: + """Delete one record by its _key. Gated write; requires an app.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) + result = do_write( + ctx, + action=f"delete record '{key}' from '{collection}' in app '{app}'", + audit_event={ + "action": "kvstore.delete", + "collection": collection, + "key": key, + "app": app, + }, + run=lambda c: core.delete_record(c, collection, key, owner=owner, app=app), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@kvstore.command("purge") +@click.argument("collection") +@command +def purge(ctx, collection) -> None: + """Delete ALL records in a collection (the schema is kept). Gated write; requires an app.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) + result = do_write( + ctx, + action=f"delete ALL records in '{collection}' in app '{app}'", + audit_event={"action": "kvstore.purge", "collection": collection, "app": app}, + run=lambda c: core.delete_all(c, collection, owner=owner, app=app), + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/core/client.py b/src/vct_splunk/core/client.py index e544578..450684e 100644 --- a/src/vct_splunk/core/client.py +++ b/src/vct_splunk/core/client.py @@ -96,6 +96,15 @@ def __exit__(self, exc_type: object, exc: object, tb: object) -> None: def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: return self._request("GET", path, params=params) + def get_json(self, path: str, params: dict[str, Any] | None = None) -> Any: + """GET a JSON document endpoint (e.g. KV Store data) and return it as-is. + + Unlike :meth:`get`, the response is plain JSON (an array for a collection, + an object for one record) rather than the ``entry[].content`` envelope, so + the return type is the raw parsed value. + """ + return self._request("GET", path, params=params) + def post( self, path: str, data: dict[str, Any], *, timeout: float | None = None ) -> dict[str, Any]: @@ -112,6 +121,22 @@ def write(self, method: str, path: str, data: dict[str, Any]) -> dict[str, Any]: } 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": 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]]: @@ -128,8 +153,15 @@ def get_collection( 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, timeout=None) -> dict[str, Any]: - params = {**(params or {}), "output_mode": "json"} + 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: @@ -138,6 +170,7 @@ def _request(self, method, path, *, params=None, data=None, timeout=None) -> dic 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, ) @@ -159,7 +192,7 @@ def _retry_after(resp: httpx.Response, attempt: int) -> float: return min(2.0**attempt, 8.0) -def _handle(resp: httpx.Response, method: str, url: str) -> dict[str, Any]: +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: diff --git a/src/vct_splunk/core/kvstore.py b/src/vct_splunk/core/kvstore.py new file mode 100644 index 0000000..5aa7d3a --- /dev/null +++ b/src/vct_splunk/core/kvstore.py @@ -0,0 +1,93 @@ +"""KV Store data records: a namespaced JSON document store. Click-free core. + +KV Store *data* is unlike the rest of the REST API. Records live under +``/servicesNS///storage/collections/data/`` and are a +plain JSON document store: a write sends a JSON body (``Content-Type: +application/json``) and a read returns a JSON array (a collection) or object +(one record) -- never the Splunk ``entry[].content`` envelope. The collection +*schema* is a separate, CRUD-shaped resource (the ``kvstore-collection`` factory +group); this module only touches the records inside a collection. + +Every call is namespaced; the command layer resolves ``owner``/``app`` via +:func:`vct_splunk.core.namespace.resolve_ns` (reads default to the ``-`` +wildcard, writes require an explicit app). +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient +from .errors import NotFoundError +from .namespace import ns_path + +_DATA = "storage/collections/data" + + +def list_records( + client: SplunkClient, + collection: str, + *, + owner: str, + app: str, + query: str | None = None, + limit: int | None = None, +) -> Any: + """List records in a collection, returning the JSON array as-is. + + ``query`` is a MongoDB-style JSON filter (passed through verbatim) and + ``limit`` caps the number of records; both are sent only when given. + """ + params: dict[str, Any] = {} + if query is not None: + params["query"] = query + if limit is not None: + params["limit"] = limit + return client.get_json(ns_path(f"{_DATA}/{collection}", owner=owner, app=app), params or None) + + +def get_record(client: SplunkClient, collection: str, key: str, *, owner: str, app: str) -> Any: + """Return one record by its ``_key``. + + Raises: + NotFoundError: If the record does not exist (a 404 already maps to + NotFoundError in the client; an empty body is treated the same). + """ + record = client.get_json(ns_path(f"{_DATA}/{collection}/{key}", owner=owner, app=app)) + if not record: + raise NotFoundError(f"Record {key!r} not found in collection {collection!r}.") + return record + + +def insert_record( + client: SplunkClient, collection: str, document: dict[str, Any], *, owner: str, app: str +) -> Any: + """Insert a record (JSON body). Splunk returns ``{"_key": "..."}``.""" + return client.write_json( + "POST", ns_path(f"{_DATA}/{collection}", owner=owner, app=app), document + ) + + +def update_record( + client: SplunkClient, + collection: str, + key: str, + document: dict[str, Any], + *, + owner: str, + app: str, +) -> Any: + """Replace the record at ``key`` with ``document`` (JSON body).""" + return client.write_json( + "POST", ns_path(f"{_DATA}/{collection}/{key}", owner=owner, app=app), document + ) + + +def delete_record(client: SplunkClient, collection: str, key: str, *, owner: str, app: str) -> Any: + """Delete one record by its ``_key``.""" + return client.write("DELETE", ns_path(f"{_DATA}/{collection}/{key}", owner=owner, app=app), {}) + + +def delete_all(client: SplunkClient, collection: str, *, owner: str, app: str) -> Any: + """Delete *every* record in the collection (the schema is left intact).""" + return client.write("DELETE", ns_path(f"{_DATA}/{collection}", owner=owner, app=app), {}) diff --git a/tests/unit/test_kvstore.py b/tests/unit/test_kvstore.py new file mode 100644 index 0000000..0cebd1f --- /dev/null +++ b/tests/unit/test_kvstore.py @@ -0,0 +1,106 @@ +"""Isolated unit tests for the `kvstore` command adapters (#9). + +These drive the real command + core + client stack through ``CliRunner`` while +mocking only the HTTP transport, mirroring ``test_commands.py``. ``Ctx.client`` +is patched to return a real ``SplunkClient`` backed by ``httpx.MockTransport``. +""" + +from __future__ import annotations + +import httpx +from click.testing import CliRunner + +from vct_splunk.cli import cli +from vct_splunk.core.client import ClientConfig, SplunkClient + + +def _env(monkeypatch): + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_TOKEN", "T") + # Keep namespace resolution deterministic regardless of the host environment. + monkeypatch.delenv("SPLUNK_APP", raising=False) + monkeypatch.delenv("SPLUNK_OWNER", raising=False) + + +def _patch_client(monkeypatch, handler): + def make(self): + cfg = ClientConfig(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) + + +def test_records_lists_with_read_wildcard_namespace(monkeypatch): + _env(monkeypatch) + seen: dict[str, str] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["path"] = req.url.path + return httpx.Response(200, json=[{"_key": "a", "x": 1}]) + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke(cli, ["kvstore", "records", "things", "--output", "json"]) + assert result.exit_code == 0 + assert '"_key": "a"' in result.output + assert seen["path"] == "/servicesNS/-/-/storage/collections/data/things" + + +def test_get_returns_one_record(monkeypatch): + _env(monkeypatch) + _patch_client(monkeypatch, lambda req: httpx.Response(200, json={"_key": "a", "x": 1})) + result = CliRunner().invoke(cli, ["kvstore", "get", "things", "a", "--output", "json"]) + assert result.exit_code == 0 + assert '"_key": "a"' in result.output + + +def test_insert_requires_app(monkeypatch): + _env(monkeypatch) + # No --app and no SPLUNK_APP -> the write must refuse (exit 2) before any + # network call, so no client patch is needed; it must never target 'search'. + result = CliRunner().invoke( + cli, ["kvstore", "insert", "things", "--data", '{"x":1}', "--output", "json"] + ) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_insert_dry_run_previews_app_namespace(monkeypatch): + _env(monkeypatch) + result = CliRunner().invoke( + cli, + [ + "kvstore", + "insert", + "things", + "--data", + '{"x":1}', + "--app", + "my_app", + "--dry-run", + "--output", + "json", + ], + ) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + assert "/servicesNS/nobody/my_app/storage/collections/data/things" in result.output + + +def test_insert_rejects_bad_json(monkeypatch): + _env(monkeypatch) + result = CliRunner().invoke( + cli, + [ + "kvstore", + "insert", + "things", + "--data", + "not json", + "--app", + "my_app", + "--output", + "json", + ], + ) + assert result.exit_code == 2 + assert "usage_error" in result.output From cf35d7c41c01e3eb05be1d21d5015fd9768bdd46 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:22:03 -0400 Subject: [PATCH 04/46] =?UTF-8?q?feat:=20platform=20control=20=E2=80=94=20?= =?UTF-8?q?cluster/shcluster=20status,=20licensing,=20server=20restart=20a?= =?UTF-8?q?nd=20settings=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add platform-level inspect and control: - 'cluster status' / 'shcluster status' summarize indexer- and search-head-cluster health (reads). - 'license list|get|usage' report licenses and pool usage (reads). - 'server restart' triggers a gated restart via /server/control/restart. - 'server settings get|set' read and (gated) change system settings; 'set' takes repeatable --set KEY=VALUE and sends only changed keys. These are system-level resources (not namespaced). Every mutation routes through do_write for the dry-run/confirm/--yes/audit gate; restart and settings-set never fire without explicit confirmation. 'message' already ships via the registry/factory and is left unchanged. Closes #10 --- CHANGELOG.md | 5 ++ src/vct_splunk/cli.py | 18 +++++- src/vct_splunk/commands/cluster.py | 23 +++++++ src/vct_splunk/commands/license.py | 42 ++++++++++++ src/vct_splunk/commands/server.py | 51 +++++++++++++++ src/vct_splunk/commands/shcluster.py | 23 +++++++ src/vct_splunk/core/cluster.py | 58 +++++++++++++++++ src/vct_splunk/core/license.py | 55 ++++++++++++++++ src/vct_splunk/core/server.py | 21 ++++++ tests/unit/test_commands.py | 96 ++++++++++++++++++++++++++++ 10 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 src/vct_splunk/commands/cluster.py create mode 100644 src/vct_splunk/commands/license.py create mode 100644 src/vct_splunk/commands/shcluster.py create mode 100644 src/vct_splunk/core/cluster.py create mode 100644 src/vct_splunk/core/license.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7c9b0..e3de76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ This is the 0.2.0 development line (version bumped from 0.0.1). schemas, system messages, and app lifecycle (#5, #6, #8, #9, #10). - `kvstore records` / `get` / `insert` / `update` / `delete` / `purge` manage KV Store data records as a namespaced JSON document store; writes require an app (#9). +- `cluster status` and `shcluster status` read indexer-cluster and search-head + cluster health, and `license list` / `get` / `usage` report licensing (#10). +- `server restart` and `server settings get` / `set` manage the instance. + `restart` and `settings set` are gated writes, so they preview with `--dry-run` + and require `--yes` when run non-interactively (#10). - An explicit additive-only output-contract statement plus a contract test pinning the JSON envelopes, the documented exit codes, and prompt-injection safety (#16). - A minimal, read-only Splunk Cloud (ACS) slice: `cloud indexes` / `hec-tokens` / diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index e580e6c..f6f7da4 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -8,15 +8,18 @@ from .commands.api import api from .commands.auth import auth from .commands.cloud import cloud +from .commands.cluster import cluster from .commands.factory import build_group from .commands.health import health from .commands.index import index from .commands.inspect import inspect from .commands.kvstore import kvstore +from .commands.license import license from .commands.registry import REGISTRY from .commands.saved_search import saved_search from .commands.search import search from .commands.server import server +from .commands.shcluster import shcluster @click.group(context_settings={"help_option_names": ["-h", "--help"]}) @@ -25,7 +28,20 @@ def cli() -> None: """Read, search, health-check, and safely administer Splunk Enterprise over its REST API.""" -for _group in (server, api, index, search, saved_search, health, cloud, auth, kvstore): +for _group in ( + server, + api, + index, + search, + saved_search, + health, + cloud, + auth, + kvstore, + cluster, + shcluster, + license, +): cli.add_command(_group) cli.add_command(inspect) diff --git a/src/vct_splunk/commands/cluster.py b/src/vct_splunk/commands/cluster.py new file mode 100644 index 0000000..3029c7a --- /dev/null +++ b/src/vct_splunk/commands/cluster.py @@ -0,0 +1,23 @@ +"""`splunk cluster` commands (indexer cluster status). Shell layer (imports Click).""" + +from __future__ import annotations + +import click + +from ..core import cluster as core +from . import output as out +from .context import command + + +@click.group() +def cluster() -> None: + """Indexer cluster.""" + + +@cluster.command("status") +@command +def status(ctx) -> None: + """Summarize indexer-cluster manager and peer health.""" + with ctx.client() as c: + data = core.cluster_status(c) + out.emit(data, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/license.py b/src/vct_splunk/commands/license.py new file mode 100644 index 0000000..30fc5d5 --- /dev/null +++ b/src/vct_splunk/commands/license.py @@ -0,0 +1,42 @@ +"""`splunk license` commands (licensing reads). Shell layer (imports Click).""" + +from __future__ import annotations + +import click + +from ..core import license as core +from . import output as out +from .context import command + + +@click.group() +def license() -> None: + """Splunk licensing.""" + + +@license.command("list") +@command +def list_(ctx) -> None: + """List installed licenses.""" + with ctx.client() as c: + data = core.list_licenses(c) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@license.command("get") +@click.argument("name") +@command +def get(ctx, name) -> None: + """Show one license by its name (license hash).""" + with ctx.client() as c: + data = core.get_license(c, name) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@license.command("usage") +@command +def usage(ctx) -> None: + """Report per-pool license usage (quota vs. used volume).""" + with ctx.client() as c: + data = core.license_usage(c) + out.emit(data, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/server.py b/src/vct_splunk/commands/server.py index ea55fc6..5a28875 100644 --- a/src/vct_splunk/commands/server.py +++ b/src/vct_splunk/commands/server.py @@ -5,8 +5,10 @@ import click from ..core import server as core +from ..core.errors import UsageError from . import output as out from .context import command +from .write import do_write @click.group() @@ -21,3 +23,52 @@ def info(ctx) -> None: with ctx.client() as c: data = core.get_server_info(c) out.emit(data, ctx.output_mode, ctx.meta()) + + +@server.command("restart") +@command +def restart(ctx) -> None: + """Restart the Splunk server. Gated write (interrupts the whole instance).""" + result = do_write( + ctx, + action="restart the Splunk server (interrupts the whole instance)", + audit_event={"action": "server.restart"}, + run=core.restart_server, + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@server.group("settings") +def settings() -> None: + """Splunk server general settings.""" + + +@settings.command("get") +@command +def settings_get(ctx) -> None: + """Show the server's general settings.""" + with ctx.client() as c: + data = core.get_settings(c) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@settings.command("set") +@click.option( + "--set", "_set", multiple=True, metavar="KEY=VALUE", help="Setting to change (repeatable)." +) +@command +def settings_set(ctx, _set) -> None: + """Change server settings (only the keys you pass). Gated write.""" + if not _set: + raise UsageError("Nothing to set. Pass at least one --set KEY=VALUE.") + changes: dict[str, str] = {} + for pair in _set: + key, _, val = pair.partition("=") + changes[key] = val + result = do_write( + ctx, + action=f"change server settings: {', '.join(sorted(changes))}", + audit_event={"action": "server.settings.set", "keys": sorted(changes)}, + run=lambda c: core.set_settings(c, changes), + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/shcluster.py b/src/vct_splunk/commands/shcluster.py new file mode 100644 index 0000000..e62d309 --- /dev/null +++ b/src/vct_splunk/commands/shcluster.py @@ -0,0 +1,23 @@ +"""`splunk shcluster` commands (search-head cluster). Shell layer (imports Click).""" + +from __future__ import annotations + +import click + +from ..core import cluster as core +from . import output as out +from .context import command + + +@click.group() +def shcluster() -> None: + """Search-head cluster.""" + + +@shcluster.command("status") +@command +def status(ctx) -> None: + """List search-head cluster members and their roles.""" + with ctx.client() as c: + data = core.shcluster_status(c) + out.emit(data, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/core/cluster.py b/src/vct_splunk/core/cluster.py new file mode 100644 index 0000000..56cda97 --- /dev/null +++ b/src/vct_splunk/core/cluster.py @@ -0,0 +1,58 @@ +"""Cluster status reads: indexer cluster and search-head cluster. Click-free core. + +These are system-level (not namespaced) read endpoints that summarize cluster +manager/peer health and search-head cluster membership. Shapes vary by Splunk +role and version, so they are normalized defensively with ``.get``. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient + + +def cluster_status(client: SplunkClient) -> dict[str, Any]: + """Summarize indexer-cluster manager state plus peer health. + + Reads ``/services/cluster/config`` for the local node's cluster config and + ``/services/cluster/master/info`` for manager-side status. Either may be + empty on a node that is not a cluster manager; missing pieces are reported + as null rather than failing. + """ + config = _first_content(client.get("/services/cluster/config")) + info = _first_content(client.get("/services/cluster/master/info")) + return { + "mode": config.get("mode"), + "manager_uri": config.get("manager_uri") or config.get("master_uri"), + "replication_factor": config.get("replication_factor"), + "search_factor": config.get("search_factor"), + "indexing_ready": info.get("indexing_ready_flag"), + "maintenance_mode": info.get("maintenance_mode"), + } + + +def shcluster_status(client: SplunkClient) -> list[dict[str, Any]]: + """List search-head cluster members and their roles. + + Reads ``/services/shcluster/member/members``; each entry is one member of + the search-head cluster. + """ + return [_member(e) for e in client.get_collection("/services/shcluster/member/members")] + + +def _member(entry: dict[str, Any]) -> dict[str, Any]: + c = entry.get("content") or {} + return { + "name": entry.get("name"), + "label": c.get("label"), + "status": c.get("status"), + "is_captain": c.get("is_captain"), + "site": c.get("site"), + } + + +def _first_content(body: dict[str, Any]) -> dict[str, Any]: + """Return the first entry's ``content`` block, or an empty dict if absent.""" + entries = body.get("entry") or [] + return (entries[0].get("content") or {}) if entries else {} diff --git a/src/vct_splunk/core/license.py b/src/vct_splunk/core/license.py new file mode 100644 index 0000000..ab398e3 --- /dev/null +++ b/src/vct_splunk/core/license.py @@ -0,0 +1,55 @@ +"""Licensing reads: installed licenses and usage. Click-free core. + +System-level (not namespaced) reads over ``/services/licenser/*``. Response +shapes vary by Splunk version, so fields are pulled defensively with ``.get``. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient +from .errors import NotFoundError + +_LICENSES = "/services/licenser/licenses" +_POOLS = "/services/licenser/pools" + + +def list_licenses(client: SplunkClient) -> list[dict[str, Any]]: + """List installed licenses.""" + return [_license(e) for e in client.get_collection(_LICENSES)] + + +def get_license(client: SplunkClient, name: str) -> dict[str, Any]: + """Show one license by its name (license hash).""" + entries = client.get(f"{_LICENSES}/{name}").get("entry") or [] + if not entries: + raise NotFoundError(f"License {name!r} not found.") + return _license(entries[0]) + + +def license_usage(client: SplunkClient) -> list[dict[str, Any]]: + """Report per-pool license usage (quota vs. used volume).""" + return [_pool(e) for e in client.get_collection(_POOLS)] + + +def _license(entry: dict[str, Any]) -> dict[str, Any]: + c = entry.get("content") or {} + return { + "name": entry.get("name"), + "label": c.get("label"), + "type": c.get("type"), + "quota_bytes": c.get("quota"), + "expiration_time": c.get("expiration_time"), + "max_violations": c.get("max_violations"), + } + + +def _pool(entry: dict[str, Any]) -> dict[str, Any]: + c = entry.get("content") or {} + return { + "name": entry.get("name"), + "stack_id": c.get("stack_id"), + "quota_bytes": c.get("quota"), + "used_bytes": c.get("used_bytes"), + } diff --git a/src/vct_splunk/core/server.py b/src/vct_splunk/core/server.py index e72160f..9aede42 100644 --- a/src/vct_splunk/core/server.py +++ b/src/vct_splunk/core/server.py @@ -28,3 +28,24 @@ def get_server_info(client: SplunkClient) -> dict[str, Any]: "os_name": content.get("os_name"), "guid": content.get("guid"), } + + +def restart_server(client: SplunkClient) -> dict[str, Any]: + """Restart the Splunk server (a gated, blast-radius-significant write).""" + return client.write("POST", "/services/server/control/restart", {}) + + +def get_settings(client: SplunkClient) -> dict[str, Any]: + """Show the server's general settings.""" + body = client.get("/services/server/settings/settings") + entries = body.get("entry") or [] + return entries[0].get("content") or {} if entries else {} + + +def set_settings(client: SplunkClient, settings: dict[str, Any]) -> dict[str, Any]: + """Apply changed server settings (form keys); a gated write.""" + result = client.write("POST", "/services/server/settings/settings", settings) + if result.get("dry_run"): + return result + entries = result.get("entry") or [] + return entries[0].get("content") or {} if entries else result diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 490c4c8..5a726fd 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -188,3 +188,99 @@ def test_saved_search_create_dry_run_previews_app_namespace(monkeypatch): assert result.exit_code == 0 assert '"dry_run": true' in result.output assert "/servicesNS/nobody/my_app/saved/searches" in result.output + + +def test_cluster_status_renders(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + if req.url.path == "/services/cluster/config": + return httpx.Response(200, json={"entry": [{"content": {"mode": "manager"}}]}) + return httpx.Response(200, json={"entry": [{"content": {"indexing_ready_flag": True}}]}) + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke(cli, ["cluster", "status", "--output", "json"]) + assert result.exit_code == 0 + assert '"mode": "manager"' in result.output + + +def test_license_list_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, + json={ + "entry": [{"name": "lic1", "content": {"label": "Enterprise"}}], + "paging": {"total": 1}, + }, + ), + ) + result = CliRunner().invoke(cli, ["license", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"name": "lic1"' in result.output + + +def test_message_list_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, + json={ + "entry": [{"name": "restart_required", "content": {"value": "Restart needed"}}], + "paging": {"total": 1}, + }, + ), + ) + result = CliRunner().invoke(cli, ["message", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"name": "restart_required"' in result.output + + +def test_server_settings_get_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, json={"entry": [{"content": {"serverName": "sh1", "host": "sh1"}}]} + ), + ) + result = CliRunner().invoke(cli, ["server", "settings", "get", "--output", "json"]) + assert result.exit_code == 0 + assert '"serverName": "sh1"' in result.output + + +def test_server_restart_refuses_without_yes_noninteractive(monkeypatch): + _env(monkeypatch) + # Must refuse before any network call, so no client patch is needed. + result = CliRunner().invoke(cli, ["server", "restart", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_server_restart_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke(cli, ["server", "restart", "--dry-run", "--output", "json"]) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + + +def test_server_settings_set_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke( + cli, + ["server", "settings", "set", "--set", "host=sh1", "--dry-run", "--output", "json"], + ) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output From 9603e7407d4516a323781e501696f0ff0ab2c42b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:30:21 -0400 Subject: [PATCH 05/46] feat: app install and deployment-server control (#5) Add the two parts of app/deploy that the CRUD specs cannot cover: - 'app install --file PATH | --url URL [--update]' installs an app via /services/apps/appinstall (Splunk reads a local path server-side or fetches an http(s) URL); exactly one source is required. - 'deploy' group: 'client list', 'serverclass list|get|create|update', and 'reload' for the deployment server. App lifecycle (list/get/enable/disable/remove) keeps shipping via the registry/factory; this adds only the hand-written multipart-ish install and the deployment-server endpoints. Server-class settings are taken as free-form --set KEY=VALUE so no field shape is assumed. Every mutation routes through do_write for the dry-run/confirm/--yes/audit gate. Closes #5 --- CHANGELOG.md | 6 ++ src/vct_splunk/cli.py | 10 ++- src/vct_splunk/commands/apps.py | 35 ++++++++ src/vct_splunk/commands/deploy.py | 119 ++++++++++++++++++++++++++++ src/vct_splunk/commands/registry.py | 4 +- src/vct_splunk/core/apps.py | 37 +++++++++ src/vct_splunk/core/deploy.py | 64 +++++++++++++++ tests/unit/test_commands.py | 91 +++++++++++++++++++++ 8 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 src/vct_splunk/commands/apps.py create mode 100644 src/vct_splunk/commands/deploy.py create mode 100644 src/vct_splunk/core/apps.py create mode 100644 src/vct_splunk/core/deploy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e3de76d..bfdd90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ This is the 0.2.0 development line (version bumped from 0.0.1). schemas, system messages, and app lifecycle (#5, #6, #8, #9, #10). - `kvstore records` / `get` / `insert` / `update` / `delete` / `purge` manage KV Store data records as a namespaced JSON document store; writes require an app (#9). +- `app install` adds an app from a local `--file` or a `--url`, with `--update` to + overwrite. It is a gated write, so it previews with `--dry-run` (#5). +- `deploy` reads the deployment server: `deploy client list` and + `deploy serverclass list` / `get`. The gated writes `deploy serverclass create` / + `update` (each needs at least one `--set KEY=VALUE`) and `deploy reload` change + and reload server-class config (#5). - `cluster status` and `shcluster status` read indexer-cluster and search-head cluster health, and `license list` / `get` / `usage` report licensing (#10). - `server restart` and `server settings get` / `set` manage the instance. diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index f6f7da4..f5e1748 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -6,9 +6,11 @@ from . import __version__ from .commands.api import api +from .commands.apps import app_install from .commands.auth import auth from .commands.cloud import cloud from .commands.cluster import cluster +from .commands.deploy import deploy from .commands.factory import build_group from .commands.health import health from .commands.index import index @@ -41,14 +43,20 @@ def cli() -> None: cluster, shcluster, license, + deploy, ): cli.add_command(_group) cli.add_command(inspect) # Factory-generated CRUD resources (users, roles, ...): each spec becomes a group. +# The 'app' group's install-from-file/URL command does not fit the CRUD shape, so +# it is hand-written and attached to the generated group here. for _spec in REGISTRY: - cli.add_command(build_group(_spec)) + _grp = build_group(_spec) + if _spec.name == "app": + _grp.add_command(app_install) + cli.add_command(_grp) def main() -> None: diff --git a/src/vct_splunk/commands/apps.py b/src/vct_splunk/commands/apps.py new file mode 100644 index 0000000..7bd23ef --- /dev/null +++ b/src/vct_splunk/commands/apps.py @@ -0,0 +1,35 @@ +"""`splunk app install` command. Shell layer (imports Click). + +The rest of the ``app`` group (list/get/delete/enable/disable) is factory-generated +from the ``app`` spec. Install does not fit the CRUD shape, so it is hand-written +here and attached to that generated group in ``cli.py``. +""" + +from __future__ import annotations + +import click + +from ..core import apps as core +from ..core.errors import UsageError +from . import output as out +from .context import command +from .write import do_write + + +@click.command("install") +@click.option("--file", "file", default=None, help="Local app archive path (.tar.gz/.spl).") +@click.option("--url", "url", default=None, help="http(s) URL to an app archive.") +@click.option("--update/--no-update", default=False, help="Overwrite an already-installed app.") +@command +def app_install(ctx, file, url, update) -> None: + """Install an app from a local --file or a --url. Gated write.""" + if bool(file) == bool(url): + raise UsageError("Pass exactly one of --file or --url.") + source = file or url + result = do_write( + ctx, + action=f"install app from '{source}'" + (" (overwrite)" if update else ""), + audit_event={"action": "app.install", "source": source, "update": update}, + run=lambda c: core.install_app(c, source, update=update), + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/deploy.py b/src/vct_splunk/commands/deploy.py new file mode 100644 index 0000000..7d241d7 --- /dev/null +++ b/src/vct_splunk/commands/deploy.py @@ -0,0 +1,119 @@ +"""`splunk deploy` commands for the deployment server. Shell layer (imports Click). + +These are system-level endpoints (not namespaced), so there is no --app/--owner +logic. Writes (serverclass create/update, reload) route through the shared +``do_write`` gate. +""" + +from __future__ import annotations + +from typing import Any + +import click + +from ..core import deploy as core +from ..core.errors import UsageError +from . import output as out +from .context import command +from .write import do_write + + +def _parse_sets(pairs: tuple[str, ...]) -> dict[str, Any]: + """Split repeated ``KEY=VALUE`` strings into a form-field dict.""" + out_: dict[str, Any] = {} + for pair in pairs: + key, _, val = pair.partition("=") + out_[key] = val + return out_ + + +@click.group(name="deploy") +def deploy() -> None: + """Splunk deployment server (clients, server classes, config reload).""" + + +@deploy.group("client") +def client_grp() -> None: + """Deployment clients.""" + + +@client_grp.command("list") +@command +def client_list(ctx) -> None: + """List the deployment clients phoning home.""" + with ctx.client() as c: + data = core.list_clients(c) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@deploy.group("serverclass") +def serverclass_grp() -> None: + """Deployment server classes.""" + + +@serverclass_grp.command("list") +@command +def serverclass_list(ctx) -> None: + """List the configured server classes.""" + with ctx.client() as c: + data = core.list_serverclasses(c) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@serverclass_grp.command("get") +@click.argument("name") +@command +def serverclass_get(ctx, name) -> None: + """Show one server class by name.""" + with ctx.client() as c: + data = core.get_serverclass(c, name) + out.emit(data, ctx.output_mode, ctx.meta()) + + +@serverclass_grp.command("create") +@click.argument("name") +@click.option("--set", "_set", multiple=True, metavar="KEY=VALUE", help="Setting (repeatable).") +@command +def serverclass_create(ctx, name, _set) -> None: + """Create a server class. Requires at least one --set. Gated write.""" + if not _set: + raise UsageError("Nothing to create. Pass at least one --set KEY=VALUE.") + settings = _parse_sets(_set) + result = do_write( + ctx, + action=f"create server class '{name}'", + audit_event={"action": "deploy.serverclass.create", "name": name}, + run=lambda c: core.create_serverclass(c, name, settings), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@serverclass_grp.command("update") +@click.argument("name") +@click.option("--set", "_set", multiple=True, metavar="KEY=VALUE", help="Setting (repeatable).") +@command +def serverclass_update(ctx, name, _set) -> None: + """Update a server class (only the keys you pass). Gated write.""" + if not _set: + raise UsageError("Nothing to update. Pass at least one --set KEY=VALUE.") + settings = _parse_sets(_set) + result = do_write( + ctx, + action=f"update server class '{name}'", + audit_event={"action": "deploy.serverclass.update", "name": name}, + run=lambda c: core.update_serverclass(c, name, settings), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@deploy.command("reload") +@command +def reload(ctx) -> None: + """Reload the deployment server's server-class config. Gated write.""" + result = do_write( + ctx, + action="reload the deployment server config", + audit_event={"action": "deploy.reload"}, + run=core.reload_config, + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index cefbe89..151e3ac 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -248,8 +248,8 @@ ) # --- Apps (#5) --------------------------------------------------------------- -# Lifecycle only. Install-from-file/URL is a multipart upload and stays -# hand-written. +# Lifecycle only. Install-from-file/URL (the appinstall endpoint) does not fit +# the CRUD shape and stays hand-written (see commands/apps.py). APP = Spec( name="app", diff --git a/src/vct_splunk/core/apps.py b/src/vct_splunk/core/apps.py new file mode 100644 index 0000000..0d511c5 --- /dev/null +++ b/src/vct_splunk/core/apps.py @@ -0,0 +1,37 @@ +"""App install from a local file or a URL. Click-free core. + +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 +it lives here. + +Install approach: we POST to ``/services/apps/appinstall`` with ``name=``. +Splunk reads a local absolute path server-side and fetches an http(s) URL itself, +so a single form field covers both cases. This avoids a true multipart streaming +upload, which is finicky and version-dependent. + +ponytail: true multipart file upload (streaming the bytes to Splunk) can be added +here when a real need appears -- e.g. installing a file the Splunk host cannot +read off its own filesystem. Today the appinstall ``name=`` form covers both +local-path and URL installs in one line. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient + +_PATH = "/services/apps/appinstall" + + +def install_app(client: SplunkClient, source: str, *, update: bool = False) -> dict[str, Any]: + """Install an app from a local path or an http(s) URL. + + ``source`` is the local absolute path (``.tar.gz``/``.spl``) or the URL; + Splunk reads it server-side. ``update=True`` allows overwriting an app that + is already installed. This is a gated write (dry-run aware via the client). + """ + data: dict[str, Any] = {"name": source} + if update: + data["update"] = "true" + return client.write("POST", _PATH, data) diff --git a/src/vct_splunk/core/deploy.py b/src/vct_splunk/core/deploy.py new file mode 100644 index 0000000..4e835fd --- /dev/null +++ b/src/vct_splunk/core/deploy.py @@ -0,0 +1,64 @@ +"""Deployment server operations: clients, server classes, reload. Click-free core. + +The deployment server hands out app bundles to deployment clients. These are +system-level endpoints under ``/services/deployment/server`` -- not namespaced, +so there is no owner/app logic. Reads normalize the Splunk ``entry[].content`` +envelope; writes are gated through the command layer's ``do_write``. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient +from .errors import NotFoundError + +_CLIENTS = "/services/deployment/server/clients" +_SERVERCLASSES = "/services/deployment/server/serverclasses" +_RELOAD = "/services/deployment/server/config/_reload" + + +def list_clients(client: SplunkClient) -> list[dict[str, Any]]: + """List the deployment clients currently phoning home.""" + return [_named(e) for e in client.get_collection(_CLIENTS)] + + +def list_serverclasses(client: SplunkClient) -> list[dict[str, Any]]: + """List the configured server classes.""" + return [_named(e) for e in client.get_collection(_SERVERCLASSES)] + + +def get_serverclass(client: SplunkClient, name: str) -> dict[str, Any]: + """Show one server class by name.""" + entries = client.get(f"{_SERVERCLASSES}/{name}").get("entry") or [] + if not entries: + raise NotFoundError(f"Server class {name!r} not found.") + return _named(entries[0]) + + +def create_serverclass(client: SplunkClient, name: str, settings: dict[str, Any]) -> dict[str, Any]: + """Create a server class with the given form settings (a gated write).""" + return _unwrap(client.write("POST", _SERVERCLASSES, {"name": name, **settings})) + + +def update_serverclass(client: SplunkClient, name: str, settings: dict[str, Any]) -> dict[str, Any]: + """Update a server class, sending only the changed settings (a gated write).""" + return _unwrap(client.write("POST", f"{_SERVERCLASSES}/{name}", settings)) + + +def reload_config(client: SplunkClient) -> dict[str, Any]: + """Reload the deployment server's server-class configuration (a gated write).""" + return client.write("POST", _RELOAD, {}) + + +def _unwrap(result: dict[str, Any]) -> dict[str, Any]: + """Return a dry-run preview unchanged, else normalize the affected server class.""" + if result.get("dry_run"): + return result + entries = result.get("entry") or [] + return _named(entries[0]) if entries else result + + +def _named(entry: dict[str, Any]) -> dict[str, Any]: + """Flatten one Splunk entry into ``name`` plus its content block.""" + return {"name": entry.get("name"), **(entry.get("content") or {})} diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 5a726fd..29b19e3 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -284,3 +284,94 @@ def handler(req: httpx.Request) -> httpx.Response: ) assert result.exit_code == 0 assert '"dry_run": true' in result.output + + +def test_app_install_requires_one_source(monkeypatch): + _env(monkeypatch) + # Neither --file nor --url: usage error before any network call. + result = CliRunner().invoke(cli, ["app", "install", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_app_install_url_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke( + cli, ["app", "install", "--url", "https://x/app.spl", "--dry-run", "--output", "json"] + ) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + + +def test_deploy_client_list_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, + json={ + "entry": [{"name": "client1", "content": {"hostname": "fwd1"}}], + "paging": {"total": 1}, + }, + ), + ) + result = CliRunner().invoke(cli, ["deploy", "client", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"name": "client1"' in result.output + + +def test_deploy_serverclass_list_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, + json={ + "entry": [{"name": "sc1", "content": {"whitelist.0": "*"}}], + "paging": {"total": 1}, + }, + ), + ) + result = CliRunner().invoke(cli, ["deploy", "serverclass", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"name": "sc1"' in result.output + + +def test_deploy_reload_refuses_without_yes_noninteractive(monkeypatch): + _env(monkeypatch) + # Must refuse before any network call, so no client patch is needed. + result = CliRunner().invoke(cli, ["deploy", "reload", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_deploy_reload_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke(cli, ["deploy", "reload", "--dry-run", "--output", "json"]) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + + +def test_deploy_serverclass_create_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke( + cli, + ["deploy", "serverclass", "create", "foo", "--set", "x=1", "--dry-run", "--output", "json"], + ) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output From 12d0b6bce31fb2860900c657fd6b11739824d1fd Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:36:51 -0400 Subject: [PATCH 06/46] feat: HEC rotation/global control and tag/datamodel/lookup knowledge objects (#6, #8) Finish the hand-written pieces the CRUD specs cannot express: HEC (#6): - 'hec rotate NAME' regenerates a token's secret (printed once; the secret is never written to the audit log). - 'hec global-enable' / 'global-disable' toggle the global HEC input. Knowledge objects (#8): - New 'tag' and 'datamodel' registry/factory groups (namespaced). - 'datamodel accelerate NAME --enable/--disable' toggles acceleration. - 'lookup upload --file CSV --app APP' registers a lookup table file (namespaced; requires an app). Every mutation routes through do_write for the dry-run/confirm/--yes/ audit gate. Large or version-variable settings (tag linkage, datamodel JSON) are taken as free-form --set KEY=VALUE rather than modeled fields. Closes #6 Closes #8 --- CHANGELOG.md | 7 ++ src/vct_splunk/cli.py | 7 ++ src/vct_splunk/commands/datamodel.py | 34 ++++++++ src/vct_splunk/commands/hec.py | 64 ++++++++++++++ src/vct_splunk/commands/lookup.py | 40 +++++++++ src/vct_splunk/commands/registry.py | 22 +++++ src/vct_splunk/core/datamodel.py | 33 ++++++++ src/vct_splunk/core/hec.py | 49 +++++++++++ src/vct_splunk/core/lookups.py | 33 ++++++++ tests/unit/test_commands.py | 119 +++++++++++++++++++++++++++ 10 files changed, 408 insertions(+) create mode 100644 src/vct_splunk/commands/datamodel.py create mode 100644 src/vct_splunk/commands/hec.py create mode 100644 src/vct_splunk/commands/lookup.py create mode 100644 src/vct_splunk/core/datamodel.py create mode 100644 src/vct_splunk/core/hec.py create mode 100644 src/vct_splunk/core/lookups.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdd90b..e4a1a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ This is the 0.2.0 development line (version bumped from 0.0.1). - Many admin resources as generated CRUD groups: data inputs and outputs, search macros, event types, field extractions, lookup definitions, KV Store collection schemas, system messages, and app lifecycle (#5, #6, #8, #9, #10). +- `hec rotate` mints a fresh secret for a HEC token and prints the new value (it + is kept out of the audit log). `hec global-enable` / `global-disable` turn the + whole HTTP Event Collector on or off. Both are gated writes (#6). +- `tag` and `datamodel` join the generated CRUD groups for field-value tags and + data models; large fields go through `--set`. `datamodel accelerate` toggles a + data model's acceleration, and `lookup upload --file PATH --app APP` adds a CSV + lookup table file to an app. Both are gated, namespaced writes (#8). - `kvstore records` / `get` / `insert` / `update` / `delete` / `purge` manage KV Store data records as a namespaced JSON document store; writes require an app (#9). - `app install` adds an app from a local `--file` or a `--url`, with `--update` to diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index f5e1748..33486a8 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -10,13 +10,16 @@ from .commands.auth import auth from .commands.cloud import cloud from .commands.cluster import cluster +from .commands.datamodel import datamodel_accelerate from .commands.deploy import deploy from .commands.factory import build_group from .commands.health import health +from .commands.hec import hec from .commands.index import index from .commands.inspect import inspect from .commands.kvstore import kvstore from .commands.license import license +from .commands.lookup import lookup from .commands.registry import REGISTRY from .commands.saved_search import saved_search from .commands.search import search @@ -44,6 +47,8 @@ def cli() -> None: shcluster, license, deploy, + hec, + lookup, ): cli.add_command(_group) @@ -56,6 +61,8 @@ def cli() -> None: _grp = build_group(_spec) if _spec.name == "app": _grp.add_command(app_install) + elif _spec.name == "datamodel": + _grp.add_command(datamodel_accelerate) cli.add_command(_grp) diff --git a/src/vct_splunk/commands/datamodel.py b/src/vct_splunk/commands/datamodel.py new file mode 100644 index 0000000..db71142 --- /dev/null +++ b/src/vct_splunk/commands/datamodel.py @@ -0,0 +1,34 @@ +"""`splunk datamodel accelerate` command. Shell layer (imports Click). + +The rest of the ``datamodel`` group (list/get/create/update/delete) is +factory-generated from the ``datamodel`` spec. Toggling acceleration does not fit +the CRUD shape, so it is hand-written here and attached to that generated group in +``cli.py``. +""" + +from __future__ import annotations + +import click + +from ..core import datamodel as core +from ..core.namespace import resolve_ns +from . import output as out +from .context import command +from .write import do_write + + +@click.command("accelerate") +@click.argument("name") +@click.option("--enable/--disable", default=True, help="Turn acceleration on (default) or off.") +@command +def datamodel_accelerate(ctx, name, enable) -> None: + """Toggle acceleration on a data model. Namespaced; gated write; requires an app.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) + verb = "enable" if enable else "disable" + result = do_write( + ctx, + action=f"{verb} acceleration on data model '{name}' in app '{app}'", + audit_event={"action": "datamodel.accelerate", "name": name, "enabled": enable, "app": app}, + run=lambda c: core.accelerate(c, name, enabled=enable, owner=owner, app=app), + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/hec.py b/src/vct_splunk/commands/hec.py new file mode 100644 index 0000000..2d6604c --- /dev/null +++ b/src/vct_splunk/commands/hec.py @@ -0,0 +1,64 @@ +"""`splunk hec` commands: HEC extras the CRUD factory cannot express. + +Token CRUD is the factory-generated ``hec-token`` group. This group adds the two +operations that do not fit that shape: rotating a token's secret and toggling the +global HEC listener on or off. +""" + +from __future__ import annotations + +import click + +from ..core import hec as core +from . import output as out +from .context import command +from .write import do_write + + +@click.group(name="hec") +def hec() -> None: + """HTTP Event Collector extras (token CRUD lives in the 'hec-token' group).""" + + +@hec.command("rotate") +@click.argument("name") +@command +def rotate(ctx, name) -> None: + """Mint a fresh secret for a HEC token, printing the new value. Gated write. + + The new token value is printed (that is the point of rotation) but is never + written to the audit log, which records only the action and token name. + """ + result = do_write( + ctx, + action=f"rotate HEC token '{name}' (mints a new secret)", + audit_event={"action": "hec.rotate", "name": name}, + run=lambda c: core.rotate_token(c, name), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@hec.command("global-enable") +@command +def global_enable(ctx) -> None: + """Enable HEC globally (the 'http' input stanza). Gated write.""" + result = do_write( + ctx, + action="enable HEC globally", + audit_event={"action": "hec.global_enable"}, + run=lambda c: core.set_global(c, enabled=True), + ) + out.emit(result, ctx.output_mode, ctx.meta()) + + +@hec.command("global-disable") +@command +def global_disable(ctx) -> None: + """Disable HEC globally (the 'http' input stanza). Gated write.""" + result = do_write( + ctx, + action="disable HEC globally", + audit_event={"action": "hec.global_disable"}, + run=lambda c: core.set_global(c, enabled=False), + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/lookup.py b/src/vct_splunk/commands/lookup.py new file mode 100644 index 0000000..d8b8998 --- /dev/null +++ b/src/vct_splunk/commands/lookup.py @@ -0,0 +1,40 @@ +"""`splunk lookup` commands: lookup table file upload. Shell layer (imports Click). + +Lookup *definitions* are the factory-generated ``lookup-definition`` group. This +group adds the one operation that does not fit the CRUD shape: uploading the CSV +table file itself. +""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from ..core import lookups as core +from ..core.namespace import resolve_ns +from . import output as out +from .context import command +from .write import do_write + + +@click.group(name="lookup") +def lookup() -> None: + """Lookup table files (lookup definitions live in the 'lookup-definition' group).""" + + +@lookup.command("upload") +@click.option("--file", "file", required=True, type=click.Path(exists=True), help="Local CSV path.") +@command +def upload(ctx, file) -> None: + """Upload a CSV lookup table file into an app. Namespaced; gated write; requires an app.""" + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) + filename = Path(file).name + contents = Path(file).read_text(encoding="utf-8") + result = do_write( + ctx, + action=f"upload lookup file '{filename}' into app '{app}'", + audit_event={"action": "lookup.upload", "filename": filename, "app": app}, + run=lambda c: core.upload_lookup(c, filename, contents, owner=owner, app=app), + ) + out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index 151e3ac..ac0fd74 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -223,6 +223,26 @@ fields=(Field("filename", key="filename", help="Lookup table file name."),), ) +TAG = Spec( + name="tag", + path="saved/fvtags", + help="Field-value tags (use --set to tie field=value to tag names).", + namespaced=True, + verbs=("list", "get", "create", "update", "delete"), + # A tag entry ties a field=value pair to one or more tag names. The exact + # keys vary by Splunk version, so only the obvious one is modeled and the + # rest go through --set (validate-in-CI against a live instance). + fields=(Field("tag", key="tag", multi=True, help="Tag name (repeatable)."),), +) + +DATAMODEL = Spec( + name="datamodel", + path="datamodel/model", + help="Data models (large JSON; use --set description/acceleration). Accelerate is separate.", + namespaced=True, + fields=(Field("description", key="description", help="Human description."),), +) + # --- KV Store (#9) ----------------------------------------------------------- # Only the collection schema is CRUD-shaped. Schema fields are dynamic # (field.=), so they go through --set. Data records are a document @@ -273,6 +293,8 @@ EVENTTYPE, EXTRACTION, LOOKUP_DEFINITION, + TAG, + DATAMODEL, KVSTORE_COLLECTION, MESSAGE, APP, diff --git a/src/vct_splunk/core/datamodel.py b/src/vct_splunk/core/datamodel.py new file mode 100644 index 0000000..02fb2c2 --- /dev/null +++ b/src/vct_splunk/core/datamodel.py @@ -0,0 +1,33 @@ +"""Data model acceleration toggle. Click-free core. + +Data model list/get/create/update is factory-generated from the ``datamodel`` +spec. Toggling acceleration does not fit the CRUD shape and lives here. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient +from .namespace import ns_path + +_MODEL = "datamodel/model" + + +def accelerate( + client: SplunkClient, name: str, *, enabled: bool, owner: str, app: str +) -> dict[str, Any]: + """Toggle acceleration on a data model. + + Posts to the model stanza with an ``acceleration`` JSON field carrying + ``{"enabled": true|false}``. Splunk merges this into the model's + acceleration settings; only the ``enabled`` flag is changed. + + ponytail: we send only ``acceleration={"enabled": ...}`` to the model + endpoint -- the simplest form Splunk accepts. Other acceleration knobs + (earliest time, cron) are reachable via ``datamodel update --set`` and are + not modeled here until a real need appears. + """ + flag = "true" if enabled else "false" + body = {"acceleration": f'{{"enabled": {flag}}}'} + return client.write("POST", ns_path(f"{_MODEL}/{name}", owner=owner, app=app), body) diff --git a/src/vct_splunk/core/hec.py b/src/vct_splunk/core/hec.py new file mode 100644 index 0000000..ded6fc8 --- /dev/null +++ b/src/vct_splunk/core/hec.py @@ -0,0 +1,49 @@ +"""HTTP Event Collector (HEC) extras that the CRUD factory cannot express. + +The HEC *token* list/get/create/update/delete surface is factory-generated from +the ``hec-token`` spec. Two operations do not fit that CRUD shape and live here: + +* 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`. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient + +_HTTP = "/services/data/inputs/http" + + +def rotate_token(client: SplunkClient, name: str) -> dict[str, Any]: + """Regenerate the secret of an existing HEC token, returning the new value. + + Splunk has no first-class "rotate" endpoint. The reliable single call is a + POST to the token stanza with ``rotate=true``; Splunk mints a fresh secret + and returns the updated entry (its ``content.token`` is the new value). + + ponytail: this assumes the running Splunk honors ``rotate=true`` on the HEC + stanza (supported on current Splunk Enterprise). If a target version ignores + it, the fallback is delete + re-create with the same settings -- not built + until a real version gap appears. The new token is returned to the caller but + must never be written to the audit log. + """ + result = client.write("POST", f"{_HTTP}/{name}", {"rotate": "true"}) + if result.get("dry_run"): + return result + entries = result.get("entry") or [] + content = entries[0].get("content") if entries else {} + return {"name": name, "token": (content or {}).get("token")} + + +def set_global(client: SplunkClient, *, enabled: bool) -> dict[str, Any]: + """Enable or disable HEC globally via the ``http`` input stanza. + + The global HEC listener is the special ``http`` stanza (not a token). A POST + with ``disabled=0`` turns the collector on; ``disabled=1`` turns it off. + """ + disabled = 0 if enabled else 1 + return client.write("POST", f"{_HTTP}/http", {"disabled": disabled}) diff --git a/src/vct_splunk/core/lookups.py b/src/vct_splunk/core/lookups.py new file mode 100644 index 0000000..4569b31 --- /dev/null +++ b/src/vct_splunk/core/lookups.py @@ -0,0 +1,33 @@ +"""Lookup table file upload. Click-free core. + +Lookup *definitions* (the transforms.conf stanza) are factory-generated from the +``lookup-definition`` spec. Uploading the CSV table file itself is a namespaced +write that does not fit the CRUD shape and lives here. +""" + +from __future__ import annotations + +from typing import Any + +from .client import SplunkClient +from .namespace import ns_path + +_FILES = "data/lookup-table-files" + + +def upload_lookup( + client: SplunkClient, filename: str, contents: str, *, owner: str, app: str +) -> dict[str, Any]: + """Create a lookup-table file entry, sending the CSV bytes as ``eai:data``. + + Splunk's lookup-table-files endpoint accepts the file name plus the file + body as the ``eai:data`` form field, which avoids a true multipart upload. + ``filename`` is the name the table file will have in the app; ``contents`` + is the raw CSV text read from the local path by the command layer. + + ponytail: this sends the whole CSV inline as a form field, which is fine for + typical lookup tables. Streaming a multi-hundred-MB file would need a real + multipart helper on the client -- not built until a large file appears. + """ + body: dict[str, Any] = {"name": filename, "eai:data": contents} + return client.write("POST", ns_path(_FILES, owner=owner, app=app), body) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 29b19e3..7c9898d 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -375,3 +375,122 @@ def handler(req: httpx.Request) -> httpx.Response: ) assert result.exit_code == 0 assert '"dry_run": true' in result.output + + +def test_hec_global_enable_refuses_without_yes_noninteractive(monkeypatch): + _env(monkeypatch) + # Must refuse before any network call, so no client patch is needed. + result = CliRunner().invoke(cli, ["hec", "global-enable", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_hec_global_enable_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke(cli, ["hec", "global-enable", "--dry-run", "--output", "json"]) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + + +def test_hec_rotate_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke(cli, ["hec", "rotate", "tok1", "--dry-run", "--output", "json"]) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + + +def test_datamodel_list_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, + json={ + "entry": [{"name": "Authentication", "content": {}, "acl": {}}], + "paging": {"total": 1}, + }, + ), + ) + result = CliRunner().invoke(cli, ["datamodel", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"name": "Authentication"' in result.output + + +def test_tag_list_renders(monkeypatch): + _env(monkeypatch) + _patch_client( + monkeypatch, + lambda req: httpx.Response( + 200, + json={ + "entry": [{"name": "important", "content": {}, "acl": {}}], + "paging": {"total": 1}, + }, + ), + ) + result = CliRunner().invoke(cli, ["tag", "list", "--output", "json"]) + assert result.exit_code == 0 + assert '"name": "important"' in result.output + + +def test_datamodel_accelerate_dry_run_previews(monkeypatch): + _env(monkeypatch) + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke( + cli, + [ + "datamodel", + "accelerate", + "Authentication", + "--enable", + "--app", + "a", + "--dry-run", + "--output", + "json", + ], + ) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + + +def test_lookup_upload_requires_app(monkeypatch, tmp_path): + _env(monkeypatch) + csv = tmp_path / "t.csv" + csv.write_text("a,b\n1,2\n", encoding="utf-8") + # No --app and no SPLUNK_APP -> the namespaced write must refuse (exit 2). + result = CliRunner().invoke(cli, ["lookup", "upload", "--file", str(csv), "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_lookup_upload_dry_run_previews_namespace(monkeypatch, tmp_path): + _env(monkeypatch) + csv = tmp_path / "t.csv" + csv.write_text("a,b\n1,2\n", encoding="utf-8") + + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("dry-run must not send a request") + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke( + cli, + ["lookup", "upload", "--file", str(csv), "--app", "a", "--dry-run", "--output", "json"], + ) + assert result.exit_code == 0 + assert '"dry_run": true' in result.output + assert "/servicesNS/nobody/a/data/lookup-table-files" in result.output From 19ff71a998356e834f2afc041b8eb1ecff28e559 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:12 -0400 Subject: [PATCH 07/46] ci: hard timeouts and manual-only Splunk integration (cost control) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db698f..099802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -11,6 +12,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -41,16 +43,20 @@ jobs: run: .venv/bin/pytest # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # Informational (not a required check): the unit `check` job above is the gate. - # KV Store and the bundled MongoDB run natively on this x86 runner, so the full - # suite works here (on Apple Silicon, boot the container with KV Store disabled). + # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # it runs ONLY on manual dispatch -- never on push or pull_request -- and is + # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. + # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: - # Current plus an older supported release. - splunk-version: ["latest", "9.2"] + # Pinned (not `latest`): a floating tag silently jumps major versions and + # breaks boot. Current plus an older supported release. + splunk-version: ["9.4", "9.2"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -71,6 +77,7 @@ jobs: - name: Start Splunk run: | docker run -d --name splunk -p 8089:8089 \ + -e SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com \ -e SPLUNK_START_ARGS=--accept-license \ -e SPLUNK_PASSWORD="$SPLUNK_PASSWORD" \ splunk/splunk:${{ matrix.splunk-version }} From d4e02765a3018ebdd721669c7d8d8711bfc1c69a Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:13 -0400 Subject: [PATCH 08/46] ci: hard timeouts and manual-only Splunk integration (cost control) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db698f..099802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -11,6 +12,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -41,16 +43,20 @@ jobs: run: .venv/bin/pytest # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # Informational (not a required check): the unit `check` job above is the gate. - # KV Store and the bundled MongoDB run natively on this x86 runner, so the full - # suite works here (on Apple Silicon, boot the container with KV Store disabled). + # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # it runs ONLY on manual dispatch -- never on push or pull_request -- and is + # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. + # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: - # Current plus an older supported release. - splunk-version: ["latest", "9.2"] + # Pinned (not `latest`): a floating tag silently jumps major versions and + # breaks boot. Current plus an older supported release. + splunk-version: ["9.4", "9.2"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -71,6 +77,7 @@ jobs: - name: Start Splunk run: | docker run -d --name splunk -p 8089:8089 \ + -e SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com \ -e SPLUNK_START_ARGS=--accept-license \ -e SPLUNK_PASSWORD="$SPLUNK_PASSWORD" \ splunk/splunk:${{ matrix.splunk-version }} From baeaf76e1b79d7e094c350218e05bc3c9d6561ab Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:15 -0400 Subject: [PATCH 09/46] ci: hard timeouts and manual-only Splunk integration (cost control) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db698f..099802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -11,6 +12,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -41,16 +43,20 @@ jobs: run: .venv/bin/pytest # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # Informational (not a required check): the unit `check` job above is the gate. - # KV Store and the bundled MongoDB run natively on this x86 runner, so the full - # suite works here (on Apple Silicon, boot the container with KV Store disabled). + # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # it runs ONLY on manual dispatch -- never on push or pull_request -- and is + # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. + # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: - # Current plus an older supported release. - splunk-version: ["latest", "9.2"] + # Pinned (not `latest`): a floating tag silently jumps major versions and + # breaks boot. Current plus an older supported release. + splunk-version: ["9.4", "9.2"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -71,6 +77,7 @@ jobs: - name: Start Splunk run: | docker run -d --name splunk -p 8089:8089 \ + -e SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com \ -e SPLUNK_START_ARGS=--accept-license \ -e SPLUNK_PASSWORD="$SPLUNK_PASSWORD" \ splunk/splunk:${{ matrix.splunk-version }} From 2f0661db2197dae473408e8ae5544ce647b7ab1d Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:17 -0400 Subject: [PATCH 10/46] ci: hard timeouts and manual-only Splunk integration (cost control) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db698f..099802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -11,6 +12,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -41,16 +43,20 @@ jobs: run: .venv/bin/pytest # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # Informational (not a required check): the unit `check` job above is the gate. - # KV Store and the bundled MongoDB run natively on this x86 runner, so the full - # suite works here (on Apple Silicon, boot the container with KV Store disabled). + # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # it runs ONLY on manual dispatch -- never on push or pull_request -- and is + # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. + # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: - # Current plus an older supported release. - splunk-version: ["latest", "9.2"] + # Pinned (not `latest`): a floating tag silently jumps major versions and + # breaks boot. Current plus an older supported release. + splunk-version: ["9.4", "9.2"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -71,6 +77,7 @@ jobs: - name: Start Splunk run: | docker run -d --name splunk -p 8089:8089 \ + -e SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com \ -e SPLUNK_START_ARGS=--accept-license \ -e SPLUNK_PASSWORD="$SPLUNK_PASSWORD" \ splunk/splunk:${{ matrix.splunk-version }} From 74c4ba93dba1b36202917831faa6e16833e7ba96 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:18 -0400 Subject: [PATCH 11/46] ci: hard timeouts and manual-only Splunk integration (cost control) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db698f..099802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -11,6 +12,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -41,16 +43,20 @@ jobs: run: .venv/bin/pytest # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # Informational (not a required check): the unit `check` job above is the gate. - # KV Store and the bundled MongoDB run natively on this x86 runner, so the full - # suite works here (on Apple Silicon, boot the container with KV Store disabled). + # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # it runs ONLY on manual dispatch -- never on push or pull_request -- and is + # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. + # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: - # Current plus an older supported release. - splunk-version: ["latest", "9.2"] + # Pinned (not `latest`): a floating tag silently jumps major versions and + # breaks boot. Current plus an older supported release. + splunk-version: ["9.4", "9.2"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -71,6 +77,7 @@ jobs: - name: Start Splunk run: | docker run -d --name splunk -p 8089:8089 \ + -e SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com \ -e SPLUNK_START_ARGS=--accept-license \ -e SPLUNK_PASSWORD="$SPLUNK_PASSWORD" \ splunk/splunk:${{ matrix.splunk-version }} From b71d7f1aa7ab72d9b328a0eeaab27929605b48ee Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:02:30 -0400 Subject: [PATCH 12/46] ci: hard timeouts and manual-only Splunk integration (cost control) --- .github/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db698f..099802b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: permissions: contents: read @@ -11,6 +12,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -41,16 +43,20 @@ jobs: run: .venv/bin/pytest # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # Informational (not a required check): the unit `check` job above is the gate. - # KV Store and the bundled MongoDB run natively on this x86 runner, so the full - # suite works here (on Apple Silicon, boot the container with KV Store disabled). + # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # it runs ONLY on manual dispatch -- never on push or pull_request -- and is + # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. + # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: - # Current plus an older supported release. - splunk-version: ["latest", "9.2"] + # Pinned (not `latest`): a floating tag silently jumps major versions and + # breaks boot. Current plus an older supported release. + splunk-version: ["9.4", "9.2"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -71,6 +77,7 @@ jobs: - name: Start Splunk run: | docker run -d --name splunk -p 8089:8089 \ + -e SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com \ -e SPLUNK_START_ARGS=--accept-license \ -e SPLUNK_PASSWORD="$SPLUNK_PASSWORD" \ splunk/splunk:${{ matrix.splunk-version }} From 16da4f6699c9b73c6f9f43c9c0f77b09c08eb3e0 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:29:21 -0400 Subject: [PATCH 13/46] ci: realign to main (Splunk 10.x latest + session-key, drop mint-token) --- .github/scripts/get-session-key.sh | 20 ++++++++++++++++++++ .github/scripts/mint-token.sh | 22 ---------------------- .github/scripts/wait-for-splunk.sh | 13 ++++++++++--- .github/workflows/ci.yml | 20 ++++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/get-session-key.sh delete mode 100644 .github/scripts/mint-token.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh new file mode 100644 index 0000000..69f8ea6 --- /dev/null +++ b/.github/scripts/get-session-key.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub +# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` +# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). +# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. +set -euo pipefail + +key=$(curl -ksf https://localhost:8089/services/auth/login \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${SPLUNK_PASSWORD}" \ + -d output_mode=json \ + | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') + +if [ -z "${key}" ]; then + echo "session-key login failed; empty sessionKey" >&2 + exit 1 +fi + +echo "::add-mask::${key}" +echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/mint-token.sh b/.github/scripts/mint-token.sh deleted file mode 100644 index 42c56cd..0000000 --- a/.github/scripts/mint-token.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Enable token authentication (off by default) on the Dockerized Splunk, then mint -# an admin token and write it (masked) to the GitHub step output as `token`. -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/admin/token-auth/tokens_auth \ - -d disabled=false >/dev/null || true - -token=$(curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/authorization/tokens \ - -d name=admin -d audience=ci -d output_mode=json | - python -c 'import sys, json; print(json.load(sys.stdin)["entry"][0]["content"]["token"])') - -if [ -z "${token}" ]; then - echo "token mint failed" >&2 - exit 1 -fi - -echo "::add-mask::${token}" -echo "token=${token}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/wait-for-splunk.sh b/.github/scripts/wait-for-splunk.sh index da46f45..b601f88 100644 --- a/.github/scripts/wait-for-splunk.sh +++ b/.github/scripts/wait-for-splunk.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Poll splunkd's REST management port until it answers, or fail after ~5 minutes. +# Poll splunkd's REST management port until it answers, or fail after ~6 minutes. +# Splunk 10.x boots KV Store (MongoDB) on start, so allow a generous window. # Expects SPLUNK_PASSWORD in the environment and a container named `splunk`. set -euo pipefail -for _ in $(seq 1 60); do +for _ in $(seq 1 72); do + # Bail early with logs if the container has already died. + if [ "$(docker inspect -f '{{.State.Running}}' splunk 2>/dev/null)" != "true" ]; then + echo "splunk container is not running" >&2 + docker logs splunk 2>&1 | tail -100 >&2 || true + exit 1 + fi if curl -ksf -u "admin:${SPLUNK_PASSWORD}" \ https://localhost:8089/services/server/info >/dev/null; then echo "splunkd is up" @@ -13,5 +20,5 @@ for _ in $(seq 1 60); do done echo "splunkd did not become ready" >&2 -docker logs splunk | tail -100 +docker logs splunk 2>&1 | tail -100 >&2 exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099802b..0ddf94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,21 +42,21 @@ jobs: - name: Test run: .venv/bin/pytest - # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. + # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - # Pinned (not `latest`): a floating tag silently jumps major versions and - # breaks boot. Current plus an older supported release. - splunk-version: ["9.4", "9.2"] + # We only support Splunk 10.x; `latest` tracks the current 10.x release. + # Splunk 10 requires accepting the general terms (SPLUNK_GENERAL_TERMS below). + splunk-version: ["latest"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -85,15 +85,15 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Mint a token - id: token - run: bash .github/scripts/mint-token.sh + - name: Get a session key + id: auth + run: bash .github/scripts/get-session-key.sh - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_TOKEN: ${{ steps.token.outputs.token }} + SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 1b4ff3884dc732274d6628bc50d81415d24ee659 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:29:22 -0400 Subject: [PATCH 14/46] ci: realign to main (Splunk 10.x latest + session-key, drop mint-token) --- .github/scripts/get-session-key.sh | 20 ++++++++++++++++++++ .github/scripts/mint-token.sh | 22 ---------------------- .github/scripts/wait-for-splunk.sh | 13 ++++++++++--- .github/workflows/ci.yml | 20 ++++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/get-session-key.sh delete mode 100644 .github/scripts/mint-token.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh new file mode 100644 index 0000000..69f8ea6 --- /dev/null +++ b/.github/scripts/get-session-key.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub +# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` +# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). +# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. +set -euo pipefail + +key=$(curl -ksf https://localhost:8089/services/auth/login \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${SPLUNK_PASSWORD}" \ + -d output_mode=json \ + | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') + +if [ -z "${key}" ]; then + echo "session-key login failed; empty sessionKey" >&2 + exit 1 +fi + +echo "::add-mask::${key}" +echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/mint-token.sh b/.github/scripts/mint-token.sh deleted file mode 100644 index 42c56cd..0000000 --- a/.github/scripts/mint-token.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Enable token authentication (off by default) on the Dockerized Splunk, then mint -# an admin token and write it (masked) to the GitHub step output as `token`. -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/admin/token-auth/tokens_auth \ - -d disabled=false >/dev/null || true - -token=$(curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/authorization/tokens \ - -d name=admin -d audience=ci -d output_mode=json | - python -c 'import sys, json; print(json.load(sys.stdin)["entry"][0]["content"]["token"])') - -if [ -z "${token}" ]; then - echo "token mint failed" >&2 - exit 1 -fi - -echo "::add-mask::${token}" -echo "token=${token}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/wait-for-splunk.sh b/.github/scripts/wait-for-splunk.sh index da46f45..b601f88 100644 --- a/.github/scripts/wait-for-splunk.sh +++ b/.github/scripts/wait-for-splunk.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Poll splunkd's REST management port until it answers, or fail after ~5 minutes. +# Poll splunkd's REST management port until it answers, or fail after ~6 minutes. +# Splunk 10.x boots KV Store (MongoDB) on start, so allow a generous window. # Expects SPLUNK_PASSWORD in the environment and a container named `splunk`. set -euo pipefail -for _ in $(seq 1 60); do +for _ in $(seq 1 72); do + # Bail early with logs if the container has already died. + if [ "$(docker inspect -f '{{.State.Running}}' splunk 2>/dev/null)" != "true" ]; then + echo "splunk container is not running" >&2 + docker logs splunk 2>&1 | tail -100 >&2 || true + exit 1 + fi if curl -ksf -u "admin:${SPLUNK_PASSWORD}" \ https://localhost:8089/services/server/info >/dev/null; then echo "splunkd is up" @@ -13,5 +20,5 @@ for _ in $(seq 1 60); do done echo "splunkd did not become ready" >&2 -docker logs splunk | tail -100 +docker logs splunk 2>&1 | tail -100 >&2 exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099802b..0ddf94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,21 +42,21 @@ jobs: - name: Test run: .venv/bin/pytest - # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. + # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - # Pinned (not `latest`): a floating tag silently jumps major versions and - # breaks boot. Current plus an older supported release. - splunk-version: ["9.4", "9.2"] + # We only support Splunk 10.x; `latest` tracks the current 10.x release. + # Splunk 10 requires accepting the general terms (SPLUNK_GENERAL_TERMS below). + splunk-version: ["latest"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -85,15 +85,15 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Mint a token - id: token - run: bash .github/scripts/mint-token.sh + - name: Get a session key + id: auth + run: bash .github/scripts/get-session-key.sh - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_TOKEN: ${{ steps.token.outputs.token }} + SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 9224bd5ddda213a03d02ad0ce953d75d8aa0e76c Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:29:24 -0400 Subject: [PATCH 15/46] ci: realign to main (Splunk 10.x latest + session-key, drop mint-token) --- .github/scripts/get-session-key.sh | 20 ++++++++++++++++++++ .github/scripts/mint-token.sh | 22 ---------------------- .github/scripts/wait-for-splunk.sh | 13 ++++++++++--- .github/workflows/ci.yml | 20 ++++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/get-session-key.sh delete mode 100644 .github/scripts/mint-token.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh new file mode 100644 index 0000000..69f8ea6 --- /dev/null +++ b/.github/scripts/get-session-key.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub +# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` +# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). +# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. +set -euo pipefail + +key=$(curl -ksf https://localhost:8089/services/auth/login \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${SPLUNK_PASSWORD}" \ + -d output_mode=json \ + | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') + +if [ -z "${key}" ]; then + echo "session-key login failed; empty sessionKey" >&2 + exit 1 +fi + +echo "::add-mask::${key}" +echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/mint-token.sh b/.github/scripts/mint-token.sh deleted file mode 100644 index 42c56cd..0000000 --- a/.github/scripts/mint-token.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Enable token authentication (off by default) on the Dockerized Splunk, then mint -# an admin token and write it (masked) to the GitHub step output as `token`. -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/admin/token-auth/tokens_auth \ - -d disabled=false >/dev/null || true - -token=$(curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/authorization/tokens \ - -d name=admin -d audience=ci -d output_mode=json | - python -c 'import sys, json; print(json.load(sys.stdin)["entry"][0]["content"]["token"])') - -if [ -z "${token}" ]; then - echo "token mint failed" >&2 - exit 1 -fi - -echo "::add-mask::${token}" -echo "token=${token}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/wait-for-splunk.sh b/.github/scripts/wait-for-splunk.sh index da46f45..b601f88 100644 --- a/.github/scripts/wait-for-splunk.sh +++ b/.github/scripts/wait-for-splunk.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Poll splunkd's REST management port until it answers, or fail after ~5 minutes. +# Poll splunkd's REST management port until it answers, or fail after ~6 minutes. +# Splunk 10.x boots KV Store (MongoDB) on start, so allow a generous window. # Expects SPLUNK_PASSWORD in the environment and a container named `splunk`. set -euo pipefail -for _ in $(seq 1 60); do +for _ in $(seq 1 72); do + # Bail early with logs if the container has already died. + if [ "$(docker inspect -f '{{.State.Running}}' splunk 2>/dev/null)" != "true" ]; then + echo "splunk container is not running" >&2 + docker logs splunk 2>&1 | tail -100 >&2 || true + exit 1 + fi if curl -ksf -u "admin:${SPLUNK_PASSWORD}" \ https://localhost:8089/services/server/info >/dev/null; then echo "splunkd is up" @@ -13,5 +20,5 @@ for _ in $(seq 1 60); do done echo "splunkd did not become ready" >&2 -docker logs splunk | tail -100 +docker logs splunk 2>&1 | tail -100 >&2 exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099802b..0ddf94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,21 +42,21 @@ jobs: - name: Test run: .venv/bin/pytest - # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. + # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - # Pinned (not `latest`): a floating tag silently jumps major versions and - # breaks boot. Current plus an older supported release. - splunk-version: ["9.4", "9.2"] + # We only support Splunk 10.x; `latest` tracks the current 10.x release. + # Splunk 10 requires accepting the general terms (SPLUNK_GENERAL_TERMS below). + splunk-version: ["latest"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -85,15 +85,15 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Mint a token - id: token - run: bash .github/scripts/mint-token.sh + - name: Get a session key + id: auth + run: bash .github/scripts/get-session-key.sh - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_TOKEN: ${{ steps.token.outputs.token }} + SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 6398f4ce90119b72986e9b514ee24f6869815989 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:29:25 -0400 Subject: [PATCH 16/46] ci: realign to main (Splunk 10.x latest + session-key, drop mint-token) --- .github/scripts/get-session-key.sh | 20 ++++++++++++++++++++ .github/scripts/mint-token.sh | 22 ---------------------- .github/scripts/wait-for-splunk.sh | 13 ++++++++++--- .github/workflows/ci.yml | 20 ++++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/get-session-key.sh delete mode 100644 .github/scripts/mint-token.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh new file mode 100644 index 0000000..69f8ea6 --- /dev/null +++ b/.github/scripts/get-session-key.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub +# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` +# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). +# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. +set -euo pipefail + +key=$(curl -ksf https://localhost:8089/services/auth/login \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${SPLUNK_PASSWORD}" \ + -d output_mode=json \ + | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') + +if [ -z "${key}" ]; then + echo "session-key login failed; empty sessionKey" >&2 + exit 1 +fi + +echo "::add-mask::${key}" +echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/mint-token.sh b/.github/scripts/mint-token.sh deleted file mode 100644 index 42c56cd..0000000 --- a/.github/scripts/mint-token.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Enable token authentication (off by default) on the Dockerized Splunk, then mint -# an admin token and write it (masked) to the GitHub step output as `token`. -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/admin/token-auth/tokens_auth \ - -d disabled=false >/dev/null || true - -token=$(curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/authorization/tokens \ - -d name=admin -d audience=ci -d output_mode=json | - python -c 'import sys, json; print(json.load(sys.stdin)["entry"][0]["content"]["token"])') - -if [ -z "${token}" ]; then - echo "token mint failed" >&2 - exit 1 -fi - -echo "::add-mask::${token}" -echo "token=${token}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/wait-for-splunk.sh b/.github/scripts/wait-for-splunk.sh index da46f45..b601f88 100644 --- a/.github/scripts/wait-for-splunk.sh +++ b/.github/scripts/wait-for-splunk.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Poll splunkd's REST management port until it answers, or fail after ~5 minutes. +# Poll splunkd's REST management port until it answers, or fail after ~6 minutes. +# Splunk 10.x boots KV Store (MongoDB) on start, so allow a generous window. # Expects SPLUNK_PASSWORD in the environment and a container named `splunk`. set -euo pipefail -for _ in $(seq 1 60); do +for _ in $(seq 1 72); do + # Bail early with logs if the container has already died. + if [ "$(docker inspect -f '{{.State.Running}}' splunk 2>/dev/null)" != "true" ]; then + echo "splunk container is not running" >&2 + docker logs splunk 2>&1 | tail -100 >&2 || true + exit 1 + fi if curl -ksf -u "admin:${SPLUNK_PASSWORD}" \ https://localhost:8089/services/server/info >/dev/null; then echo "splunkd is up" @@ -13,5 +20,5 @@ for _ in $(seq 1 60); do done echo "splunkd did not become ready" >&2 -docker logs splunk | tail -100 +docker logs splunk 2>&1 | tail -100 >&2 exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099802b..0ddf94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,21 +42,21 @@ jobs: - name: Test run: .venv/bin/pytest - # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. + # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - # Pinned (not `latest`): a floating tag silently jumps major versions and - # breaks boot. Current plus an older supported release. - splunk-version: ["9.4", "9.2"] + # We only support Splunk 10.x; `latest` tracks the current 10.x release. + # Splunk 10 requires accepting the general terms (SPLUNK_GENERAL_TERMS below). + splunk-version: ["latest"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -85,15 +85,15 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Mint a token - id: token - run: bash .github/scripts/mint-token.sh + - name: Get a session key + id: auth + run: bash .github/scripts/get-session-key.sh - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_TOKEN: ${{ steps.token.outputs.token }} + SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 3574ea2d0a26e58ffbe4685da7f6c1110eb03a5b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:29:27 -0400 Subject: [PATCH 17/46] ci: realign to main (Splunk 10.x latest + session-key, drop mint-token) --- .github/scripts/get-session-key.sh | 20 ++++++++++++++++++++ .github/scripts/mint-token.sh | 22 ---------------------- .github/scripts/wait-for-splunk.sh | 13 ++++++++++--- .github/workflows/ci.yml | 20 ++++++++++---------- 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 .github/scripts/get-session-key.sh delete mode 100644 .github/scripts/mint-token.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh new file mode 100644 index 0000000..69f8ea6 --- /dev/null +++ b/.github/scripts/get-session-key.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub +# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` +# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). +# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. +set -euo pipefail + +key=$(curl -ksf https://localhost:8089/services/auth/login \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${SPLUNK_PASSWORD}" \ + -d output_mode=json \ + | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') + +if [ -z "${key}" ]; then + echo "session-key login failed; empty sessionKey" >&2 + exit 1 +fi + +echo "::add-mask::${key}" +echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/mint-token.sh b/.github/scripts/mint-token.sh deleted file mode 100644 index 42c56cd..0000000 --- a/.github/scripts/mint-token.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Enable token authentication (off by default) on the Dockerized Splunk, then mint -# an admin token and write it (masked) to the GitHub step output as `token`. -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/admin/token-auth/tokens_auth \ - -d disabled=false >/dev/null || true - -token=$(curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/authorization/tokens \ - -d name=admin -d audience=ci -d output_mode=json | - python -c 'import sys, json; print(json.load(sys.stdin)["entry"][0]["content"]["token"])') - -if [ -z "${token}" ]; then - echo "token mint failed" >&2 - exit 1 -fi - -echo "::add-mask::${token}" -echo "token=${token}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/wait-for-splunk.sh b/.github/scripts/wait-for-splunk.sh index da46f45..b601f88 100644 --- a/.github/scripts/wait-for-splunk.sh +++ b/.github/scripts/wait-for-splunk.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Poll splunkd's REST management port until it answers, or fail after ~5 minutes. +# Poll splunkd's REST management port until it answers, or fail after ~6 minutes. +# Splunk 10.x boots KV Store (MongoDB) on start, so allow a generous window. # Expects SPLUNK_PASSWORD in the environment and a container named `splunk`. set -euo pipefail -for _ in $(seq 1 60); do +for _ in $(seq 1 72); do + # Bail early with logs if the container has already died. + if [ "$(docker inspect -f '{{.State.Running}}' splunk 2>/dev/null)" != "true" ]; then + echo "splunk container is not running" >&2 + docker logs splunk 2>&1 | tail -100 >&2 || true + exit 1 + fi if curl -ksf -u "admin:${SPLUNK_PASSWORD}" \ https://localhost:8089/services/server/info >/dev/null; then echo "splunkd is up" @@ -13,5 +20,5 @@ for _ in $(seq 1 60); do done echo "splunkd did not become ready" >&2 -docker logs splunk | tail -100 +docker logs splunk 2>&1 | tail -100 >&2 exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099802b..0ddf94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,21 +42,21 @@ jobs: - name: Test run: .venv/bin/pytest - # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. + # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - # Pinned (not `latest`): a floating tag silently jumps major versions and - # breaks boot. Current plus an older supported release. - splunk-version: ["9.4", "9.2"] + # We only support Splunk 10.x; `latest` tracks the current 10.x release. + # Splunk 10 requires accepting the general terms (SPLUNK_GENERAL_TERMS below). + splunk-version: ["latest"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -85,15 +85,15 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Mint a token - id: token - run: bash .github/scripts/mint-token.sh + - name: Get a session key + id: auth + run: bash .github/scripts/get-session-key.sh - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_TOKEN: ${{ steps.token.outputs.token }} + SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 103671d0925bfc65e7724ea9671613479a934a7a Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:17 -0400 Subject: [PATCH 18/46] ci: realign CI to main (login in the client, no auth script) --- .github/scripts/get-session-key.sh | 20 -------------------- .github/workflows/ci.yml | 9 ++++----- 2 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 .github/scripts/get-session-key.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh deleted file mode 100644 index 69f8ea6..0000000 --- a/.github/scripts/get-session-key.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub -# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` -# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -key=$(curl -ksf https://localhost:8089/services/auth/login \ - --data-urlencode "username=admin" \ - --data-urlencode "password=${SPLUNK_PASSWORD}" \ - -d output_mode=json \ - | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') - -if [ -z "${key}" ]; then - echo "session-key login failed; empty sessionKey" >&2 - exit 1 -fi - -echo "::add-mask::${key}" -echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ddf94e..ca1f833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,15 +85,14 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Get a session key - id: auth - run: bash .github/scripts/get-session-key.sh - + # No auth script: the CLI exchanges SPLUNK_USERNAME/SPLUNK_PASSWORD for a + # session key itself (via /services/auth/login) when no token is set. - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} + SPLUNK_USERNAME: admin + SPLUNK_PASSWORD: ${{ env.SPLUNK_PASSWORD }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 6a3d8ada85b8d2eab7053597e0b1094f278f5c25 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:19 -0400 Subject: [PATCH 19/46] ci: realign CI to main (login in the client, no auth script) --- .github/scripts/get-session-key.sh | 20 -------------------- .github/workflows/ci.yml | 9 ++++----- 2 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 .github/scripts/get-session-key.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh deleted file mode 100644 index 69f8ea6..0000000 --- a/.github/scripts/get-session-key.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub -# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` -# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -key=$(curl -ksf https://localhost:8089/services/auth/login \ - --data-urlencode "username=admin" \ - --data-urlencode "password=${SPLUNK_PASSWORD}" \ - -d output_mode=json \ - | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') - -if [ -z "${key}" ]; then - echo "session-key login failed; empty sessionKey" >&2 - exit 1 -fi - -echo "::add-mask::${key}" -echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ddf94e..ca1f833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,15 +85,14 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Get a session key - id: auth - run: bash .github/scripts/get-session-key.sh - + # No auth script: the CLI exchanges SPLUNK_USERNAME/SPLUNK_PASSWORD for a + # session key itself (via /services/auth/login) when no token is set. - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} + SPLUNK_USERNAME: admin + SPLUNK_PASSWORD: ${{ env.SPLUNK_PASSWORD }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 85bc8dcc268107f35e456b79b19eede2003326ad Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:20 -0400 Subject: [PATCH 20/46] ci: realign CI to main (login in the client, no auth script) --- .github/scripts/get-session-key.sh | 20 -------------------- .github/workflows/ci.yml | 9 ++++----- 2 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 .github/scripts/get-session-key.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh deleted file mode 100644 index 69f8ea6..0000000 --- a/.github/scripts/get-session-key.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub -# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` -# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -key=$(curl -ksf https://localhost:8089/services/auth/login \ - --data-urlencode "username=admin" \ - --data-urlencode "password=${SPLUNK_PASSWORD}" \ - -d output_mode=json \ - | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') - -if [ -z "${key}" ]; then - echo "session-key login failed; empty sessionKey" >&2 - exit 1 -fi - -echo "::add-mask::${key}" -echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ddf94e..ca1f833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,15 +85,14 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Get a session key - id: auth - run: bash .github/scripts/get-session-key.sh - + # No auth script: the CLI exchanges SPLUNK_USERNAME/SPLUNK_PASSWORD for a + # session key itself (via /services/auth/login) when no token is set. - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} + SPLUNK_USERNAME: admin + SPLUNK_PASSWORD: ${{ env.SPLUNK_PASSWORD }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 50a38b568c9d6fa657983048734f5ea6df0bad88 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:22 -0400 Subject: [PATCH 21/46] ci: realign CI to main (login in the client, no auth script) --- .github/scripts/get-session-key.sh | 20 -------------------- .github/workflows/ci.yml | 9 ++++----- 2 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 .github/scripts/get-session-key.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh deleted file mode 100644 index 69f8ea6..0000000 --- a/.github/scripts/get-session-key.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub -# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` -# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -key=$(curl -ksf https://localhost:8089/services/auth/login \ - --data-urlencode "username=admin" \ - --data-urlencode "password=${SPLUNK_PASSWORD}" \ - -d output_mode=json \ - | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') - -if [ -z "${key}" ]; then - echo "session-key login failed; empty sessionKey" >&2 - exit 1 -fi - -echo "::add-mask::${key}" -echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ddf94e..ca1f833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,15 +85,14 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Get a session key - id: auth - run: bash .github/scripts/get-session-key.sh - + # No auth script: the CLI exchanges SPLUNK_USERNAME/SPLUNK_PASSWORD for a + # session key itself (via /services/auth/login) when no token is set. - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} + SPLUNK_USERNAME: admin + SPLUNK_PASSWORD: ${{ env.SPLUNK_PASSWORD }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From c27d0a6a1359b2b2639c6720a62c375337333dc4 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:23 -0400 Subject: [PATCH 22/46] ci: realign CI to main (login in the client, no auth script) --- .github/scripts/get-session-key.sh | 20 -------------------- .github/workflows/ci.yml | 9 ++++----- 2 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 .github/scripts/get-session-key.sh diff --git a/.github/scripts/get-session-key.sh b/.github/scripts/get-session-key.sh deleted file mode 100644 index 69f8ea6..0000000 --- a/.github/scripts/get-session-key.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Log in to the Dockerized Splunk and write the session key (masked) to the GitHub -# step output as `session_key`. The CLI sends it as `Authorization: Splunk ` -# (the simpler alternative to a JWT — no token-auth enablement, no JWT minting). -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -key=$(curl -ksf https://localhost:8089/services/auth/login \ - --data-urlencode "username=admin" \ - --data-urlencode "password=${SPLUNK_PASSWORD}" \ - -d output_mode=json \ - | python3 -c 'import sys, json; print(json.load(sys.stdin)["sessionKey"])') - -if [ -z "${key}" ]; then - echo "session-key login failed; empty sessionKey" >&2 - exit 1 -fi - -echo "::add-mask::${key}" -echo "session_key=${key}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ddf94e..ca1f833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,15 +85,14 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Get a session key - id: auth - run: bash .github/scripts/get-session-key.sh - + # No auth script: the CLI exchanges SPLUNK_USERNAME/SPLUNK_PASSWORD for a + # session key itself (via /services/auth/login) when no token is set. - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_SESSION_KEY: ${{ steps.auth.outputs.session_key }} + SPLUNK_USERNAME: admin + SPLUNK_PASSWORD: ${{ env.SPLUNK_PASSWORD }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From bf3669b80bfd8b88c39e41f1b981876211308c26 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:37 -0400 Subject: [PATCH 23/46] ci: realign CI to main (login in the client, no auth script) --- .github/scripts/mint-token.sh | 22 ---------------------- .github/scripts/wait-for-splunk.sh | 13 ++++++++++--- .github/workflows/ci.yml | 21 ++++++++++----------- 3 files changed, 20 insertions(+), 36 deletions(-) delete mode 100644 .github/scripts/mint-token.sh diff --git a/.github/scripts/mint-token.sh b/.github/scripts/mint-token.sh deleted file mode 100644 index 42c56cd..0000000 --- a/.github/scripts/mint-token.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Enable token authentication (off by default) on the Dockerized Splunk, then mint -# an admin token and write it (masked) to the GitHub step output as `token`. -# Expects SPLUNK_PASSWORD and GITHUB_OUTPUT in the environment. -set -euo pipefail - -curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/admin/token-auth/tokens_auth \ - -d disabled=false >/dev/null || true - -token=$(curl -ksf -u "admin:${SPLUNK_PASSWORD}" -X POST \ - https://localhost:8089/services/authorization/tokens \ - -d name=admin -d audience=ci -d output_mode=json | - python -c 'import sys, json; print(json.load(sys.stdin)["entry"][0]["content"]["token"])') - -if [ -z "${token}" ]; then - echo "token mint failed" >&2 - exit 1 -fi - -echo "::add-mask::${token}" -echo "token=${token}" >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/wait-for-splunk.sh b/.github/scripts/wait-for-splunk.sh index da46f45..b601f88 100644 --- a/.github/scripts/wait-for-splunk.sh +++ b/.github/scripts/wait-for-splunk.sh @@ -1,9 +1,16 @@ #!/usr/bin/env bash -# Poll splunkd's REST management port until it answers, or fail after ~5 minutes. +# Poll splunkd's REST management port until it answers, or fail after ~6 minutes. +# Splunk 10.x boots KV Store (MongoDB) on start, so allow a generous window. # Expects SPLUNK_PASSWORD in the environment and a container named `splunk`. set -euo pipefail -for _ in $(seq 1 60); do +for _ in $(seq 1 72); do + # Bail early with logs if the container has already died. + if [ "$(docker inspect -f '{{.State.Running}}' splunk 2>/dev/null)" != "true" ]; then + echo "splunk container is not running" >&2 + docker logs splunk 2>&1 | tail -100 >&2 || true + exit 1 + fi if curl -ksf -u "admin:${SPLUNK_PASSWORD}" \ https://localhost:8089/services/server/info >/dev/null; then echo "splunkd is up" @@ -13,5 +20,5 @@ for _ in $(seq 1 60); do done echo "splunkd did not become ready" >&2 -docker logs splunk | tail -100 +docker logs splunk 2>&1 | tail -100 >&2 exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099802b..ca1f833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,21 +42,21 @@ jobs: - name: Test run: .venv/bin/pytest - # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise. - # COST CONTROL: this is expensive (boots two Splunk containers, ~6 min each), so + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. + # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is # hard-capped by timeout-minutes. It is informational; the `check` job is the gate. # Run it on demand from the Actions tab ("Run workflow") when you want a live check. integration: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - # Pinned (not `latest`): a floating tag silently jumps major versions and - # breaks boot. Current plus an older supported release. - splunk-version: ["9.4", "9.2"] + # We only support Splunk 10.x; `latest` tracks the current 10.x release. + # Splunk 10 requires accepting the general terms (SPLUNK_GENERAL_TERMS below). + splunk-version: ["latest"] env: # A throwaway admin password for the ephemeral container. Override with the # SPLUNK_TEST_PASSWORD secret if org policy requires it. @@ -85,15 +85,14 @@ jobs: - name: Wait for splunkd run: bash .github/scripts/wait-for-splunk.sh - - name: Mint a token - id: token - run: bash .github/scripts/mint-token.sh - + # No auth script: the CLI exchanges SPLUNK_USERNAME/SPLUNK_PASSWORD for a + # session key itself (via /services/auth/login) when no token is set. - name: Integration tests env: SPLUNK_INTEGRATION_TEST: "true" SPLUNK_URL: https://localhost:8089 - SPLUNK_TOKEN: ${{ steps.token.outputs.token }} + SPLUNK_USERNAME: admin + SPLUNK_PASSWORD: ${{ env.SPLUNK_PASSWORD }} SPLUNK_VERIFY: "false" run: .venv/bin/pytest -m integration -v From 0b36a453d9357896e28406be82f1697d9b100003 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:02:37 -0400 Subject: [PATCH 24/46] refactor: thin the resource specs (propagate registry trim) --- src/vct_splunk/commands/registry.py | 101 ++-------------------------- tests/unit/test_factory_cmd.py | 20 +++--- 2 files changed, 15 insertions(+), 106 deletions(-) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index cefbe89..d52e356 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -5,9 +5,11 @@ command group per spec via :func:`vct_splunk.commands.factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. -Field ``key`` values are the official Splunk REST form-field names, so the surface -stays familiar to a Splunk admin. Any field not given a typed option is still -reachable through the generic ``--set KEY=VALUE`` escape hatch. +Specs are intentionally thin: a path, help text, and the verbs/flags that shape +the command surface. Settings flow through the generic ``--set KEY=VALUE`` escape +hatch, where ``KEY`` is the official Splunk REST form-field name, and Splunk +validates them server-side. The lone exception is the ``user`` password, which +stays a typed secret field so it is read from the environment, never a flag. """ from __future__ import annotations @@ -18,12 +20,6 @@ # (Plain CRUD is the Spec default, so it does not need a named constant.) _CRUD_TOGGLE = ("list", "get", "create", "update", "delete", "enable", "disable") -# Fields shared by most data inputs. -_INDEX_SOURCETYPE = ( - Field("index", key="index", help="Target index."), - Field("sourcetype", key="sourcetype", help="Source type."), -) - # --- Access (#4) ------------------------------------------------------------- USER = Spec( @@ -37,55 +33,13 @@ secret=True, help="Initial password (from $SPLUNK_USER_PASSWORD or a prompt; never a flag).", ), - Field("role", key="roles", multi=True, help="Role to assign (repeatable)."), - Field("email", key="email", help="Email address."), - Field("realname", key="realname", help="Full name."), - Field("default_app", key="defaultApp", help="Default app on login."), ), - out_map={ - "realname": "real_name", - "email": "email", - "roles": "roles", - "defaultApp": "default_app", - "type": "auth_type", - }, ) ROLE = Spec( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", - fields=( - Field( - "capability", key="capabilities", multi=True, help="Capability to grant (repeatable)." - ), - Field( - "imported_role", key="imported_roles", multi=True, help="Role to inherit (repeatable)." - ), - Field( - "search_index", - key="srchIndexesAllowed", - multi=True, - help="Allowed search index (repeatable).", - ), - Field( - "default_index", - key="srchIndexesDefault", - multi=True, - help="Default search index (repeatable).", - ), - Field( - "search_quota", - key="srchJobsQuota", - type="int", - help="Concurrent historical search quota.", - ), - ), - out_map={ - "imported_roles": "imported_roles", - "srchIndexesAllowed": "search_indexes", - "srchJobsQuota": "search_quota", - }, ) CAPABILITY = Spec( @@ -104,13 +58,6 @@ path="/services/data/inputs/monitor", help="File and directory monitor inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("host", key="host", help="Host value for events."), - Field("recursive", key="recursive", type="bool", help="Recurse into subdirectories."), - Field("whitelist", key="whitelist", help="Allowlist regex."), - Field("blacklist", key="blacklist", help="Denylist regex."), - ), ) TCP_INPUT = Spec( @@ -118,10 +65,6 @@ path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) UDP_INPUT = Spec( @@ -129,10 +72,6 @@ path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) SCRIPT_INPUT = Spec( @@ -140,10 +79,6 @@ path="/services/data/inputs/script", help="Scripted inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("interval", key="interval", help="Run interval (seconds or cron)."), - ), ) HEC_TOKEN = Spec( @@ -151,11 +86,6 @@ path="/services/data/inputs/http", help="HTTP Event Collector tokens.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("allowed_index", key="indexes", multi=True, help="Allowed index (repeatable)."), - Field("source", key="source", help="Default source."), - ), ) OUTPUT_SERVER = Spec( @@ -163,7 +93,6 @@ path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, - fields=(Field("method", key="method", help="Routing: clone|balance|autobalance."),), ) OUTPUT_GROUP = Spec( @@ -171,10 +100,6 @@ path="/services/data/outputs/tcp/group", help="Forwarder output groups.", verbs=_CRUD_TOGGLE, - fields=( - Field("server", key="servers", multi=True, help="Member server host:port (repeatable)."), - Field("method", key="method", help="Routing: clone|balance|autobalance."), - ), ) # --- Knowledge objects (#8) -------------------------------------------------- @@ -185,11 +110,6 @@ path="configs/conf-macros", help="Search macros.", namespaced=True, - fields=( - Field("definition", key="definition", help="The macro expansion."), - Field("args", key="args", help="Comma-separated argument names."), - Field("iseval", key="iseval", type="bool", help="Definition is an eval expression."), - ), ) EVENTTYPE = Spec( @@ -197,11 +117,6 @@ path="saved/eventtypes", help="Event types.", namespaced=True, - fields=( - Field("search", key="search", help="The search that defines the event type."), - Field("description", key="description", help="Description."), - Field("priority", key="priority", type="int", help="Priority (1-10)."), - ), ) EXTRACTION = Spec( @@ -209,10 +124,6 @@ path="data/transforms/extractions", help="Field extractions (transforms).", namespaced=True, - fields=( - Field("regex", key="REGEX", help="Extraction regular expression."), - Field("format", key="FORMAT", help="Output format."), - ), ) LOOKUP_DEFINITION = Spec( @@ -220,7 +131,6 @@ path="data/transforms/lookups", help="Lookup definitions (transforms).", namespaced=True, - fields=(Field("filename", key="filename", help="Lookup table file name."),), ) # --- KV Store (#9) ----------------------------------------------------------- @@ -244,7 +154,6 @@ path="/services/messages", help="System bulletin messages.", verbs=("list", "get", "create", "delete"), - fields=(Field("value", key="value", help="Message text."),), ) # --- Apps (#5) --------------------------------------------------------------- diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index 062fd03..8fcaeaa 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -33,10 +33,10 @@ def test_generated_user_create_dry_run_previews(monkeypatch): "user", "create", "alice", - "--role", - "admin", - "--email", - "a@x.com", + "--set", + "roles=admin", + "--set", + "email=a@x.com", "--dry-run", "--output", "json", @@ -51,7 +51,7 @@ def test_generated_user_create_dry_run_previews(monkeypatch): def test_generated_user_create_refuses_without_yes(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -85,7 +85,7 @@ def handler(req: httpx.Request) -> httpx.Response: _patch_client(monkeypatch, handler) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--yes", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--yes", "--output", "json"] ) assert result.exit_code == 0 assert "password=hunter2" in seen["body"] # secret pulled from env, sent on the wire @@ -94,7 +94,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_add_alias_on_generated_group(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "add", "alice", "--role", "admin", "--dry-run", "--output", "json"] + cli, ["user", "add", "alice", "--set", "roles=admin", "--dry-run", "--output", "json"] ) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -105,7 +105,7 @@ def test_namespaced_generated_group_requires_app(monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) # macro is namespaced -> a write without --app must refuse (never target 'search'). result = CliRunner().invoke( - cli, ["macro", "create", "m1", "--definition", "x", "--dry-run", "--output", "json"] + cli, ["macro", "create", "m1", "--set", "definition=x", "--dry-run", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -119,8 +119,8 @@ def test_namespaced_generated_group_previews_app_namespace(monkeypatch): "macro", "create", "m1", - "--definition", - "x", + "--set", + "definition=x", "--app", "my_app", "--dry-run", From 9063bb8395c5607a4cf49d2ecce825555a8d32f5 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:02:39 -0400 Subject: [PATCH 25/46] refactor: thin the resource specs (propagate registry trim) --- src/vct_splunk/commands/registry.py | 101 ++-------------------------- tests/unit/test_factory_cmd.py | 20 +++--- 2 files changed, 15 insertions(+), 106 deletions(-) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index cefbe89..d52e356 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -5,9 +5,11 @@ command group per spec via :func:`vct_splunk.commands.factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. -Field ``key`` values are the official Splunk REST form-field names, so the surface -stays familiar to a Splunk admin. Any field not given a typed option is still -reachable through the generic ``--set KEY=VALUE`` escape hatch. +Specs are intentionally thin: a path, help text, and the verbs/flags that shape +the command surface. Settings flow through the generic ``--set KEY=VALUE`` escape +hatch, where ``KEY`` is the official Splunk REST form-field name, and Splunk +validates them server-side. The lone exception is the ``user`` password, which +stays a typed secret field so it is read from the environment, never a flag. """ from __future__ import annotations @@ -18,12 +20,6 @@ # (Plain CRUD is the Spec default, so it does not need a named constant.) _CRUD_TOGGLE = ("list", "get", "create", "update", "delete", "enable", "disable") -# Fields shared by most data inputs. -_INDEX_SOURCETYPE = ( - Field("index", key="index", help="Target index."), - Field("sourcetype", key="sourcetype", help="Source type."), -) - # --- Access (#4) ------------------------------------------------------------- USER = Spec( @@ -37,55 +33,13 @@ secret=True, help="Initial password (from $SPLUNK_USER_PASSWORD or a prompt; never a flag).", ), - Field("role", key="roles", multi=True, help="Role to assign (repeatable)."), - Field("email", key="email", help="Email address."), - Field("realname", key="realname", help="Full name."), - Field("default_app", key="defaultApp", help="Default app on login."), ), - out_map={ - "realname": "real_name", - "email": "email", - "roles": "roles", - "defaultApp": "default_app", - "type": "auth_type", - }, ) ROLE = Spec( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", - fields=( - Field( - "capability", key="capabilities", multi=True, help="Capability to grant (repeatable)." - ), - Field( - "imported_role", key="imported_roles", multi=True, help="Role to inherit (repeatable)." - ), - Field( - "search_index", - key="srchIndexesAllowed", - multi=True, - help="Allowed search index (repeatable).", - ), - Field( - "default_index", - key="srchIndexesDefault", - multi=True, - help="Default search index (repeatable).", - ), - Field( - "search_quota", - key="srchJobsQuota", - type="int", - help="Concurrent historical search quota.", - ), - ), - out_map={ - "imported_roles": "imported_roles", - "srchIndexesAllowed": "search_indexes", - "srchJobsQuota": "search_quota", - }, ) CAPABILITY = Spec( @@ -104,13 +58,6 @@ path="/services/data/inputs/monitor", help="File and directory monitor inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("host", key="host", help="Host value for events."), - Field("recursive", key="recursive", type="bool", help="Recurse into subdirectories."), - Field("whitelist", key="whitelist", help="Allowlist regex."), - Field("blacklist", key="blacklist", help="Denylist regex."), - ), ) TCP_INPUT = Spec( @@ -118,10 +65,6 @@ path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) UDP_INPUT = Spec( @@ -129,10 +72,6 @@ path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) SCRIPT_INPUT = Spec( @@ -140,10 +79,6 @@ path="/services/data/inputs/script", help="Scripted inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("interval", key="interval", help="Run interval (seconds or cron)."), - ), ) HEC_TOKEN = Spec( @@ -151,11 +86,6 @@ path="/services/data/inputs/http", help="HTTP Event Collector tokens.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("allowed_index", key="indexes", multi=True, help="Allowed index (repeatable)."), - Field("source", key="source", help="Default source."), - ), ) OUTPUT_SERVER = Spec( @@ -163,7 +93,6 @@ path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, - fields=(Field("method", key="method", help="Routing: clone|balance|autobalance."),), ) OUTPUT_GROUP = Spec( @@ -171,10 +100,6 @@ path="/services/data/outputs/tcp/group", help="Forwarder output groups.", verbs=_CRUD_TOGGLE, - fields=( - Field("server", key="servers", multi=True, help="Member server host:port (repeatable)."), - Field("method", key="method", help="Routing: clone|balance|autobalance."), - ), ) # --- Knowledge objects (#8) -------------------------------------------------- @@ -185,11 +110,6 @@ path="configs/conf-macros", help="Search macros.", namespaced=True, - fields=( - Field("definition", key="definition", help="The macro expansion."), - Field("args", key="args", help="Comma-separated argument names."), - Field("iseval", key="iseval", type="bool", help="Definition is an eval expression."), - ), ) EVENTTYPE = Spec( @@ -197,11 +117,6 @@ path="saved/eventtypes", help="Event types.", namespaced=True, - fields=( - Field("search", key="search", help="The search that defines the event type."), - Field("description", key="description", help="Description."), - Field("priority", key="priority", type="int", help="Priority (1-10)."), - ), ) EXTRACTION = Spec( @@ -209,10 +124,6 @@ path="data/transforms/extractions", help="Field extractions (transforms).", namespaced=True, - fields=( - Field("regex", key="REGEX", help="Extraction regular expression."), - Field("format", key="FORMAT", help="Output format."), - ), ) LOOKUP_DEFINITION = Spec( @@ -220,7 +131,6 @@ path="data/transforms/lookups", help="Lookup definitions (transforms).", namespaced=True, - fields=(Field("filename", key="filename", help="Lookup table file name."),), ) # --- KV Store (#9) ----------------------------------------------------------- @@ -244,7 +154,6 @@ path="/services/messages", help="System bulletin messages.", verbs=("list", "get", "create", "delete"), - fields=(Field("value", key="value", help="Message text."),), ) # --- Apps (#5) --------------------------------------------------------------- diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index 062fd03..8fcaeaa 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -33,10 +33,10 @@ def test_generated_user_create_dry_run_previews(monkeypatch): "user", "create", "alice", - "--role", - "admin", - "--email", - "a@x.com", + "--set", + "roles=admin", + "--set", + "email=a@x.com", "--dry-run", "--output", "json", @@ -51,7 +51,7 @@ def test_generated_user_create_dry_run_previews(monkeypatch): def test_generated_user_create_refuses_without_yes(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -85,7 +85,7 @@ def handler(req: httpx.Request) -> httpx.Response: _patch_client(monkeypatch, handler) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--yes", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--yes", "--output", "json"] ) assert result.exit_code == 0 assert "password=hunter2" in seen["body"] # secret pulled from env, sent on the wire @@ -94,7 +94,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_add_alias_on_generated_group(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "add", "alice", "--role", "admin", "--dry-run", "--output", "json"] + cli, ["user", "add", "alice", "--set", "roles=admin", "--dry-run", "--output", "json"] ) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -105,7 +105,7 @@ def test_namespaced_generated_group_requires_app(monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) # macro is namespaced -> a write without --app must refuse (never target 'search'). result = CliRunner().invoke( - cli, ["macro", "create", "m1", "--definition", "x", "--dry-run", "--output", "json"] + cli, ["macro", "create", "m1", "--set", "definition=x", "--dry-run", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -119,8 +119,8 @@ def test_namespaced_generated_group_previews_app_namespace(monkeypatch): "macro", "create", "m1", - "--definition", - "x", + "--set", + "definition=x", "--app", "my_app", "--dry-run", From 080aa281f77ab75aeb73d5d5735597ea34fdae31 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:02:40 -0400 Subject: [PATCH 26/46] refactor: thin the resource specs (propagate registry trim) --- src/vct_splunk/commands/registry.py | 101 ++-------------------------- tests/unit/test_factory_cmd.py | 20 +++--- 2 files changed, 15 insertions(+), 106 deletions(-) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index cefbe89..d52e356 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -5,9 +5,11 @@ command group per spec via :func:`vct_splunk.commands.factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. -Field ``key`` values are the official Splunk REST form-field names, so the surface -stays familiar to a Splunk admin. Any field not given a typed option is still -reachable through the generic ``--set KEY=VALUE`` escape hatch. +Specs are intentionally thin: a path, help text, and the verbs/flags that shape +the command surface. Settings flow through the generic ``--set KEY=VALUE`` escape +hatch, where ``KEY`` is the official Splunk REST form-field name, and Splunk +validates them server-side. The lone exception is the ``user`` password, which +stays a typed secret field so it is read from the environment, never a flag. """ from __future__ import annotations @@ -18,12 +20,6 @@ # (Plain CRUD is the Spec default, so it does not need a named constant.) _CRUD_TOGGLE = ("list", "get", "create", "update", "delete", "enable", "disable") -# Fields shared by most data inputs. -_INDEX_SOURCETYPE = ( - Field("index", key="index", help="Target index."), - Field("sourcetype", key="sourcetype", help="Source type."), -) - # --- Access (#4) ------------------------------------------------------------- USER = Spec( @@ -37,55 +33,13 @@ secret=True, help="Initial password (from $SPLUNK_USER_PASSWORD or a prompt; never a flag).", ), - Field("role", key="roles", multi=True, help="Role to assign (repeatable)."), - Field("email", key="email", help="Email address."), - Field("realname", key="realname", help="Full name."), - Field("default_app", key="defaultApp", help="Default app on login."), ), - out_map={ - "realname": "real_name", - "email": "email", - "roles": "roles", - "defaultApp": "default_app", - "type": "auth_type", - }, ) ROLE = Spec( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", - fields=( - Field( - "capability", key="capabilities", multi=True, help="Capability to grant (repeatable)." - ), - Field( - "imported_role", key="imported_roles", multi=True, help="Role to inherit (repeatable)." - ), - Field( - "search_index", - key="srchIndexesAllowed", - multi=True, - help="Allowed search index (repeatable).", - ), - Field( - "default_index", - key="srchIndexesDefault", - multi=True, - help="Default search index (repeatable).", - ), - Field( - "search_quota", - key="srchJobsQuota", - type="int", - help="Concurrent historical search quota.", - ), - ), - out_map={ - "imported_roles": "imported_roles", - "srchIndexesAllowed": "search_indexes", - "srchJobsQuota": "search_quota", - }, ) CAPABILITY = Spec( @@ -104,13 +58,6 @@ path="/services/data/inputs/monitor", help="File and directory monitor inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("host", key="host", help="Host value for events."), - Field("recursive", key="recursive", type="bool", help="Recurse into subdirectories."), - Field("whitelist", key="whitelist", help="Allowlist regex."), - Field("blacklist", key="blacklist", help="Denylist regex."), - ), ) TCP_INPUT = Spec( @@ -118,10 +65,6 @@ path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) UDP_INPUT = Spec( @@ -129,10 +72,6 @@ path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) SCRIPT_INPUT = Spec( @@ -140,10 +79,6 @@ path="/services/data/inputs/script", help="Scripted inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("interval", key="interval", help="Run interval (seconds or cron)."), - ), ) HEC_TOKEN = Spec( @@ -151,11 +86,6 @@ path="/services/data/inputs/http", help="HTTP Event Collector tokens.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("allowed_index", key="indexes", multi=True, help="Allowed index (repeatable)."), - Field("source", key="source", help="Default source."), - ), ) OUTPUT_SERVER = Spec( @@ -163,7 +93,6 @@ path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, - fields=(Field("method", key="method", help="Routing: clone|balance|autobalance."),), ) OUTPUT_GROUP = Spec( @@ -171,10 +100,6 @@ path="/services/data/outputs/tcp/group", help="Forwarder output groups.", verbs=_CRUD_TOGGLE, - fields=( - Field("server", key="servers", multi=True, help="Member server host:port (repeatable)."), - Field("method", key="method", help="Routing: clone|balance|autobalance."), - ), ) # --- Knowledge objects (#8) -------------------------------------------------- @@ -185,11 +110,6 @@ path="configs/conf-macros", help="Search macros.", namespaced=True, - fields=( - Field("definition", key="definition", help="The macro expansion."), - Field("args", key="args", help="Comma-separated argument names."), - Field("iseval", key="iseval", type="bool", help="Definition is an eval expression."), - ), ) EVENTTYPE = Spec( @@ -197,11 +117,6 @@ path="saved/eventtypes", help="Event types.", namespaced=True, - fields=( - Field("search", key="search", help="The search that defines the event type."), - Field("description", key="description", help="Description."), - Field("priority", key="priority", type="int", help="Priority (1-10)."), - ), ) EXTRACTION = Spec( @@ -209,10 +124,6 @@ path="data/transforms/extractions", help="Field extractions (transforms).", namespaced=True, - fields=( - Field("regex", key="REGEX", help="Extraction regular expression."), - Field("format", key="FORMAT", help="Output format."), - ), ) LOOKUP_DEFINITION = Spec( @@ -220,7 +131,6 @@ path="data/transforms/lookups", help="Lookup definitions (transforms).", namespaced=True, - fields=(Field("filename", key="filename", help="Lookup table file name."),), ) # --- KV Store (#9) ----------------------------------------------------------- @@ -244,7 +154,6 @@ path="/services/messages", help="System bulletin messages.", verbs=("list", "get", "create", "delete"), - fields=(Field("value", key="value", help="Message text."),), ) # --- Apps (#5) --------------------------------------------------------------- diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index 062fd03..8fcaeaa 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -33,10 +33,10 @@ def test_generated_user_create_dry_run_previews(monkeypatch): "user", "create", "alice", - "--role", - "admin", - "--email", - "a@x.com", + "--set", + "roles=admin", + "--set", + "email=a@x.com", "--dry-run", "--output", "json", @@ -51,7 +51,7 @@ def test_generated_user_create_dry_run_previews(monkeypatch): def test_generated_user_create_refuses_without_yes(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -85,7 +85,7 @@ def handler(req: httpx.Request) -> httpx.Response: _patch_client(monkeypatch, handler) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--yes", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--yes", "--output", "json"] ) assert result.exit_code == 0 assert "password=hunter2" in seen["body"] # secret pulled from env, sent on the wire @@ -94,7 +94,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_add_alias_on_generated_group(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "add", "alice", "--role", "admin", "--dry-run", "--output", "json"] + cli, ["user", "add", "alice", "--set", "roles=admin", "--dry-run", "--output", "json"] ) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -105,7 +105,7 @@ def test_namespaced_generated_group_requires_app(monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) # macro is namespaced -> a write without --app must refuse (never target 'search'). result = CliRunner().invoke( - cli, ["macro", "create", "m1", "--definition", "x", "--dry-run", "--output", "json"] + cli, ["macro", "create", "m1", "--set", "definition=x", "--dry-run", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -119,8 +119,8 @@ def test_namespaced_generated_group_previews_app_namespace(monkeypatch): "macro", "create", "m1", - "--definition", - "x", + "--set", + "definition=x", "--app", "my_app", "--dry-run", From 43887f72940c22fd07cc5009565c9c8e6ca563d8 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:02:42 -0400 Subject: [PATCH 27/46] refactor: thin the resource specs (propagate registry trim) --- src/vct_splunk/commands/registry.py | 101 ++-------------------------- tests/unit/test_factory_cmd.py | 20 +++--- 2 files changed, 15 insertions(+), 106 deletions(-) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index cefbe89..d52e356 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -5,9 +5,11 @@ command group per spec via :func:`vct_splunk.commands.factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. -Field ``key`` values are the official Splunk REST form-field names, so the surface -stays familiar to a Splunk admin. Any field not given a typed option is still -reachable through the generic ``--set KEY=VALUE`` escape hatch. +Specs are intentionally thin: a path, help text, and the verbs/flags that shape +the command surface. Settings flow through the generic ``--set KEY=VALUE`` escape +hatch, where ``KEY`` is the official Splunk REST form-field name, and Splunk +validates them server-side. The lone exception is the ``user`` password, which +stays a typed secret field so it is read from the environment, never a flag. """ from __future__ import annotations @@ -18,12 +20,6 @@ # (Plain CRUD is the Spec default, so it does not need a named constant.) _CRUD_TOGGLE = ("list", "get", "create", "update", "delete", "enable", "disable") -# Fields shared by most data inputs. -_INDEX_SOURCETYPE = ( - Field("index", key="index", help="Target index."), - Field("sourcetype", key="sourcetype", help="Source type."), -) - # --- Access (#4) ------------------------------------------------------------- USER = Spec( @@ -37,55 +33,13 @@ secret=True, help="Initial password (from $SPLUNK_USER_PASSWORD or a prompt; never a flag).", ), - Field("role", key="roles", multi=True, help="Role to assign (repeatable)."), - Field("email", key="email", help="Email address."), - Field("realname", key="realname", help="Full name."), - Field("default_app", key="defaultApp", help="Default app on login."), ), - out_map={ - "realname": "real_name", - "email": "email", - "roles": "roles", - "defaultApp": "default_app", - "type": "auth_type", - }, ) ROLE = Spec( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", - fields=( - Field( - "capability", key="capabilities", multi=True, help="Capability to grant (repeatable)." - ), - Field( - "imported_role", key="imported_roles", multi=True, help="Role to inherit (repeatable)." - ), - Field( - "search_index", - key="srchIndexesAllowed", - multi=True, - help="Allowed search index (repeatable).", - ), - Field( - "default_index", - key="srchIndexesDefault", - multi=True, - help="Default search index (repeatable).", - ), - Field( - "search_quota", - key="srchJobsQuota", - type="int", - help="Concurrent historical search quota.", - ), - ), - out_map={ - "imported_roles": "imported_roles", - "srchIndexesAllowed": "search_indexes", - "srchJobsQuota": "search_quota", - }, ) CAPABILITY = Spec( @@ -104,13 +58,6 @@ path="/services/data/inputs/monitor", help="File and directory monitor inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("host", key="host", help="Host value for events."), - Field("recursive", key="recursive", type="bool", help="Recurse into subdirectories."), - Field("whitelist", key="whitelist", help="Allowlist regex."), - Field("blacklist", key="blacklist", help="Denylist regex."), - ), ) TCP_INPUT = Spec( @@ -118,10 +65,6 @@ path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) UDP_INPUT = Spec( @@ -129,10 +72,6 @@ path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) SCRIPT_INPUT = Spec( @@ -140,10 +79,6 @@ path="/services/data/inputs/script", help="Scripted inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("interval", key="interval", help="Run interval (seconds or cron)."), - ), ) HEC_TOKEN = Spec( @@ -151,11 +86,6 @@ path="/services/data/inputs/http", help="HTTP Event Collector tokens.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("allowed_index", key="indexes", multi=True, help="Allowed index (repeatable)."), - Field("source", key="source", help="Default source."), - ), ) OUTPUT_SERVER = Spec( @@ -163,7 +93,6 @@ path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, - fields=(Field("method", key="method", help="Routing: clone|balance|autobalance."),), ) OUTPUT_GROUP = Spec( @@ -171,10 +100,6 @@ path="/services/data/outputs/tcp/group", help="Forwarder output groups.", verbs=_CRUD_TOGGLE, - fields=( - Field("server", key="servers", multi=True, help="Member server host:port (repeatable)."), - Field("method", key="method", help="Routing: clone|balance|autobalance."), - ), ) # --- Knowledge objects (#8) -------------------------------------------------- @@ -185,11 +110,6 @@ path="configs/conf-macros", help="Search macros.", namespaced=True, - fields=( - Field("definition", key="definition", help="The macro expansion."), - Field("args", key="args", help="Comma-separated argument names."), - Field("iseval", key="iseval", type="bool", help="Definition is an eval expression."), - ), ) EVENTTYPE = Spec( @@ -197,11 +117,6 @@ path="saved/eventtypes", help="Event types.", namespaced=True, - fields=( - Field("search", key="search", help="The search that defines the event type."), - Field("description", key="description", help="Description."), - Field("priority", key="priority", type="int", help="Priority (1-10)."), - ), ) EXTRACTION = Spec( @@ -209,10 +124,6 @@ path="data/transforms/extractions", help="Field extractions (transforms).", namespaced=True, - fields=( - Field("regex", key="REGEX", help="Extraction regular expression."), - Field("format", key="FORMAT", help="Output format."), - ), ) LOOKUP_DEFINITION = Spec( @@ -220,7 +131,6 @@ path="data/transforms/lookups", help="Lookup definitions (transforms).", namespaced=True, - fields=(Field("filename", key="filename", help="Lookup table file name."),), ) # --- KV Store (#9) ----------------------------------------------------------- @@ -244,7 +154,6 @@ path="/services/messages", help="System bulletin messages.", verbs=("list", "get", "create", "delete"), - fields=(Field("value", key="value", help="Message text."),), ) # --- Apps (#5) --------------------------------------------------------------- diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index 062fd03..8fcaeaa 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -33,10 +33,10 @@ def test_generated_user_create_dry_run_previews(monkeypatch): "user", "create", "alice", - "--role", - "admin", - "--email", - "a@x.com", + "--set", + "roles=admin", + "--set", + "email=a@x.com", "--dry-run", "--output", "json", @@ -51,7 +51,7 @@ def test_generated_user_create_dry_run_previews(monkeypatch): def test_generated_user_create_refuses_without_yes(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -85,7 +85,7 @@ def handler(req: httpx.Request) -> httpx.Response: _patch_client(monkeypatch, handler) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--yes", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--yes", "--output", "json"] ) assert result.exit_code == 0 assert "password=hunter2" in seen["body"] # secret pulled from env, sent on the wire @@ -94,7 +94,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_add_alias_on_generated_group(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "add", "alice", "--role", "admin", "--dry-run", "--output", "json"] + cli, ["user", "add", "alice", "--set", "roles=admin", "--dry-run", "--output", "json"] ) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -105,7 +105,7 @@ def test_namespaced_generated_group_requires_app(monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) # macro is namespaced -> a write without --app must refuse (never target 'search'). result = CliRunner().invoke( - cli, ["macro", "create", "m1", "--definition", "x", "--dry-run", "--output", "json"] + cli, ["macro", "create", "m1", "--set", "definition=x", "--dry-run", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -119,8 +119,8 @@ def test_namespaced_generated_group_previews_app_namespace(monkeypatch): "macro", "create", "m1", - "--definition", - "x", + "--set", + "definition=x", "--app", "my_app", "--dry-run", From 9bfc373086c543e3daaff721588ec26621892e13 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:02:43 -0400 Subject: [PATCH 28/46] refactor: thin the resource specs (propagate registry trim) --- src/vct_splunk/commands/registry.py | 105 ++-------------------------- tests/unit/test_factory_cmd.py | 20 +++--- 2 files changed, 17 insertions(+), 108 deletions(-) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index 151e3ac..d52e356 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -5,9 +5,11 @@ command group per spec via :func:`vct_splunk.commands.factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. -Field ``key`` values are the official Splunk REST form-field names, so the surface -stays familiar to a Splunk admin. Any field not given a typed option is still -reachable through the generic ``--set KEY=VALUE`` escape hatch. +Specs are intentionally thin: a path, help text, and the verbs/flags that shape +the command surface. Settings flow through the generic ``--set KEY=VALUE`` escape +hatch, where ``KEY`` is the official Splunk REST form-field name, and Splunk +validates them server-side. The lone exception is the ``user`` password, which +stays a typed secret field so it is read from the environment, never a flag. """ from __future__ import annotations @@ -18,12 +20,6 @@ # (Plain CRUD is the Spec default, so it does not need a named constant.) _CRUD_TOGGLE = ("list", "get", "create", "update", "delete", "enable", "disable") -# Fields shared by most data inputs. -_INDEX_SOURCETYPE = ( - Field("index", key="index", help="Target index."), - Field("sourcetype", key="sourcetype", help="Source type."), -) - # --- Access (#4) ------------------------------------------------------------- USER = Spec( @@ -37,55 +33,13 @@ secret=True, help="Initial password (from $SPLUNK_USER_PASSWORD or a prompt; never a flag).", ), - Field("role", key="roles", multi=True, help="Role to assign (repeatable)."), - Field("email", key="email", help="Email address."), - Field("realname", key="realname", help="Full name."), - Field("default_app", key="defaultApp", help="Default app on login."), ), - out_map={ - "realname": "real_name", - "email": "email", - "roles": "roles", - "defaultApp": "default_app", - "type": "auth_type", - }, ) ROLE = Spec( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", - fields=( - Field( - "capability", key="capabilities", multi=True, help="Capability to grant (repeatable)." - ), - Field( - "imported_role", key="imported_roles", multi=True, help="Role to inherit (repeatable)." - ), - Field( - "search_index", - key="srchIndexesAllowed", - multi=True, - help="Allowed search index (repeatable).", - ), - Field( - "default_index", - key="srchIndexesDefault", - multi=True, - help="Default search index (repeatable).", - ), - Field( - "search_quota", - key="srchJobsQuota", - type="int", - help="Concurrent historical search quota.", - ), - ), - out_map={ - "imported_roles": "imported_roles", - "srchIndexesAllowed": "search_indexes", - "srchJobsQuota": "search_quota", - }, ) CAPABILITY = Spec( @@ -104,13 +58,6 @@ path="/services/data/inputs/monitor", help="File and directory monitor inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("host", key="host", help="Host value for events."), - Field("recursive", key="recursive", type="bool", help="Recurse into subdirectories."), - Field("whitelist", key="whitelist", help="Allowlist regex."), - Field("blacklist", key="blacklist", help="Denylist regex."), - ), ) TCP_INPUT = Spec( @@ -118,10 +65,6 @@ path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) UDP_INPUT = Spec( @@ -129,10 +72,6 @@ path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) SCRIPT_INPUT = Spec( @@ -140,10 +79,6 @@ path="/services/data/inputs/script", help="Scripted inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("interval", key="interval", help="Run interval (seconds or cron)."), - ), ) HEC_TOKEN = Spec( @@ -151,11 +86,6 @@ path="/services/data/inputs/http", help="HTTP Event Collector tokens.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("allowed_index", key="indexes", multi=True, help="Allowed index (repeatable)."), - Field("source", key="source", help="Default source."), - ), ) OUTPUT_SERVER = Spec( @@ -163,7 +93,6 @@ path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, - fields=(Field("method", key="method", help="Routing: clone|balance|autobalance."),), ) OUTPUT_GROUP = Spec( @@ -171,10 +100,6 @@ path="/services/data/outputs/tcp/group", help="Forwarder output groups.", verbs=_CRUD_TOGGLE, - fields=( - Field("server", key="servers", multi=True, help="Member server host:port (repeatable)."), - Field("method", key="method", help="Routing: clone|balance|autobalance."), - ), ) # --- Knowledge objects (#8) -------------------------------------------------- @@ -185,11 +110,6 @@ path="configs/conf-macros", help="Search macros.", namespaced=True, - fields=( - Field("definition", key="definition", help="The macro expansion."), - Field("args", key="args", help="Comma-separated argument names."), - Field("iseval", key="iseval", type="bool", help="Definition is an eval expression."), - ), ) EVENTTYPE = Spec( @@ -197,11 +117,6 @@ path="saved/eventtypes", help="Event types.", namespaced=True, - fields=( - Field("search", key="search", help="The search that defines the event type."), - Field("description", key="description", help="Description."), - Field("priority", key="priority", type="int", help="Priority (1-10)."), - ), ) EXTRACTION = Spec( @@ -209,10 +124,6 @@ path="data/transforms/extractions", help="Field extractions (transforms).", namespaced=True, - fields=( - Field("regex", key="REGEX", help="Extraction regular expression."), - Field("format", key="FORMAT", help="Output format."), - ), ) LOOKUP_DEFINITION = Spec( @@ -220,7 +131,6 @@ path="data/transforms/lookups", help="Lookup definitions (transforms).", namespaced=True, - fields=(Field("filename", key="filename", help="Lookup table file name."),), ) # --- KV Store (#9) ----------------------------------------------------------- @@ -244,12 +154,11 @@ path="/services/messages", help="System bulletin messages.", verbs=("list", "get", "create", "delete"), - fields=(Field("value", key="value", help="Message text."),), ) # --- Apps (#5) --------------------------------------------------------------- -# Lifecycle only. Install-from-file/URL (the appinstall endpoint) does not fit -# the CRUD shape and stays hand-written (see commands/apps.py). +# Lifecycle only. Install-from-file/URL is a multipart upload and stays +# hand-written. APP = Spec( name="app", diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index 062fd03..8fcaeaa 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -33,10 +33,10 @@ def test_generated_user_create_dry_run_previews(monkeypatch): "user", "create", "alice", - "--role", - "admin", - "--email", - "a@x.com", + "--set", + "roles=admin", + "--set", + "email=a@x.com", "--dry-run", "--output", "json", @@ -51,7 +51,7 @@ def test_generated_user_create_dry_run_previews(monkeypatch): def test_generated_user_create_refuses_without_yes(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -85,7 +85,7 @@ def handler(req: httpx.Request) -> httpx.Response: _patch_client(monkeypatch, handler) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--yes", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--yes", "--output", "json"] ) assert result.exit_code == 0 assert "password=hunter2" in seen["body"] # secret pulled from env, sent on the wire @@ -94,7 +94,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_add_alias_on_generated_group(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "add", "alice", "--role", "admin", "--dry-run", "--output", "json"] + cli, ["user", "add", "alice", "--set", "roles=admin", "--dry-run", "--output", "json"] ) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -105,7 +105,7 @@ def test_namespaced_generated_group_requires_app(monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) # macro is namespaced -> a write without --app must refuse (never target 'search'). result = CliRunner().invoke( - cli, ["macro", "create", "m1", "--definition", "x", "--dry-run", "--output", "json"] + cli, ["macro", "create", "m1", "--set", "definition=x", "--dry-run", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -119,8 +119,8 @@ def test_namespaced_generated_group_previews_app_namespace(monkeypatch): "macro", "create", "m1", - "--definition", - "x", + "--set", + "definition=x", "--app", "my_app", "--dry-run", From bf7d832b7ee70d1c7a47f662596ebd18df06a67c Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:04:24 -0400 Subject: [PATCH 29/46] refactor: thin the resource specs (propagate trim, incl. tag/datamodel) --- src/vct_splunk/commands/registry.py | 114 +++------------------------- tests/unit/test_factory_cmd.py | 20 ++--- 2 files changed, 19 insertions(+), 115 deletions(-) diff --git a/src/vct_splunk/commands/registry.py b/src/vct_splunk/commands/registry.py index ac0fd74..f510f93 100644 --- a/src/vct_splunk/commands/registry.py +++ b/src/vct_splunk/commands/registry.py @@ -5,9 +5,11 @@ command group per spec via :func:`vct_splunk.commands.factory.build_group`. Resources that do not fit the CRUD shape stay hand-written and are not listed here. -Field ``key`` values are the official Splunk REST form-field names, so the surface -stays familiar to a Splunk admin. Any field not given a typed option is still -reachable through the generic ``--set KEY=VALUE`` escape hatch. +Specs are intentionally thin: a path, help text, and the verbs/flags that shape +the command surface. Settings flow through the generic ``--set KEY=VALUE`` escape +hatch, where ``KEY`` is the official Splunk REST form-field name, and Splunk +validates them server-side. The lone exception is the ``user`` password, which +stays a typed secret field so it is read from the environment, never a flag. """ from __future__ import annotations @@ -18,12 +20,6 @@ # (Plain CRUD is the Spec default, so it does not need a named constant.) _CRUD_TOGGLE = ("list", "get", "create", "update", "delete", "enable", "disable") -# Fields shared by most data inputs. -_INDEX_SOURCETYPE = ( - Field("index", key="index", help="Target index."), - Field("sourcetype", key="sourcetype", help="Source type."), -) - # --- Access (#4) ------------------------------------------------------------- USER = Spec( @@ -37,55 +33,13 @@ secret=True, help="Initial password (from $SPLUNK_USER_PASSWORD or a prompt; never a flag).", ), - Field("role", key="roles", multi=True, help="Role to assign (repeatable)."), - Field("email", key="email", help="Email address."), - Field("realname", key="realname", help="Full name."), - Field("default_app", key="defaultApp", help="Default app on login."), ), - out_map={ - "realname": "real_name", - "email": "email", - "roles": "roles", - "defaultApp": "default_app", - "type": "auth_type", - }, ) ROLE = Spec( name="role", path="/services/authorization/roles", help="Splunk roles (authorization).", - fields=( - Field( - "capability", key="capabilities", multi=True, help="Capability to grant (repeatable)." - ), - Field( - "imported_role", key="imported_roles", multi=True, help="Role to inherit (repeatable)." - ), - Field( - "search_index", - key="srchIndexesAllowed", - multi=True, - help="Allowed search index (repeatable).", - ), - Field( - "default_index", - key="srchIndexesDefault", - multi=True, - help="Default search index (repeatable).", - ), - Field( - "search_quota", - key="srchJobsQuota", - type="int", - help="Concurrent historical search quota.", - ), - ), - out_map={ - "imported_roles": "imported_roles", - "srchIndexesAllowed": "search_indexes", - "srchJobsQuota": "search_quota", - }, ) CAPABILITY = Spec( @@ -104,13 +58,6 @@ path="/services/data/inputs/monitor", help="File and directory monitor inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("host", key="host", help="Host value for events."), - Field("recursive", key="recursive", type="bool", help="Recurse into subdirectories."), - Field("whitelist", key="whitelist", help="Allowlist regex."), - Field("blacklist", key="blacklist", help="Denylist regex."), - ), ) TCP_INPUT = Spec( @@ -118,10 +65,6 @@ path="/services/data/inputs/tcp/raw", help="Raw TCP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) UDP_INPUT = Spec( @@ -129,10 +72,6 @@ path="/services/data/inputs/udp", help="UDP inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("connection_host", key="connection_host", help="Host from: ip|dns|none."), - ), ) SCRIPT_INPUT = Spec( @@ -140,10 +79,6 @@ path="/services/data/inputs/script", help="Scripted inputs.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("interval", key="interval", help="Run interval (seconds or cron)."), - ), ) HEC_TOKEN = Spec( @@ -151,11 +86,6 @@ path="/services/data/inputs/http", help="HTTP Event Collector tokens.", verbs=_CRUD_TOGGLE, - fields=( - *_INDEX_SOURCETYPE, - Field("allowed_index", key="indexes", multi=True, help="Allowed index (repeatable)."), - Field("source", key="source", help="Default source."), - ), ) OUTPUT_SERVER = Spec( @@ -163,7 +93,6 @@ path="/services/data/outputs/tcp/server", help="Forwarder output servers (forwarding destinations).", verbs=_CRUD_TOGGLE, - fields=(Field("method", key="method", help="Routing: clone|balance|autobalance."),), ) OUTPUT_GROUP = Spec( @@ -171,10 +100,6 @@ path="/services/data/outputs/tcp/group", help="Forwarder output groups.", verbs=_CRUD_TOGGLE, - fields=( - Field("server", key="servers", multi=True, help="Member server host:port (repeatable)."), - Field("method", key="method", help="Routing: clone|balance|autobalance."), - ), ) # --- Knowledge objects (#8) -------------------------------------------------- @@ -185,11 +110,6 @@ path="configs/conf-macros", help="Search macros.", namespaced=True, - fields=( - Field("definition", key="definition", help="The macro expansion."), - Field("args", key="args", help="Comma-separated argument names."), - Field("iseval", key="iseval", type="bool", help="Definition is an eval expression."), - ), ) EVENTTYPE = Spec( @@ -197,11 +117,6 @@ path="saved/eventtypes", help="Event types.", namespaced=True, - fields=( - Field("search", key="search", help="The search that defines the event type."), - Field("description", key="description", help="Description."), - Field("priority", key="priority", type="int", help="Priority (1-10)."), - ), ) EXTRACTION = Spec( @@ -209,10 +124,6 @@ path="data/transforms/extractions", help="Field extractions (transforms).", namespaced=True, - fields=( - Field("regex", key="REGEX", help="Extraction regular expression."), - Field("format", key="FORMAT", help="Output format."), - ), ) LOOKUP_DEFINITION = Spec( @@ -220,27 +131,21 @@ path="data/transforms/lookups", help="Lookup definitions (transforms).", namespaced=True, - fields=(Field("filename", key="filename", help="Lookup table file name."),), ) TAG = Spec( name="tag", path="saved/fvtags", - help="Field-value tags (use --set to tie field=value to tag names).", + help="Field-value tags (settings via --set).", namespaced=True, verbs=("list", "get", "create", "update", "delete"), - # A tag entry ties a field=value pair to one or more tag names. The exact - # keys vary by Splunk version, so only the obvious one is modeled and the - # rest go through --set (validate-in-CI against a live instance). - fields=(Field("tag", key="tag", multi=True, help="Tag name (repeatable)."),), ) DATAMODEL = Spec( name="datamodel", path="datamodel/model", - help="Data models (large JSON; use --set description/acceleration). Accelerate is separate.", + help="Data models (settings via --set). Acceleration is a separate command.", namespaced=True, - fields=(Field("description", key="description", help="Human description."),), ) # --- KV Store (#9) ----------------------------------------------------------- @@ -264,12 +169,11 @@ path="/services/messages", help="System bulletin messages.", verbs=("list", "get", "create", "delete"), - fields=(Field("value", key="value", help="Message text."),), ) # --- Apps (#5) --------------------------------------------------------------- -# Lifecycle only. Install-from-file/URL (the appinstall endpoint) does not fit -# the CRUD shape and stays hand-written (see commands/apps.py). +# Lifecycle only. Install-from-file/URL is a multipart upload and stays +# hand-written. APP = Spec( name="app", diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index 062fd03..8fcaeaa 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -33,10 +33,10 @@ def test_generated_user_create_dry_run_previews(monkeypatch): "user", "create", "alice", - "--role", - "admin", - "--email", - "a@x.com", + "--set", + "roles=admin", + "--set", + "email=a@x.com", "--dry-run", "--output", "json", @@ -51,7 +51,7 @@ def test_generated_user_create_dry_run_previews(monkeypatch): def test_generated_user_create_refuses_without_yes(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -85,7 +85,7 @@ def handler(req: httpx.Request) -> httpx.Response: _patch_client(monkeypatch, handler) result = CliRunner().invoke( - cli, ["user", "create", "alice", "--role", "admin", "--yes", "--output", "json"] + cli, ["user", "create", "alice", "--set", "roles=admin", "--yes", "--output", "json"] ) assert result.exit_code == 0 assert "password=hunter2" in seen["body"] # secret pulled from env, sent on the wire @@ -94,7 +94,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_add_alias_on_generated_group(monkeypatch): _env(monkeypatch) result = CliRunner().invoke( - cli, ["user", "add", "alice", "--role", "admin", "--dry-run", "--output", "json"] + cli, ["user", "add", "alice", "--set", "roles=admin", "--dry-run", "--output", "json"] ) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -105,7 +105,7 @@ def test_namespaced_generated_group_requires_app(monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) # macro is namespaced -> a write without --app must refuse (never target 'search'). result = CliRunner().invoke( - cli, ["macro", "create", "m1", "--definition", "x", "--dry-run", "--output", "json"] + cli, ["macro", "create", "m1", "--set", "definition=x", "--dry-run", "--output", "json"] ) assert result.exit_code == 2 assert "usage_error" in result.output @@ -119,8 +119,8 @@ def test_namespaced_generated_group_previews_app_namespace(monkeypatch): "macro", "create", "m1", - "--definition", - "x", + "--set", + "definition=x", "--app", "my_app", "--dry-run", From 080e8ff7d78320ae88324b03da520e0874a09d03 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:47:15 -0400 Subject: [PATCH 30/46] fix: harden profiles authentication and health checks --- src/vct_splunk/commands/auth.py | 16 ++--- src/vct_splunk/commands/context.py | 34 ++++----- src/vct_splunk/commands/write.py | 2 +- src/vct_splunk/core/auth.py | 6 +- src/vct_splunk/core/health.py | 80 +++++++++++++++------ src/vct_splunk/core/profiles.py | 14 +++- tests/unit/test_auth.py | 11 +++ tests/unit/test_cli_matrix.py | 16 ++++- tests/unit/test_client.py | 10 +++ tests/unit/test_health.py | 110 +++++++++++++++++++++++++++++ tests/unit/test_profiles.py | 24 +++++++ 11 files changed, 262 insertions(+), 61 deletions(-) diff --git a/src/vct_splunk/commands/auth.py b/src/vct_splunk/commands/auth.py index 466b343..59fe688 100644 --- a/src/vct_splunk/commands/auth.py +++ b/src/vct_splunk/commands/auth.py @@ -1,7 +1,7 @@ """`splunk auth` commands: session login and auth status. Shell layer (imports Click). ``auth login`` exchanges a username/password for a Splunk session key and prints -it (a secret hint goes to stderr; nothing is written to disk). ``auth status`` +it as command data. ``auth status`` reports the resolved target and which auth scheme is active, without revealing any secret value. """ @@ -20,14 +20,6 @@ from .context import command -def _resolve_url(base_url: str | None, profile: str | None) -> str: - """Resolve the management URL by flag > env > profile (no credential needed).""" - url = base_url or os.environ.get("SPLUNK_URL") or load_profile(profile).get("url") - if not url: - raise UsageError("No Splunk URL. Set SPLUNK_URL or pass --base-url.") - return url.rstrip("/") - - def _verify() -> bool | str: """TLS verification from the environment, matching the main client.""" ca = os.environ.get("SPLUNK_CA_BUNDLE") @@ -74,10 +66,10 @@ def login(ctx, username: str | None) -> None: flag. The session key is printed as data; export it as ``SPLUNK_SESSION_KEY`` to use it (this command does not persist it). """ - url = _resolve_url(ctx.base_url, ctx.profile) + url = ctx.base_url + if not url: + raise UsageError("No Splunk URL. Set SPLUNK_URL, select a profile, or pass --base-url.") key = core.login(url, _resolve_username(username), _resolve_password(), verify=_verify()) - # The key itself is data on stdout; the usage hint is a diagnostic on stderr. - click.echo(f"export SPLUNK_SESSION_KEY={key}", err=True) out.emit({"session_key": key}, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/commands/context.py b/src/vct_splunk/commands/context.py index d6fc9c4..eac7686 100644 --- a/src/vct_splunk/commands/context.py +++ b/src/vct_splunk/commands/context.py @@ -116,7 +116,7 @@ def acs_client(self) -> AcsClient: from ..core.acs.client import AcsClient, acs_config_from_env from ..core.backends import cloud_stack_from_url - stack = cloud_stack_from_url(self.base_url or os.environ.get("SPLUNK_URL")) + stack = cloud_stack_from_url(self.base_url) return AcsClient(acs_config_from_env(stack)) def meta(self) -> dict[str, str | None]: @@ -125,8 +125,7 @@ def meta(self) -> dict[str, str | None]: Right now this is just the target Splunk URL, so a piece of output can be traced back to the instance it came from. """ - profile = load_profile(self.profile) - return {"target": self.base_url or os.environ.get("SPLUNK_URL") or profile.get("url")} + return {"target": self.base_url} def command(fn: Callable) -> Callable: @@ -151,23 +150,20 @@ def wrapper(output, table, dry_run, yes, base_url, app, owner, profile, **kwargs # Click passes every option to the callback by name. The shared options # are named explicitly here; the command's own arguments arrive untouched # in **kwargs and are forwarded straight through to fn. - profile = profile or os.environ.get("SPLUNK_PROFILE") - prof = load_profile(profile) - ctx = Ctx( - out.resolve_mode(output, table), - dry_run, - yes, - base_url, - # Flag > env > profile; any may stay None, in which case the namespace - # policy (core.namespace.resolve_ns) supplies a safe default. - app=app or os.environ.get("SPLUNK_APP") or prof.get("app"), - owner=owner or os.environ.get("SPLUNK_OWNER") or prof.get("owner"), - profile=profile, - # Deduced from the target URL, never user-chosen. Drives backend - # routing (REST vs ACS) and the Cloud write guard. - backend=deduce_backend(base_url or os.environ.get("SPLUNK_URL") or prof.get("url")), - ) try: + profile = profile or os.environ.get("SPLUNK_PROFILE") + prof = load_profile(profile) + target = base_url or os.environ.get("SPLUNK_URL") or prof.get("url") + ctx = Ctx( + out.resolve_mode(output, table), + dry_run, + yes, + target, + app=app or os.environ.get("SPLUNK_APP") or prof.get("app"), + owner=owner or os.environ.get("SPLUNK_OWNER") or prof.get("owner"), + profile=profile, + backend=deduce_backend(target), + ) return fn(ctx, **kwargs) except SplunkError as exc: # The core stays Click-free and raises typed errors; the shell layer diff --git a/src/vct_splunk/commands/write.py b/src/vct_splunk/commands/write.py index b20a0c7..0deec8d 100644 --- a/src/vct_splunk/commands/write.py +++ b/src/vct_splunk/commands/write.py @@ -52,7 +52,7 @@ def do_write( if getattr(ctx, "backend", "enterprise") == "cloud": resource, _, verb = str(audit_event.get("action", "")).partition(".") raise UnsupportedBackendError(resource or "this resource", verb or "write", "cloud") - target = target or config_from_env(ctx.base_url).base_url + target = target or config_from_env(ctx.base_url, profile=getattr(ctx, "profile", None)).base_url out.confirm_write(ctx, action, target) with ctx.client() as c: result = run(c) diff --git a/src/vct_splunk/core/auth.py b/src/vct_splunk/core/auth.py index 119d0af..d246a6e 100644 --- a/src/vct_splunk/core/auth.py +++ b/src/vct_splunk/core/auth.py @@ -41,7 +41,7 @@ def login( The session key string. Raises: - AuthError: On a 401 (bad credentials) or a missing ``sessionKey``. + AuthError: On a 401/403 (bad credentials) or a missing ``sessionKey``. APIError: On any other non-2xx response. TransportError: If Splunk cannot be reached. """ @@ -54,8 +54,8 @@ def login( ) except httpx.HTTPError as exc: raise TransportError(f"Could not reach Splunk at {url}: {exc}") from exc - if resp.status_code == 401: - raise AuthError("Login failed (401). Check the username and password.") + 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: diff --git a/src/vct_splunk/core/health.py b/src/vct_splunk/core/health.py index b0d3c5c..8d7ea19 100644 --- a/src/vct_splunk/core/health.py +++ b/src/vct_splunk/core/health.py @@ -9,6 +9,7 @@ from __future__ import annotations +import math from dataclasses import asdict, dataclass from typing import Any @@ -111,26 +112,38 @@ def _resource_usage(client: SplunkClient) -> list[Verdict]: except SplunkError as exc: return [Verdict("resource_usage", "unknown", "error", "fail", exc.message)] - cpu_pct = _to_float(content.get("cpu_system_pct")) + _to_float(content.get("cpu_user_pct")) + cpu_system = _to_float(content.get("cpu_system_pct")) + cpu_user = _to_float(content.get("cpu_user_pct")) load = _to_float(content.get("normalized_load_avg_1min")) - cpu = Verdict( - "resource_cpu", - "applicable", - "completed", - "warn" if cpu_pct > _CPU_WARN_PCT else "pass", - f"cpu={cpu_pct:.1f}% (warn>{_CPU_WARN_PCT:g}%), load_1min={load:.2f}", - ) + if cpu_system is None or cpu_user is None: + cpu = _unknown("resource_cpu", "missing or malformed CPU usage data") + else: + cpu_pct = cpu_system + cpu_user + load_evidence = f", load_1min={load:.2f}" if load is not None else "" + cpu = Verdict( + "resource_cpu", + "applicable", + "completed", + "warn" if cpu_pct > _CPU_WARN_PCT else "pass", + f"cpu={cpu_pct:.1f}% (warn>{_CPU_WARN_PCT:g}%){load_evidence}", + ) mem_total = _to_float(content.get("mem")) mem_used = _to_float(content.get("mem_used")) - mem_pct = (mem_used / mem_total * 100.0) if mem_total > 0 else 0.0 - mem = Verdict( - "resource_memory", - "applicable", - "completed", - "warn" if mem_pct > _MEM_WARN_PCT else "pass", - f"mem={mem_pct:.1f}% used ({mem_used:.0f}/{mem_total:.0f} MB, warn>{_MEM_WARN_PCT:g}%)", - ) + if mem_total is None or mem_used is None or mem_total <= 0: + mem = _unknown("resource_memory", "missing, malformed, or zero-total memory data") + else: + mem_pct = mem_used / mem_total * 100.0 + mem = Verdict( + "resource_memory", + "applicable", + "completed", + "warn" if mem_pct > _MEM_WARN_PCT else "pass", + ( + f"mem={mem_pct:.1f}% used " + f"({mem_used:.0f}/{mem_total:.0f} MB, warn>{_MEM_WARN_PCT:g}%)" + ), + ) return [cpu, mem] @@ -146,13 +159,24 @@ def _disk_space(client: SplunkClient) -> list[Verdict]: except SplunkError as exc: return [Verdict("disk_space", "unknown", "error", "fail", exc.message)] + if not entries: + return [_unknown("disk_space", "partition endpoint returned no data")] + out: list[Verdict] = [] for entry in entries: content = (entry or {}).get("content", {}) mount = content.get("mount_point") or (entry or {}).get("name") or "?" capacity = _to_float(content.get("capacity")) free = _to_float(content.get("free")) - free_pct = (free / capacity * 100.0) if capacity > 0 else 0.0 + if capacity is None or free is None or capacity <= 0: + out.append( + _unknown( + f"disk:{mount}", + "missing, malformed, or zero-capacity partition data", + ) + ) + continue + free_pct = free / capacity * 100.0 evidence = ( f"free={free_pct:.1f}% ({free:.0f}/{capacity:.0f} MB, warn<{_DISK_WARN_FREE_PCT:g}%)" ) @@ -189,7 +213,12 @@ def _internal_errors(client: SplunkClient) -> list[Verdict]: return [Verdict("internal_errors", "unknown", "error", "fail", exc.message)] results = body.get("results") or [] - count = int(_to_float(results[0].get("error_count"))) if results else 0 + if not results or not isinstance(results[0], dict): + return [_unknown("internal_errors", "internal-error search returned no data")] + count_value = _to_float(results[0].get("error_count")) + if count_value is None or count_value < 0: + return [_unknown("internal_errors", "internal-error count is missing or malformed")] + count = int(count_value) return [ Verdict( "internal_errors", @@ -201,13 +230,20 @@ def _internal_errors(client: SplunkClient) -> list[Verdict]: ] -def _to_float(value: Any) -> float: - """Coerce a Splunk field to float, treating missing/garbage as 0.0. +def _unknown(check: str, evidence: str) -> Verdict: + """Return a failed verdict for data whose health cannot be determined.""" + return Verdict(check, "unknown", "error", "fail", evidence) + + +def _to_float(value: Any) -> float | None: + """Coerce a Splunk numeric field, preserving missing or malformed input. Splunk returns numeric introspection fields as JSON strings (e.g. ``"42.5"``), so every threshold comparison routes through this instead of assuming a type. + ``None`` distinguishes unknown data from a valid numeric zero. """ try: - return float(value) + number = float(value) except (TypeError, ValueError): - return 0.0 + return None + return number if math.isfinite(number) else None diff --git a/src/vct_splunk/core/profiles.py b/src/vct_splunk/core/profiles.py index 4e18ac9..b54b88d 100644 --- a/src/vct_splunk/core/profiles.py +++ b/src/vct_splunk/core/profiles.py @@ -19,6 +19,8 @@ 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") @@ -52,7 +54,7 @@ def load_profile(name: str | None) -> dict[str, str]: path = config_path() if not path.is_file(): return {} - parser = configparser.ConfigParser() + parser = configparser.ConfigParser(interpolation=None) try: parser.read(path) except (OSError, configparser.Error): @@ -60,4 +62,12 @@ def load_profile(name: str | None) -> dict[str, str]: if not parser.has_section(name): return {} section = parser[name] - return {key: section[key] for key in PROFILE_KEYS if key in section} + values = {key: section[key] for key in PROFILE_KEYS if key in section} + if os.name == "posix" and any(values.get(key) for key in ("token", "session_key")): + try: + mode = path.stat().st_mode & 0o777 + except OSError: + return {} + if mode & 0o077: + raise UsageError(f"Profile file {path} contains credentials and must have mode 0600.") + return values diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index a7a0f73..d66dd9b 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -62,6 +62,16 @@ def test_login_401_raises_auth(): ) +def test_login_403_raises_auth(): + with pytest.raises(AuthError): + core.login( + "https://splunk.test:8089", + "admin", + "forbidden", + transport=httpx.MockTransport(lambda req: httpx.Response(403, json={})), + ) + + def test_login_missing_session_key_raises_auth(): with pytest.raises(AuthError): core.login( @@ -94,6 +104,7 @@ def test_auth_login_echoes_session_key(monkeypatch): result = CliRunner().invoke(cli, ["auth", "login", "--output", "json"]) assert result.exit_code == 0 assert '"session_key": "SK-FROM-LOGIN"' in result.output + assert "export SPLUNK_SESSION_KEY" not in result.stderr def test_auth_login_refuses_without_password_noninteractive(monkeypatch): diff --git a/tests/unit/test_cli_matrix.py b/tests/unit/test_cli_matrix.py index e1ac24b..aaf9b24 100644 --- a/tests/unit/test_cli_matrix.py +++ b/tests/unit/test_cli_matrix.py @@ -26,6 +26,8 @@ # Non-CRUD leaves the generic verb rules below cannot infer. Writes include # --dry-run; reads run for real against the mock. _SPECIAL: dict[tuple[str, ...], list[str]] = { + ("auth", "login"): ["--username", "admin"], + ("auth", "status"): [], ("server", "info"): [], ("api", "get"): ["/services/server/info", "-q", "count=1"], ("search", "run"): ["--query", "index=_internal", "--earliest", "-1h", "--max-rows", "5"], @@ -87,14 +89,21 @@ def _handler(req: httpx.Request) -> httpx.Response: if path.endswith("/dispatch"): return httpx.Response(201, json={"sid": "sid1"}) if "/search/jobs" in path and req.method == "POST": - return httpx.Response(200, json={"results": []}) + return httpx.Response(200, json={"results": [{"error_count": "0"}]}) + content = {"version": "9.4", "health": "green", "features": {}} + if path.endswith("/resource-usage/hostwide"): + content.update( + {"cpu_system_pct": "5", "cpu_user_pct": "10", "mem": "100", "mem_used": "20"} + ) + if path.endswith("/partitions-space"): + content.update({"mount_point": "/", "capacity": "100", "free": "80"}) return httpx.Response( 200, json={ "entry": [ { "name": "x", - "content": {"version": "9.4", "health": "green", "features": {}}, + "content": content, "acl": {"app": "a", "owner": "o", "sharing": "app"}, } ], @@ -132,7 +141,10 @@ def test_every_leaf_has_representative_args(): @pytest.mark.parametrize("path", [p for p, _ in _iter_leaves(cli)], ids=lambda p: " ".join(p)) def test_every_leaf_runs_with_common_inputs(path, cli_env, patch_client, monkeypatch, tmp_path): monkeypatch.setenv("SPLUNK_APP", "my_app") # satisfies namespaced writes + monkeypatch.setenv("SPLUNK_PASSWORD", "secret") monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(tmp_path / "audit.log")) + if path == ("auth", "login"): + monkeypatch.setattr("vct_splunk.commands.auth.core.login", lambda *a, **k: "SK") patch_client(_handler) argv = [*path, *(_args_for(path) or []), "--output", "json"] result = CliRunner().invoke(cli, argv) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index d0fd8d5..2c6cc5c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -221,3 +221,13 @@ def test_config_from_env_env_url_wins_over_profile(monkeypatch, tmp_path): monkeypatch.setenv("SPLUNK_TOKEN", "T") cfg = config_from_env(profile="prod") assert cfg.base_url == "https://from-env:8089" + + +def test_config_from_env_profile_only_resolves_url_and_token(monkeypatch, tmp_path): + _clear_auth_env(monkeypatch) + cfgfile = tmp_path / "config" + 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") + assert (cfg.base_url, cfg.token) == ("https://from-profile:8089", "T%PROFILE") diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index 24346df..3d9719a 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -4,6 +4,7 @@ from typing import Any import httpx +import pytest from vct_splunk.core import health @@ -65,6 +66,41 @@ def test_resource_usage_normal_passes(client_for): assert verdicts["resource_memory"].finding == "pass" +@pytest.mark.parametrize( + "content", + [ + {}, + {"cpu_system_pct": "", "cpu_user_pct": "10"}, + {"cpu_system_pct": "garbage", "cpu_user_pct": "10"}, + {"cpu_system_pct": "nan", "cpu_user_pct": "10"}, + ], +) +def test_resource_usage_unknown_cpu_is_error(client_for, content): + verdicts = {v.check: v for v in health._resource_usage(client_for(_resource_handler(content)))} + cpu = verdicts["resource_cpu"] + assert (cpu.applicability, cpu.execution, cpu.finding) == ("unknown", "error", "fail") + + +@pytest.mark.parametrize( + "content", + [ + {}, + {"mem": "", "mem_used": "1"}, + {"mem": "garbage", "mem_used": "1"}, + {"mem": "0", "mem_used": "0"}, + {"mem": "100", "mem_used": "garbage"}, + ], +) +def test_resource_usage_unknown_memory_is_error(client_for, content): + verdicts = {v.check: v for v in health._resource_usage(client_for(_resource_handler(content)))} + memory = verdicts["resource_memory"] + assert (memory.applicability, memory.execution, memory.finding) == ( + "unknown", + "error", + "fail", + ) + + def test_disk_space_low_free_warns(client_for): def handler(req: httpx.Request) -> httpx.Response: return httpx.Response( @@ -82,6 +118,49 @@ def handler(req: httpx.Request) -> httpx.Response: assert verdicts["disk:/var"].finding == "pass" # 80% free +def _disk_handler(entries): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"entry": entries}) + + return handler + + +def test_disk_space_empty_results_are_error(client_for): + verdict = health._disk_space(client_for(_disk_handler([])))[0] + assert (verdict.check, verdict.applicability, verdict.execution, verdict.finding) == ( + "disk_space", + "unknown", + "error", + "fail", + ) + + +@pytest.mark.parametrize( + "content", + [ + {}, + {"capacity": "", "free": "1"}, + {"capacity": "garbage", "free": "1"}, + {"capacity": "0", "free": "0"}, + {"capacity": "100", "free": "garbage"}, + ], +) +def test_disk_space_unknown_partition_data_is_error(client_for, content): + verdict = health._disk_space(client_for(_disk_handler([{"content": content}])))[0] + assert (verdict.applicability, verdict.execution, verdict.finding) == ( + "unknown", + "error", + "fail", + ) + + +def test_disk_space_valid_zero_free_warns(client_for): + verdict = health._disk_space( + client_for(_disk_handler([{"content": {"capacity": "100", "free": "0"}}])) + )[0] + assert (verdict.execution, verdict.finding) == ("completed", "warn") + + def test_internal_errors_high_count_warns(client_for): # error_count comes back from Splunk as a string; the check must coerce it. def handler(req: httpx.Request) -> httpx.Response: @@ -99,6 +178,37 @@ def handler(req: httpx.Request) -> httpx.Response: assert verdicts["internal_errors"].finding == "pass" +@pytest.mark.parametrize( + "body", + [ + {}, + {"results": []}, + {"results": [{}]}, + {"results": [{"error_count": ""}]}, + {"results": [{"error_count": "garbage"}]}, + {"results": [{"error_count": "nan"}]}, + ], +) +def test_internal_errors_unknown_data_is_error(client_for, body): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=body) + + verdict = health._internal_errors(client_for(handler))[0] + assert (verdict.applicability, verdict.execution, verdict.finding) == ( + "unknown", + "error", + "fail", + ) + + +def test_internal_errors_valid_zero_passes(client_for): + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"results": [{"error_count": "0"}]}) + + verdict = health._internal_errors(client_for(handler))[0] + assert (verdict.execution, verdict.finding) == ("completed", "pass") + + def test_health_red_maps_to_fail(client_for): def handler(req: httpx.Request) -> httpx.Response: if req.url.path.endswith("/server/info"): diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index dbfb301..d8c71f4 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -2,6 +2,11 @@ from __future__ import annotations +import os + +import pytest + +from vct_splunk.core.errors import UsageError from vct_splunk.core.profiles import config_path, load_profile @@ -25,6 +30,7 @@ def test_load_profile_reads_recognized_keys(tmp_path, monkeypatch): "owner = nobody\n" "ignored = nope\n" ) + cfgfile.chmod(0o600) monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) got = load_profile("prod") assert got == { @@ -51,3 +57,21 @@ def test_config_path_falls_back_to_xdg(tmp_path, monkeypatch): monkeypatch.delenv("VCT_SPLUNK_CONFIG", raising=False) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) assert config_path() == tmp_path / "vct-splunk" / "config" + + +def test_profile_credentials_allow_percent_characters(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod]\ntoken = value%with%percent\n") + cfgfile.chmod(0o600) + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + assert load_profile("prod")["token"] == "value%with%percent" + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits required") +def test_secret_profile_rejects_group_or_world_access(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod]\ntoken = secret\n") + cfgfile.chmod(0o644) + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + with pytest.raises(UsageError, match="mode 0600"): + load_profile("prod") From 4df34f1063f5a2d88f3bc4ce9819416b9904fe4f Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:56:12 -0400 Subject: [PATCH 31/46] fix: harden KV and platform control contracts --- src/vct_splunk/commands/kvstore.py | 19 +- src/vct_splunk/commands/server.py | 6 +- src/vct_splunk/core/cluster.py | 52 ++--- src/vct_splunk/core/jobs.py | 11 +- src/vct_splunk/core/kvstore.py | 28 +-- src/vct_splunk/core/license.py | 7 +- src/vct_splunk/core/namespace.py | 6 +- src/vct_splunk/core/parsing.py | 22 ++ src/vct_splunk/core/path.py | 36 ++++ src/vct_splunk/core/saved_searches.py | 10 +- src/vct_splunk/core/server.py | 36 +++- tests/unit/test_cli_matrix.py | 18 ++ tests/unit/test_commands.py | 10 +- tests/unit/test_kvstore.py | 99 ++++++++- tests/unit/test_namespace.py | 7 + tests/unit/test_platform_controls.py | 300 ++++++++++++++++++++++++++ 16 files changed, 595 insertions(+), 72 deletions(-) create mode 100644 src/vct_splunk/core/parsing.py create mode 100644 src/vct_splunk/core/path.py create mode 100644 tests/unit/test_platform_controls.py diff --git a/src/vct_splunk/commands/kvstore.py b/src/vct_splunk/commands/kvstore.py index 51e78a5..4a12199 100644 --- a/src/vct_splunk/commands/kvstore.py +++ b/src/vct_splunk/commands/kvstore.py @@ -4,9 +4,8 @@ document store. The collection *schema* (creating the collection and its fields) is a separate group, ``kvstore-collection``. -Records are namespaced: reads default to the ``-`` wildcard (all owners/apps); -writes require an explicit ``--app`` (or ``$SPLUNK_APP``) so a record is never -written into the default ``search`` app by accident. +Every operation requires an explicit ``--app`` (or ``$SPLUNK_APP``) and defaults +to owner ``nobody`` because Splunk does not support wildcard KV data namespaces. """ from __future__ import annotations @@ -19,6 +18,7 @@ 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 .context import command from .write import do_write @@ -47,7 +47,8 @@ def _parse_doc(data: str) -> dict[str, Any]: @command def records(ctx, collection, query, limit) -> None: """List records in a collection (use --query/--limit to narrow).""" - owner, app = resolve_ns(ctx.owner, ctx.app, for_write=False) + path_segment(collection, label="collection") + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) with ctx.client() as c: data = core.list_records(c, collection, owner=owner, app=app, query=query, limit=limit) out.emit(data, ctx.output_mode, ctx.meta()) @@ -59,7 +60,9 @@ def records(ctx, collection, query, limit) -> None: @command def get(ctx, collection, key) -> None: """Show one record by its _key.""" - owner, app = resolve_ns(ctx.owner, ctx.app, for_write=False) + path_segment(collection, label="collection") + path_segment(key, label="record key") + owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) with ctx.client() as c: data = core.get_record(c, collection, key, owner=owner, app=app) out.emit(data, ctx.output_mode, ctx.meta()) @@ -71,6 +74,7 @@ def get(ctx, collection, key) -> None: @command def insert(ctx, collection, data) -> None: """Insert a record (JSON document). Gated write; requires an app.""" + path_segment(collection, label="collection") owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) document = _parse_doc(data) result = do_write( @@ -89,6 +93,8 @@ def insert(ctx, collection, data) -> None: @command def update(ctx, collection, key, data) -> None: """Replace a record by its _key (JSON document). Gated write; requires an app.""" + path_segment(collection, label="collection") + path_segment(key, label="record key") owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) document = _parse_doc(data) result = do_write( @@ -111,6 +117,8 @@ def update(ctx, collection, key, data) -> None: @command def delete(ctx, collection, key) -> None: """Delete one record by its _key. Gated write; requires an app.""" + path_segment(collection, label="collection") + path_segment(key, label="record key") owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) result = do_write( ctx, @@ -131,6 +139,7 @@ def delete(ctx, collection, key) -> None: @command def purge(ctx, collection) -> None: """Delete ALL records in a collection (the schema is kept). Gated write; requires an app.""" + path_segment(collection, label="collection") owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) result = do_write( ctx, diff --git a/src/vct_splunk/commands/server.py b/src/vct_splunk/commands/server.py index 5a28875..688f963 100644 --- a/src/vct_splunk/commands/server.py +++ b/src/vct_splunk/commands/server.py @@ -6,6 +6,7 @@ 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 .context import command from .write import do_write @@ -61,10 +62,7 @@ def settings_set(ctx, _set) -> None: """Change server settings (only the keys you pass). Gated write.""" if not _set: raise UsageError("Nothing to set. Pass at least one --set KEY=VALUE.") - changes: dict[str, str] = {} - for pair in _set: - key, _, val = pair.partition("=") - changes[key] = val + changes = parse_key_value_pairs(_set) result = do_write( ctx, action=f"change server settings: {', '.join(sorted(changes))}", diff --git a/src/vct_splunk/core/cluster.py b/src/vct_splunk/core/cluster.py index 56cda97..9e9ebc5 100644 --- a/src/vct_splunk/core/cluster.py +++ b/src/vct_splunk/core/cluster.py @@ -10,46 +10,36 @@ from typing import Any from .client import SplunkClient +from .errors import NotFoundError def cluster_status(client: SplunkClient) -> dict[str, Any]: - """Summarize indexer-cluster manager state plus peer health. - - Reads ``/services/cluster/config`` for the local node's cluster config and - ``/services/cluster/master/info`` for manager-side status. Either may be - empty on a node that is not a cluster manager; missing pieces are reported - as null rather than failing. - """ - config = _first_content(client.get("/services/cluster/config")) - info = _first_content(client.get("/services/cluster/master/info")) + """Summarize indexer-cluster manager state, or report it as not configured.""" + try: + info = _first_content(client.get("/services/cluster/manager/info")) + except NotFoundError: + return {"configured": False} + if not info: + return {"configured": False} return { - "mode": config.get("mode"), - "manager_uri": config.get("manager_uri") or config.get("master_uri"), - "replication_factor": config.get("replication_factor"), - "search_factor": config.get("search_factor"), + "configured": True, + "label": info.get("label"), + "replication_factor": info.get("replication_factor"), + "search_factor": info.get("search_factor"), "indexing_ready": info.get("indexing_ready_flag"), "maintenance_mode": info.get("maintenance_mode"), } -def shcluster_status(client: SplunkClient) -> list[dict[str, Any]]: - """List search-head cluster members and their roles. - - Reads ``/services/shcluster/member/members``; each entry is one member of - the search-head cluster. - """ - return [_member(e) for e in client.get_collection("/services/shcluster/member/members")] - - -def _member(entry: dict[str, Any]) -> dict[str, Any]: - c = entry.get("content") or {} - return { - "name": entry.get("name"), - "label": c.get("label"), - "status": c.get("status"), - "is_captain": c.get("is_captain"), - "site": c.get("site"), - } +def shcluster_status(client: SplunkClient) -> dict[str, Any]: + """Return search-head-cluster state, or report it as not configured.""" + try: + status = _first_content(client.get("/services/shcluster/status")) + except NotFoundError: + return {"configured": False} + if not status: + return {"configured": False} + return {"configured": True, **status} def _first_content(body: dict[str, Any]) -> dict[str, Any]: diff --git a/src/vct_splunk/core/jobs.py b/src/vct_splunk/core/jobs.py index 126df82..b5dd978 100644 --- a/src/vct_splunk/core/jobs.py +++ b/src/vct_splunk/core/jobs.py @@ -10,6 +10,7 @@ from .client import SplunkClient from .errors import NotFoundError +from .path import path_segment from .search import JOBS_PATH @@ -24,7 +25,9 @@ def get_job(client: SplunkClient, sid: str) -> dict[str, Any]: Raises: NotFoundError: If no job has that SID. """ - entries = client.get(f"{JOBS_PATH}/{sid}").get("entry") or [] + entries = ( + client.get(f"{JOBS_PATH}/{path_segment(sid, label='search job ID')}").get("entry") or [] + ) if not entries: raise NotFoundError(f"Search job {sid!r} not found.") return _job(entries[0]) @@ -36,7 +39,11 @@ def cancel_job(client: SplunkClient, sid: str) -> dict[str, Any]: A POST to the job's ``control`` endpoint with ``action=cancel``. Routed through the client's ``write`` so ``--dry-run`` previews it and sends nothing. """ - return client.write("POST", f"{JOBS_PATH}/{sid}/control", {"action": "cancel"}) + return client.write( + "POST", + f"{JOBS_PATH}/{path_segment(sid, label='search job ID')}/control", + {"action": "cancel"}, + ) def _job(entry: dict[str, Any]) -> dict[str, Any]: diff --git a/src/vct_splunk/core/kvstore.py b/src/vct_splunk/core/kvstore.py index 5aa7d3a..b2653d1 100644 --- a/src/vct_splunk/core/kvstore.py +++ b/src/vct_splunk/core/kvstore.py @@ -8,9 +8,7 @@ *schema* is a separate, CRUD-shaped resource (the ``kvstore-collection`` factory group); this module only touches the records inside a collection. -Every call is namespaced; the command layer resolves ``owner``/``app`` via -:func:`vct_splunk.core.namespace.resolve_ns` (reads default to the ``-`` -wildcard, writes require an explicit app). +Every call requires an explicit app and defaults to owner ``nobody``. """ from __future__ import annotations @@ -20,10 +18,18 @@ from .client import SplunkClient from .errors import NotFoundError from .namespace import ns_path +from .path import path_segment _DATA = "storage/collections/data" +def _path(collection: str, *, owner: str, app: str, key: str | None = None) -> str: + suffix = f"{_DATA}/{path_segment(collection, label='collection')}" + if key is not None: + suffix += f"/{path_segment(key, label='record key')}" + return ns_path(suffix, owner=owner, app=app) + + def list_records( client: SplunkClient, collection: str, @@ -43,7 +49,7 @@ def list_records( params["query"] = query if limit is not None: params["limit"] = limit - return client.get_json(ns_path(f"{_DATA}/{collection}", owner=owner, app=app), params or None) + return client.get_json(_path(collection, owner=owner, app=app), params or None) def get_record(client: SplunkClient, collection: str, key: str, *, owner: str, app: str) -> Any: @@ -53,7 +59,7 @@ def get_record(client: SplunkClient, collection: str, key: str, *, owner: str, a NotFoundError: If the record does not exist (a 404 already maps to NotFoundError in the client; an empty body is treated the same). """ - record = client.get_json(ns_path(f"{_DATA}/{collection}/{key}", owner=owner, app=app)) + record = client.get_json(_path(collection, key=key, owner=owner, app=app)) if not record: raise NotFoundError(f"Record {key!r} not found in collection {collection!r}.") return record @@ -63,9 +69,7 @@ def insert_record( client: SplunkClient, collection: str, document: dict[str, Any], *, owner: str, app: str ) -> Any: """Insert a record (JSON body). Splunk returns ``{"_key": "..."}``.""" - return client.write_json( - "POST", ns_path(f"{_DATA}/{collection}", owner=owner, app=app), document - ) + return client.write_json("POST", _path(collection, owner=owner, app=app), document) def update_record( @@ -78,16 +82,14 @@ def update_record( app: str, ) -> Any: """Replace the record at ``key`` with ``document`` (JSON body).""" - return client.write_json( - "POST", ns_path(f"{_DATA}/{collection}/{key}", owner=owner, app=app), document - ) + return client.write_json("POST", _path(collection, key=key, owner=owner, app=app), document) def delete_record(client: SplunkClient, collection: str, key: str, *, owner: str, app: str) -> Any: """Delete one record by its ``_key``.""" - return client.write("DELETE", ns_path(f"{_DATA}/{collection}/{key}", owner=owner, app=app), {}) + return client.write("DELETE", _path(collection, key=key, owner=owner, app=app), {}) def delete_all(client: SplunkClient, collection: str, *, owner: str, app: str) -> Any: """Delete *every* record in the collection (the schema is left intact).""" - return client.write("DELETE", ns_path(f"{_DATA}/{collection}", owner=owner, app=app), {}) + return client.write("DELETE", _path(collection, owner=owner, app=app), {}) diff --git a/src/vct_splunk/core/license.py b/src/vct_splunk/core/license.py index ab398e3..b394486 100644 --- a/src/vct_splunk/core/license.py +++ b/src/vct_splunk/core/license.py @@ -10,6 +10,7 @@ from .client import SplunkClient from .errors import NotFoundError +from .path import path_segment _LICENSES = "/services/licenser/licenses" _POOLS = "/services/licenser/pools" @@ -22,7 +23,9 @@ def list_licenses(client: SplunkClient) -> list[dict[str, Any]]: def get_license(client: SplunkClient, name: str) -> dict[str, Any]: """Show one license by its name (license hash).""" - entries = client.get(f"{_LICENSES}/{name}").get("entry") or [] + entries = ( + client.get(f"{_LICENSES}/{path_segment(name, label='license name')}").get("entry") or [] + ) if not entries: raise NotFoundError(f"License {name!r} not found.") return _license(entries[0]) @@ -50,6 +53,6 @@ def _pool(entry: dict[str, Any]) -> dict[str, Any]: return { "name": entry.get("name"), "stack_id": c.get("stack_id"), - "quota_bytes": c.get("quota"), + "quota_bytes": c.get("effective_quota"), "used_bytes": c.get("used_bytes"), } diff --git a/src/vct_splunk/core/namespace.py b/src/vct_splunk/core/namespace.py index c47af34..3a13385 100644 --- a/src/vct_splunk/core/namespace.py +++ b/src/vct_splunk/core/namespace.py @@ -18,6 +18,7 @@ from __future__ import annotations from .errors import UsageError +from .path import path_segment #: Splunk wildcard for owner or app — matches across every namespace. WILDCARD = "-" @@ -42,7 +43,10 @@ def ns_path(suffix: str, *, owner: str, app: str) -> str: """ if not owner or not app: raise UsageError("A namespace needs both an owner and an app.") - return f"/servicesNS/{owner}/{app}/{suffix.lstrip('/')}" + return ( + f"/servicesNS/{path_segment(owner, label='owner')}/" + f"{path_segment(app, label='app')}/{suffix.lstrip('/')}" + ) def resolve_ns(owner: str | None, app: str | None, *, for_write: bool) -> tuple[str, str]: diff --git a/src/vct_splunk/core/parsing.py b/src/vct_splunk/core/parsing.py new file mode 100644 index 0000000..470f16d --- /dev/null +++ b/src/vct_splunk/core/parsing.py @@ -0,0 +1,22 @@ +"""Strict parsing helpers shared by command inputs.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from .errors import UsageError + + +def parse_key_value_pairs(pairs: Iterable[str]) -> dict[str, str]: + """Parse unique, non-empty ``KEY=VALUE`` pairs while preserving empty values.""" + parsed: dict[str, str] = {} + for pair in pairs: + if "=" not in pair: + raise UsageError(f"Expected KEY=VALUE (got {pair!r}).") + key, value = pair.split("=", 1) + if not key: + raise UsageError("Expected a non-empty key before '='.") + if key in parsed: + raise UsageError(f"Duplicate key {key!r}.") + parsed[key] = value + return parsed diff --git a/src/vct_splunk/core/path.py b/src/vct_splunk/core/path.py new file mode 100644 index 0000000..d674200 --- /dev/null +++ b/src/vct_splunk/core/path.py @@ -0,0 +1,36 @@ +"""Validation and encoding for values inserted into REST path segments.""" + +from __future__ import annotations + +from urllib.parse import quote, unquote + +from .errors import UsageError + + +def path_segment(value: str, *, label: str = "path segment") -> str: + """Validate and percent-encode one dynamic REST path segment. + + Encoded traversal forms are rejected as well as literal separators so a + caller cannot smuggle a second segment through URL decoding. + """ + if not value: + raise UsageError(f"{label.capitalize()} cannot be empty.") + + candidate = value + while True: + _reject_unsafe(candidate, label) + decoded = unquote(candidate) + if decoded == candidate: + break + candidate = decoded + + return quote(value, safe="") + + +def _reject_unsafe(value: str, label: str) -> None: + if value in {".", ".."}: + raise UsageError(f"{label.capitalize()} cannot be a dot segment.") + if "/" in value or "\\" in value: + raise UsageError(f"{label.capitalize()} cannot contain a path separator.") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise UsageError(f"{label.capitalize()} cannot contain control characters.") diff --git a/src/vct_splunk/core/saved_searches.py b/src/vct_splunk/core/saved_searches.py index f5935ea..7628618 100644 --- a/src/vct_splunk/core/saved_searches.py +++ b/src/vct_splunk/core/saved_searches.py @@ -15,6 +15,7 @@ from .client import SplunkClient from .namespace import ns_path +from .path import path_segment _SUFFIX = "saved/searches" @@ -56,7 +57,14 @@ def dispatch_saved_search( ``search get``. """ data = build_dispatch_payload(trigger_actions=trigger_actions, earliest=earliest, latest=latest) - body = client.post(ns_path(f"{_SUFFIX}/{name}/dispatch", owner=owner, app=app), data) + body = client.post( + ns_path( + f"{_SUFFIX}/{path_segment(name, label='saved search name')}/dispatch", + owner=owner, + app=app, + ), + data, + ) sid = body.get("sid") if isinstance(body, dict) else None if not sid and isinstance(body, dict): entries = body.get("entry") or [] diff --git a/src/vct_splunk/core/server.py b/src/vct_splunk/core/server.py index 9aede42..3810f94 100644 --- a/src/vct_splunk/core/server.py +++ b/src/vct_splunk/core/server.py @@ -5,7 +5,9 @@ from typing import Any from .client import SplunkClient -from .errors import UsageError +from .errors import APIError, UsageError + +_REDACTED = "" def get_server_info(client: SplunkClient) -> dict[str, Any]: @@ -39,13 +41,37 @@ def get_settings(client: SplunkClient) -> dict[str, Any]: """Show the server's general settings.""" body = client.get("/services/server/settings/settings") entries = body.get("entry") or [] - return entries[0].get("content") or {} if entries else {} + content = entries[0].get("content") or {} if entries else {} + return _redact_settings(content) def set_settings(client: SplunkClient, settings: dict[str, Any]) -> dict[str, Any]: """Apply changed server settings (form keys); a gated write.""" - result = client.write("POST", "/services/server/settings/settings", settings) + try: + result = client.write("POST", "/services/server/settings/settings", settings) + except APIError as exc: + raise APIError(exc.message, details=_redact_settings(exc.details)) from exc if result.get("dry_run"): - return result + return _redact_settings(result) entries = result.get("entry") or [] - return entries[0].get("content") or {} if entries else result + content = entries[0].get("content") or {} if entries else result + return _redact_settings(content) + + +def _redact_settings(value: Any) -> Any: + """Recursively replace secret-bearing server-setting values.""" + if isinstance(value, dict): + return { + key: _REDACTED if _secret_setting(key) else _redact_settings(item) + for key, item in value.items() + } + if isinstance(value, list): + return [_redact_settings(item) for item in value] + return value + + +def _secret_setting(key: object) -> bool: + normalized = str(key).casefold().replace("_", "").replace("-", "") + return any( + marker in normalized for marker in ("pass4symmkey", "password", "passwd", "secret", "token") + ) diff --git a/tests/unit/test_cli_matrix.py b/tests/unit/test_cli_matrix.py index aaf9b24..50867e0 100644 --- a/tests/unit/test_cli_matrix.py +++ b/tests/unit/test_cli_matrix.py @@ -28,6 +28,7 @@ _SPECIAL: dict[tuple[str, ...], list[str]] = { ("auth", "login"): ["--username", "admin"], ("auth", "status"): [], + ("cluster", "status"): [], ("server", "info"): [], ("api", "get"): ["/services/server/info", "-q", "count=1"], ("search", "run"): ["--query", "index=_internal", "--earliest", "-1h", "--max-rows", "5"], @@ -37,6 +38,23 @@ ("saved-search", "run"): ["nightly", "--app", "my_app", "--earliest", "-1h"], ("health", "check"): [], ("inspect",): [], + ("kvstore", "records"): ["records"], + ("kvstore", "get"): ["records", "key"], + ("kvstore", "insert"): ["records", "--data", '{"value":"x"}', "--dry-run"], + ("kvstore", "update"): [ + "records", + "key", + "--data", + '{"value":"x"}', + "--dry-run", + ], + ("kvstore", "delete"): ["records", "key", "--dry-run"], + ("kvstore", "purge"): ["records", "--dry-run"], + ("license", "usage"): [], + ("server", "restart"): ["--dry-run"], + ("server", "settings", "get"): [], + ("server", "settings", "set"): ["--set", "host=x", "--dry-run"], + ("shcluster", "status"): [], } # Generic argument rules by CRUD verb (factory-generated and factory-shaped groups). diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 036b41c..c0abf08 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -158,14 +158,16 @@ def test_saved_search_create_dry_run_previews_app_namespace(cli_env): def test_cluster_status_renders(cli_env, patch_client): def handler(req: httpx.Request) -> httpx.Response: - if req.url.path == "/services/cluster/config": - return httpx.Response(200, json={"entry": [{"content": {"mode": "manager"}}]}) - return httpx.Response(200, json={"entry": [{"content": {"indexing_ready_flag": True}}]}) + assert req.url.path == "/services/cluster/manager/info" + return httpx.Response( + 200, + json={"entry": [{"content": {"label": "cluster-one", "indexing_ready_flag": True}}]}, + ) patch_client(handler) result = CliRunner().invoke(cli, ["cluster", "status", "--output", "json"]) assert result.exit_code == 0 - assert '"mode": "manager"' in result.output + assert '"label": "cluster-one"' in result.output def test_license_list_renders(cli_env, patch_client): diff --git a/tests/unit/test_kvstore.py b/tests/unit/test_kvstore.py index 0cebd1f..320e837 100644 --- a/tests/unit/test_kvstore.py +++ b/tests/unit/test_kvstore.py @@ -8,10 +8,20 @@ from __future__ import annotations import httpx +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 ( + delete_all, + delete_record, + get_record, + insert_record, + list_records, + update_record, +) def _env(monkeypatch): @@ -30,7 +40,14 @@ def make(self): monkeypatch.setattr("vct_splunk.commands.context.Ctx.client", make) -def test_records_lists_with_read_wildcard_namespace(monkeypatch): +def test_records_requires_app(monkeypatch): + _env(monkeypatch) + result = CliRunner().invoke(cli, ["kvstore", "records", "things", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + +def test_records_uses_shared_owner_and_explicit_app(monkeypatch): _env(monkeypatch) seen: dict[str, str] = {} @@ -39,16 +56,20 @@ def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"_key": "a", "x": 1}]) _patch_client(monkeypatch, handler) - result = CliRunner().invoke(cli, ["kvstore", "records", "things", "--output", "json"]) + result = CliRunner().invoke( + cli, ["kvstore", "records", "things", "--app", "my_app", "--output", "json"] + ) assert result.exit_code == 0 assert '"_key": "a"' in result.output - assert seen["path"] == "/servicesNS/-/-/storage/collections/data/things" + assert seen["path"] == "/servicesNS/nobody/my_app/storage/collections/data/things" def test_get_returns_one_record(monkeypatch): _env(monkeypatch) _patch_client(monkeypatch, lambda req: httpx.Response(200, json={"_key": "a", "x": 1})) - result = CliRunner().invoke(cli, ["kvstore", "get", "things", "a", "--output", "json"]) + result = CliRunner().invoke( + cli, ["kvstore", "get", "things", "a", "--app", "my_app", "--output", "json"] + ) assert result.exit_code == 0 assert '"_key": "a"' in result.output @@ -104,3 +125,73 @@ def test_insert_rejects_bad_json(monkeypatch): ) assert result.exit_code == 2 assert "usage_error" in result.output + + +@pytest.mark.parametrize("value", [".", "..", "a/b", "a\\b", "%2fetc", "%25252e%25252e", "a\nb"]) +def test_rejected_collection_sends_no_request(monkeypatch, value): + _env(monkeypatch) + requests: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + requests.append(req) + return httpx.Response(200, json=[]) + + _patch_client(monkeypatch, handler) + result = CliRunner().invoke( + cli, ["kvstore", "records", value, "--app", "my_app", "--output", "json"] + ) + assert result.exit_code == 2 + assert requests == [] + + +def test_kvstore_exact_wire_contracts_and_encoding(client_for): + seen: list[tuple[str, str, dict[str, str], bytes]] = [] + + def handler(req: httpx.Request) -> httpx.Response: + raw_path = req.url.raw_path.split(b"?", 1)[0].decode() + seen.append((req.method, raw_path, dict(req.url.params), req.content)) + if req.method == "GET": + record = {"_key": "a b"} if req.url.path.endswith("a b") else [] + return httpx.Response(200, json=record) + return httpx.Response(200, json={"ok": True}) + + client = client_for(handler) + assert ( + list_records(client, "my things", owner="nobody", app="my app", query='{"x":1}', limit=3) + == [] + ) + assert get_record(client, "my things", "a b", owner="nobody", app="my app") == {"_key": "a b"} + assert insert_record(client, "my things", {"x": 1}, owner="nobody", app="my app") == { + "ok": True + } + assert update_record(client, "my things", "a b", {"x": 2}, owner="nobody", app="my app") == { + "ok": True + } + assert delete_record(client, "my things", "a b", owner="nobody", app="my app") == {"ok": True} + assert delete_all(client, "my things", owner="nobody", app="my app") == {"ok": True} + + base = "/servicesNS/nobody/my%20app/storage/collections/data/my%20things" + assert seen == [ + ("GET", base, {"query": '{"x":1}', "limit": "3", "output_mode": "json"}, b""), + ("GET", f"{base}/a%20b", {"output_mode": "json"}, b""), + ("POST", base, {}, b'{"x":1}'), + ("POST", f"{base}/a%20b", {}, b'{"x":2}'), + ("DELETE", f"{base}/a%20b", {"output_mode": "json"}, b""), + ("DELETE", base, {"output_mode": "json"}, b""), + ] + + +@pytest.mark.parametrize("value", ["..", "a/b", "a\\b", "%2e%2e", "%25252fetc", "\x00"]) +def test_core_rejects_traversal_before_request(client_for, value): + requests: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + requests.append(req) + return httpx.Response(200, json=[]) + + client = client_for(handler) + with pytest.raises(UsageError): + list_records(client, value, owner="nobody", app="my_app") + with pytest.raises(UsageError): + get_record(client, "things", value, owner="nobody", app="my_app") + assert requests == [] diff --git a/tests/unit/test_namespace.py b/tests/unit/test_namespace.py index 4600255..15572c5 100644 --- a/tests/unit/test_namespace.py +++ b/tests/unit/test_namespace.py @@ -19,6 +19,13 @@ def test_ns_path_requires_owner_and_app(): ns_path("saved/searches", owner="", app="my_app") +def test_ns_path_encodes_dynamic_namespace_segments(): + assert ( + ns_path("saved/searches", owner="first last", app="my app") + == "/servicesNS/first%20last/my%20app/saved/searches" + ) + + def test_resolve_ns_read_defaults_to_wildcard(): assert resolve_ns(None, None, for_write=False) == ("-", "-") assert resolve_ns("alice", None, for_write=False) == ("alice", "-") diff --git a/tests/unit/test_platform_controls.py b/tests/unit/test_platform_controls.py new file mode 100644 index 0000000..f30fe82 --- /dev/null +++ b/tests/unit/test_platform_controls.py @@ -0,0 +1,300 @@ +"""Exact platform-control contracts and normalization.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +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 + + +def test_cluster_status_uses_manager_info(cli_env, patch_client): + seen: list[str] = [] + + def handler(req: httpx.Request) -> httpx.Response: + seen.append(req.url.path) + return httpx.Response( + 200, + json={ + "entry": [ + { + "content": { + "label": "cluster-one", + "replication_factor": 3, + "search_factor": 2, + "indexing_ready_flag": True, + "maintenance_mode": False, + } + } + ] + }, + ) + + patch_client(handler) + result = CliRunner().invoke(cli, ["cluster", "status", "--output", "json"]) + + assert result.exit_code == 0 + assert seen == ["/services/cluster/manager/info"] + assert json.loads(result.output)["data"] == { + "configured": True, + "label": "cluster-one", + "replication_factor": 3, + "search_factor": 2, + "indexing_ready": True, + "maintenance_mode": False, + } + + +@pytest.mark.parametrize("status", [200, 404]) +def test_cluster_status_normalizes_standalone(cli_env, patch_client, status): + patch_client( + lambda req: ( + httpx.Response(status, json={"entry": []}) + if status == 200 + else httpx.Response(status, json={"messages": []}) + ) + ) + result = CliRunner().invoke(cli, ["cluster", "status", "--output", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.output)["data"] == {"configured": False} + + +def test_shcluster_status_uses_status_endpoint(cli_env, patch_client): + seen: list[str] = [] + + def handler(req: httpx.Request) -> httpx.Response: + seen.append(req.url.path) + return httpx.Response( + 200, + json={ + "entry": [ + { + "content": { + "captain": {"label": "sh1"}, + "members": {"guid-1": {"status": "Up"}}, + } + } + ] + }, + ) + + patch_client(handler) + result = CliRunner().invoke(cli, ["shcluster", "status", "--output", "json"]) + + assert result.exit_code == 0 + assert seen == ["/services/shcluster/status"] + assert json.loads(result.output)["data"] == { + "configured": True, + "captain": {"label": "sh1"}, + "members": {"guid-1": {"status": "Up"}}, + } + + +@pytest.mark.parametrize("status", [200, 404]) +def test_shcluster_status_normalizes_standalone(cli_env, patch_client, status): + patch_client( + lambda req: ( + httpx.Response(status, json={"entry": []}) + if status == 200 + else httpx.Response(status, json={"messages": []}) + ) + ) + result = CliRunner().invoke(cli, ["shcluster", "status", "--output", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.output)["data"] == {"configured": False} + + +def test_license_usage_uses_documented_fields(cli_env, patch_client): + patch_client( + lambda req: httpx.Response( + 200, + json={ + "entry": [ + { + "name": "pool-one", + "content": { + "stack_id": "enterprise", + "effective_quota": 1000, + "quota": 9999, + "used_bytes": 250, + }, + }, + {"name": "pool-two", "content": {}}, + ] + }, + ) + ) + result = CliRunner().invoke(cli, ["license", "usage", "--output", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.output)["data"] == [ + { + "name": "pool-one", + "stack_id": "enterprise", + "quota_bytes": 1000, + "used_bytes": 250, + }, + { + "name": "pool-two", + "stack_id": None, + "quota_bytes": None, + "used_bytes": None, + }, + ] + + +@pytest.mark.parametrize( + ("pairs", "message"), + [ + (["broken"], "Expected KEY=VALUE"), + (["=value"], "non-empty key"), + (["host=one", "host=two"], "Duplicate key"), + ], +) +def test_key_value_parser_rejects_invalid_pairs(pairs, message): + with pytest.raises(UsageError, match=message): + parse_key_value_pairs(pairs) + + +def test_key_value_parser_preserves_explicit_empty_value(): + assert parse_key_value_pairs(["host="]) == {"host": ""} + + +def test_server_settings_set_rejects_invalid_pairs_before_request(cli_env, patch_client): + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("invalid settings must not send a request") + + patch_client(handler) + result = CliRunner().invoke( + cli, + [ + "server", + "settings", + "set", + "--set", + "host=one", + "--set", + "host=two", + "--yes", + "--output", + "json", + ], + ) + + assert result.exit_code == 2 + assert "Duplicate key" in result.output + + +def test_server_settings_get_redacts_secrets(cli_env, patch_client): + patch_client( + lambda req: httpx.Response( + 200, + json={ + "entry": [ + { + "content": { + "host": "sh1", + "pass4SymmKey": "top-secret", + "sslKeysfilePassword": "also-secret", + } + } + ] + }, + ) + ) + result = CliRunner().invoke(cli, ["server", "settings", "get", "--output", "json"]) + + assert result.exit_code == 0 + assert "top-secret" not in result.output + assert "also-secret" not in result.output + assert json.loads(result.output)["data"] == { + "host": "sh1", + "pass4SymmKey": "", + "sslKeysfilePassword": "", + } + + +def test_server_settings_dry_run_redacts_secret_values(cli_env): + result = CliRunner().invoke( + cli, + [ + "server", + "settings", + "set", + "--set", + "pass4SymmKey=top-secret", + "--dry-run", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + assert "top-secret" not in result.output + assert json.loads(result.output)["data"]["request"]["body"]["pass4SymmKey"] == "" + + +def test_server_settings_set_redacts_success_response(cli_env, patch_client): + def handler(req: httpx.Request) -> httpx.Response: + assert req.method == "POST" + assert req.url.path == "/services/server/settings/settings" + assert req.content.decode() == "host=sh1" + return httpx.Response( + 200, + json={"entry": [{"content": {"host": "sh1", "pass4SymmKey": "top-secret"}}]}, + ) + + patch_client(handler) + result = CliRunner().invoke( + cli, + [ + "server", + "settings", + "set", + "--set", + "host=sh1", + "--yes", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + assert "top-secret" not in result.output + assert json.loads(result.output)["data"]["pass4SymmKey"] == "" + + +def test_server_settings_set_redacts_api_error_details(cli_env, patch_client): + patch_client( + lambda req: httpx.Response( + 500, + json={ + "messages": [{"type": "ERROR", "text": "rejected"}], + "pass4SymmKey": "top-secret", + }, + ) + ) + result = CliRunner().invoke( + cli, + [ + "server", + "settings", + "set", + "--set", + "host=sh1", + "--yes", + "--output", + "json", + ], + ) + + assert result.exit_code == 1 + assert "top-secret" not in result.output + assert "" in result.output From 377e06a4e7e946dabaede0d907bd39cf9389fd0d Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:00:40 -0400 Subject: [PATCH 32/46] fix: correct app and deployment server contracts --- src/vct_splunk/commands/apps.py | 42 ++++-- src/vct_splunk/commands/deploy.py | 20 +-- src/vct_splunk/core/apps.py | 17 +-- src/vct_splunk/core/deploy.py | 8 +- tests/unit/test_app_deploy_contracts.py | 169 ++++++++++++++++++++++++ tests/unit/test_cli_matrix.py | 17 +++ tests/unit/test_commands.py | 2 +- 7 files changed, 238 insertions(+), 37 deletions(-) create mode 100644 tests/unit/test_app_deploy_contracts.py diff --git a/src/vct_splunk/commands/apps.py b/src/vct_splunk/commands/apps.py index 7bd23ef..9d09cca 100644 --- a/src/vct_splunk/commands/apps.py +++ b/src/vct_splunk/commands/apps.py @@ -7,6 +7,8 @@ from __future__ import annotations +from urllib.parse import urlsplit, urlunsplit + import click from ..core import apps as core @@ -17,19 +19,41 @@ @click.command("install") -@click.option("--file", "file", default=None, help="Local app archive path (.tar.gz/.spl).") -@click.option("--url", "url", default=None, help="http(s) URL to an app archive.") +@click.option( + "--server-file", + default=None, + help="App archive path readable by splunkd on the server (.tar.gz/.spl).", +) +@click.option("--url", default=None, help="http(s) app archive URL reachable by splunkd.") @click.option("--update/--no-update", default=False, help="Overwrite an already-installed app.") @command -def app_install(ctx, file, url, update) -> None: - """Install an app from a local --file or a --url. Gated write.""" - if bool(file) == bool(url): - raise UsageError("Pass exactly one of --file or --url.") - source = file or url +def app_install(ctx, server_file, url, update) -> None: + """Install an app from a splunkd-readable server path or URL. Gated write.""" + if bool(server_file) == bool(url): + raise UsageError("Pass exactly one of --server-file or --url.") + source = server_file or url + safe_source = _safe_source(source, is_url=bool(url)) result = do_write( ctx, - action=f"install app from '{source}'" + (" (overwrite)" if update else ""), - audit_event={"action": "app.install", "source": source, "update": update}, + action=f"install app from '{safe_source}'" + (" (overwrite)" if update else ""), + audit_event={"action": "app.install", "source": safe_source, "update": update}, run=lambda c: core.install_app(c, source, update=update), ) out.emit(result, ctx.output_mode, ctx.meta()) + + +def _safe_source(source: str, *, is_url: bool) -> str: + """Return only the non-secret URL components used in prompts and audit records.""" + if not is_url: + return source + parsed = urlsplit(source) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise UsageError("--url must be an absolute http(s) URL.") + host = parsed.hostname or "" + try: + port = parsed.port + except ValueError as exc: + raise UsageError(f"--url is invalid: {exc}") from exc + if port is not None: + host = f"{host}:{port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) diff --git a/src/vct_splunk/commands/deploy.py b/src/vct_splunk/commands/deploy.py index 7d241d7..91b1710 100644 --- a/src/vct_splunk/commands/deploy.py +++ b/src/vct_splunk/commands/deploy.py @@ -7,26 +7,17 @@ from __future__ import annotations -from typing import Any - 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 .context import command from .write import do_write -def _parse_sets(pairs: tuple[str, ...]) -> dict[str, Any]: - """Split repeated ``KEY=VALUE`` strings into a form-field dict.""" - out_: dict[str, Any] = {} - for pair in pairs: - key, _, val = pair.partition("=") - out_[key] = val - return out_ - - @click.group(name="deploy") def deploy() -> None: """Splunk deployment server (clients, server classes, config reload).""" @@ -65,6 +56,7 @@ def serverclass_list(ctx) -> None: @command def serverclass_get(ctx, name) -> None: """Show one server class by name.""" + path_segment(name, label="server class name") with ctx.client() as c: data = core.get_serverclass(c, name) out.emit(data, ctx.output_mode, ctx.meta()) @@ -78,7 +70,8 @@ def serverclass_create(ctx, name, _set) -> None: """Create a server class. Requires at least one --set. Gated write.""" if not _set: raise UsageError("Nothing to create. Pass at least one --set KEY=VALUE.") - settings = _parse_sets(_set) + path_segment(name, label="server class name") + settings = parse_key_value_pairs(_set) result = do_write( ctx, action=f"create server class '{name}'", @@ -96,7 +89,8 @@ def serverclass_update(ctx, name, _set) -> None: """Update a server class (only the keys you pass). Gated write.""" if not _set: raise UsageError("Nothing to update. Pass at least one --set KEY=VALUE.") - settings = _parse_sets(_set) + path_segment(name, label="server class name") + settings = parse_key_value_pairs(_set) result = do_write( ctx, action=f"update server class '{name}'", diff --git a/src/vct_splunk/core/apps.py b/src/vct_splunk/core/apps.py index 0d511c5..2a485db 100644 --- a/src/vct_splunk/core/apps.py +++ b/src/vct_splunk/core/apps.py @@ -4,15 +4,8 @@ the ``app`` spec. Install is the one operation that does not fit that shape, so it lives here. -Install approach: we POST to ``/services/apps/appinstall`` with ``name=``. -Splunk reads a local absolute path server-side and fetches an http(s) URL itself, -so a single form field covers both cases. This avoids a true multipart streaming -upload, which is finicky and version-dependent. - -ponytail: true multipart file upload (streaming the bytes to Splunk) can be added -here when a real need appears -- e.g. installing a file the Splunk host cannot -read off its own filesystem. Today the appinstall ``name=`` form covers both -local-path and URL installs in one line. +Splunk reads the supplied server path or URL itself. This command does not upload +bytes from the caller's filesystem. """ from __future__ import annotations @@ -21,13 +14,13 @@ from .client import SplunkClient -_PATH = "/services/apps/appinstall" +_PATH = "/services/apps/local" def install_app(client: SplunkClient, source: str, *, update: bool = False) -> dict[str, Any]: - """Install an app from a local path or an http(s) URL. + """Install an app from a server-readable path or an http(s) URL. - ``source`` is the local absolute path (``.tar.gz``/``.spl``) or the URL; + ``source`` is a server-side path (``.tar.gz``/``.spl``) or the URL; Splunk reads it server-side. ``update=True`` allows overwriting an app that is already installed. This is a gated write (dry-run aware via the client). """ diff --git a/src/vct_splunk/core/deploy.py b/src/vct_splunk/core/deploy.py index 4e835fd..3693e00 100644 --- a/src/vct_splunk/core/deploy.py +++ b/src/vct_splunk/core/deploy.py @@ -12,6 +12,7 @@ from .client import SplunkClient from .errors import NotFoundError +from .path import path_segment _CLIENTS = "/services/deployment/server/clients" _SERVERCLASSES = "/services/deployment/server/serverclasses" @@ -30,7 +31,8 @@ def list_serverclasses(client: SplunkClient) -> list[dict[str, Any]]: def get_serverclass(client: SplunkClient, name: str) -> dict[str, Any]: """Show one server class by name.""" - entries = client.get(f"{_SERVERCLASSES}/{name}").get("entry") or [] + encoded = path_segment(name, label="server class name") + entries = client.get(f"{_SERVERCLASSES}/{encoded}").get("entry") or [] if not entries: raise NotFoundError(f"Server class {name!r} not found.") return _named(entries[0]) @@ -38,12 +40,14 @@ def get_serverclass(client: SplunkClient, name: str) -> dict[str, Any]: def create_serverclass(client: SplunkClient, name: str, settings: dict[str, Any]) -> dict[str, Any]: """Create a server class with the given form settings (a gated write).""" + path_segment(name, label="server class name") return _unwrap(client.write("POST", _SERVERCLASSES, {"name": name, **settings})) def update_serverclass(client: SplunkClient, name: str, settings: dict[str, Any]) -> dict[str, Any]: """Update a server class, sending only the changed settings (a gated write).""" - return _unwrap(client.write("POST", f"{_SERVERCLASSES}/{name}", settings)) + encoded = path_segment(name, label="server class name") + return _unwrap(client.write("POST", f"{_SERVERCLASSES}/{encoded}", settings)) def reload_config(client: SplunkClient) -> dict[str, Any]: diff --git a/tests/unit/test_app_deploy_contracts.py b/tests/unit/test_app_deploy_contracts.py new file mode 100644 index 0000000..64dd43e --- /dev/null +++ b/tests/unit/test_app_deploy_contracts.py @@ -0,0 +1,169 @@ +"""Exact app-install and deployment-server contracts.""" + +from __future__ import annotations + +import json +from urllib.parse import parse_qs + +import httpx +import pytest +from click.testing import CliRunner + +from vct_splunk.cli import cli + + +@pytest.mark.parametrize( + ("option", "source"), + [ + ("--server-file", "/var/tmp/apps/example.spl"), + ("--url", "https://downloads.example.test/apps/example.spl"), + ], +) +def test_app_install_uses_apps_local_contract(cli_env, patch_client, option, source): + seen: dict[str, object] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen.update(method=req.method, path=req.url.path, form=parse_qs(req.content.decode())) + return httpx.Response(201, json={"entry": [{"name": "example", "content": {}}]}) + + patch_client(handler) + result = CliRunner().invoke( + cli, + ["app", "install", option, source, "--update", "--yes", "--output", "json"], + ) + + assert result.exit_code == 0 + assert seen == { + "method": "POST", + "path": "/services/apps/local", + "form": {"name": [source], "update": ["true"]}, + } + + +def test_app_install_audit_uses_sanitized_url(cli_env, patch_client, monkeypatch, tmp_path): + audit = tmp_path / "audit.log" + monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(audit)) + source = "https://user:password@example.test:8443/apps/example.spl?token=secret#fragment" + patch_client(lambda req: httpx.Response(201, json={"entry": []})) + + result = CliRunner().invoke( + cli, ["app", "install", "--url", source, "--yes", "--output", "json"] + ) + + assert result.exit_code == 0 + record = json.loads(audit.read_text()) + assert record["source"] == "https://example.test:8443/apps/example.spl" + assert "user" not in audit.read_text() + assert "password" not in audit.read_text() + assert "token" not in audit.read_text() + assert "secret" not in audit.read_text() + assert "fragment" not in audit.read_text() + + +def test_app_install_help_has_no_caller_local_file_option(): + result = CliRunner().invoke(cli, ["app", "install", "--help"]) + + assert result.exit_code == 0 + assert "--server-file" in result.output + assert "--file " not in result.output + assert "readable by splunkd" in result.output + + +@pytest.mark.parametrize("source", ["ftp://example.test/app.spl", "https:///app.spl"]) +def test_app_install_rejects_invalid_url(cli_env, patch_client, source): + patch_client( + lambda req: (_ for _ in ()).throw(AssertionError("invalid URL must not send a request")) + ) + result = CliRunner().invoke( + cli, ["app", "install", "--url", source, "--yes", "--output", "json"] + ) + + assert result.exit_code == 2 + + +def test_deploy_serverclass_get_encodes_name(cli_env, patch_client): + seen: dict[str, str] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["method"] = req.method + seen["path"] = req.url.raw_path.decode().partition("?")[0] + return httpx.Response(200, json={"entry": [{"name": "east west", "content": {}}]}) + + patch_client(handler) + result = CliRunner().invoke( + cli, ["deploy", "serverclass", "get", "east west", "--output", "json"] + ) + + assert result.exit_code == 0 + assert seen == { + "method": "GET", + "path": "/services/deployment/server/serverclasses/east%20west", + } + + +@pytest.mark.parametrize("verb", ["create", "update"]) +def test_deploy_serverclass_write_exact_contract(cli_env, patch_client, verb): + seen: dict[str, object] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen.update( + method=req.method, + path=req.url.raw_path.decode().partition("?")[0], + form=parse_qs(req.content.decode()), + ) + return httpx.Response(200, json={"entry": [{"name": "east west", "content": {}}]}) + + patch_client(handler) + result = CliRunner().invoke( + cli, + [ + "deploy", + "serverclass", + verb, + "east west", + "--set", + "whitelist.0=*", + "--yes", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + expected_path = "/services/deployment/server/serverclasses" + expected_form = {"whitelist.0": ["*"]} + if verb == "create": + expected_form["name"] = ["east west"] + else: + expected_path += "/east%20west" + assert seen == {"method": "POST", "path": expected_path, "form": expected_form} + + +@pytest.mark.parametrize("verb", ["create", "update"]) +@pytest.mark.parametrize("setting", ["broken", "=value", "x=1"]) +def test_deploy_serverclass_rejects_malformed_settings(cli_env, patch_client, verb, setting): + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("malformed settings must not send a request") + + patch_client(handler) + args = ["deploy", "serverclass", verb, "example", "--set", setting] + if setting == "x=1": + args.extend(["--set", "x=2"]) + result = CliRunner().invoke(cli, [*args, "--yes", "--output", "json"]) + + assert result.exit_code == 2 + + +@pytest.mark.parametrize("verb", ["get", "create", "update"]) +@pytest.mark.parametrize("name", ["..", "../other", r"..\\other", "%2e%2e%2fother"]) +def test_deploy_serverclass_rejects_traversal_before_request(cli_env, patch_client, verb, name): + def handler(req: httpx.Request) -> httpx.Response: + raise AssertionError("unsafe names must not send a request") + + patch_client(handler) + args = ["deploy", "serverclass", verb, name] + if verb != "get": + args.extend(["--set", "x=1", "--yes"]) + result = CliRunner().invoke(cli, [*args, "--output", "json"]) + + assert result.exit_code == 2 diff --git a/tests/unit/test_cli_matrix.py b/tests/unit/test_cli_matrix.py index 50867e0..c647b2a 100644 --- a/tests/unit/test_cli_matrix.py +++ b/tests/unit/test_cli_matrix.py @@ -26,9 +26,26 @@ # Non-CRUD leaves the generic verb rules below cannot infer. Writes include # --dry-run; reads run for real against the mock. _SPECIAL: dict[tuple[str, ...], list[str]] = { + ("app", "install"): ["--server-file", "/tmp/app.spl", "--dry-run"], ("auth", "login"): ["--username", "admin"], ("auth", "status"): [], ("cluster", "status"): [], + ("deploy", "client", "list"): [], + ("deploy", "reload"): ["--dry-run"], + ("deploy", "serverclass", "list"): [], + ("deploy", "serverclass", "get"): ["class"], + ("deploy", "serverclass", "create"): [ + "class", + "--set", + "whitelist.0=*", + "--dry-run", + ], + ("deploy", "serverclass", "update"): [ + "class", + "--set", + "whitelist.0=*", + "--dry-run", + ], ("server", "info"): [], ("api", "get"): ["/services/server/info", "-q", "count=1"], ("search", "run"): ["--query", "index=_internal", "--earliest", "-1h", "--max-rows", "5"], diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index d1e37e1..9ad519f 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -242,7 +242,7 @@ def handler(req: httpx.Request) -> httpx.Response: def test_app_install_requires_one_source(cli_env): - # Neither --file nor --url: usage error before any network call. + # Neither --server-file nor --url: usage error before any network call. result = CliRunner().invoke(cli, ["app", "install", "--output", "json"]) assert result.exit_code == 2 assert "usage_error" in result.output From de8d38bfa6d50cad261aa8c8583ae1872bd682cb Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:05:08 -0400 Subject: [PATCH 33/46] fix: correct HEC rotation and lookup staging --- src/vct_splunk/commands/datamodel.py | 2 + src/vct_splunk/commands/hec.py | 2 + src/vct_splunk/commands/lookup.py | 17 ++- src/vct_splunk/core/datamodel.py | 4 +- src/vct_splunk/core/hec.py | 21 ++- src/vct_splunk/core/lookups.py | 16 +-- tests/unit/test_cli_matrix.py | 5 + tests/unit/test_commands.py | 25 ++-- tests/unit/test_hec_knowledge_contracts.py | 149 +++++++++++++++++++++ 9 files changed, 202 insertions(+), 39 deletions(-) create mode 100644 tests/unit/test_hec_knowledge_contracts.py diff --git a/src/vct_splunk/commands/datamodel.py b/src/vct_splunk/commands/datamodel.py index db71142..af5d6ac 100644 --- a/src/vct_splunk/commands/datamodel.py +++ b/src/vct_splunk/commands/datamodel.py @@ -12,6 +12,7 @@ from ..core import datamodel as core from ..core.namespace import resolve_ns +from ..core.path import path_segment from . import output as out from .context import command from .write import do_write @@ -23,6 +24,7 @@ @command def datamodel_accelerate(ctx, name, enable) -> None: """Toggle acceleration on a data model. Namespaced; gated write; requires an app.""" + path_segment(name, label="data model name") owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) verb = "enable" if enable else "disable" result = do_write( diff --git a/src/vct_splunk/commands/hec.py b/src/vct_splunk/commands/hec.py index 2d6604c..e5711ab 100644 --- a/src/vct_splunk/commands/hec.py +++ b/src/vct_splunk/commands/hec.py @@ -10,6 +10,7 @@ import click from ..core import hec as core +from ..core.path import path_segment from . import output as out from .context import command from .write import do_write @@ -29,6 +30,7 @@ def rotate(ctx, name) -> None: The new token value is printed (that is the point of rotation) but is never written to the audit log, which records only the action and token name. """ + path_segment(name, label="HEC token name") result = do_write( ctx, action=f"rotate HEC token '{name}' (mints a new secret)", diff --git a/src/vct_splunk/commands/lookup.py b/src/vct_splunk/commands/lookup.py index d8b8998..52f834e 100644 --- a/src/vct_splunk/commands/lookup.py +++ b/src/vct_splunk/commands/lookup.py @@ -7,7 +7,7 @@ from __future__ import annotations -from pathlib import Path +from pathlib import PurePath import click @@ -24,17 +24,20 @@ def lookup() -> None: @lookup.command("upload") -@click.option("--file", "file", required=True, type=click.Path(exists=True), help="Local CSV path.") +@click.option( + "--server-file", + required=True, + help="CSV staging path readable by splunkd on the Splunk server.", +) @command -def upload(ctx, file) -> None: - """Upload a CSV lookup table file into an app. Namespaced; gated write; requires an app.""" +def upload(ctx, server_file) -> None: + """Install a staged CSV lookup table into an app. Namespaced, gated, and app-required.""" owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) - filename = Path(file).name - contents = Path(file).read_text(encoding="utf-8") + filename = PurePath(server_file).name result = do_write( ctx, action=f"upload lookup file '{filename}' into app '{app}'", audit_event={"action": "lookup.upload", "filename": filename, "app": app}, - run=lambda c: core.upload_lookup(c, filename, contents, owner=owner, app=app), + run=lambda c: core.upload_lookup(c, filename, server_file, owner=owner, app=app), ) out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/core/datamodel.py b/src/vct_splunk/core/datamodel.py index 02fb2c2..6dba418 100644 --- a/src/vct_splunk/core/datamodel.py +++ b/src/vct_splunk/core/datamodel.py @@ -10,6 +10,7 @@ from .client import SplunkClient from .namespace import ns_path +from .path import path_segment _MODEL = "datamodel/model" @@ -30,4 +31,5 @@ def accelerate( """ flag = "true" if enabled else "false" body = {"acceleration": f'{{"enabled": {flag}}}'} - return client.write("POST", ns_path(f"{_MODEL}/{name}", owner=owner, app=app), body) + encoded = path_segment(name, label="data model name") + return client.write("POST", ns_path(f"{_MODEL}/{encoded}", owner=owner, app=app), body) diff --git a/src/vct_splunk/core/hec.py b/src/vct_splunk/core/hec.py index ded6fc8..36a9f31 100644 --- a/src/vct_splunk/core/hec.py +++ b/src/vct_splunk/core/hec.py @@ -14,6 +14,8 @@ from typing import Any from .client import SplunkClient +from .errors import APIError +from .path import path_segment _HTTP = "/services/data/inputs/http" @@ -21,22 +23,19 @@ def rotate_token(client: SplunkClient, name: str) -> dict[str, Any]: """Regenerate the secret of an existing HEC token, returning the new value. - Splunk has no first-class "rotate" endpoint. The reliable single call is a - POST to the token stanza with ``rotate=true``; Splunk mints a fresh secret - and returns the updated entry (its ``content.token`` is the new value). - - ponytail: this assumes the running Splunk honors ``rotate=true`` on the HEC - stanza (supported on current Splunk Enterprise). If a target version ignores - it, the fallback is delete + re-create with the same settings -- not built - until a real version gap appears. The new token is returned to the caller but - must never be written to the audit log. + The new token is returned to the caller but must never be written to the + audit log. """ - result = client.write("POST", f"{_HTTP}/{name}", {"rotate": "true"}) + encoded = path_segment(name, label="HEC token name") + result = client.write("POST", f"{_HTTP}/{encoded}/rotate", {}) if result.get("dry_run"): return result entries = result.get("entry") or [] content = entries[0].get("content") if entries else {} - return {"name": name, "token": (content or {}).get("token")} + token = (content or {}).get("token") + if not token: + raise APIError("Splunk's HEC rotation response did not contain a new token.") + return {"name": name, "token": token} def set_global(client: SplunkClient, *, enabled: bool) -> dict[str, Any]: diff --git a/src/vct_splunk/core/lookups.py b/src/vct_splunk/core/lookups.py index 4569b31..d000fad 100644 --- a/src/vct_splunk/core/lookups.py +++ b/src/vct_splunk/core/lookups.py @@ -16,18 +16,12 @@ def upload_lookup( - client: SplunkClient, filename: str, contents: str, *, owner: str, app: str + client: SplunkClient, filename: str, server_file: str, *, owner: str, app: str ) -> dict[str, Any]: - """Create a lookup-table file entry, sending the CSV bytes as ``eai:data``. + """Create a lookup-table file entry from a path readable by splunkd. - Splunk's lookup-table-files endpoint accepts the file name plus the file - body as the ``eai:data`` form field, which avoids a true multipart upload. - ``filename`` is the name the table file will have in the app; ``contents`` - is the raw CSV text read from the local path by the command layer. - - ponytail: this sends the whole CSV inline as a form field, which is fine for - typical lookup tables. Streaming a multi-hundred-MB file would need a real - multipart helper on the client -- not built until a large file appears. + ``filename`` is the name the table file will have in the app. + ``server_file`` is the staging path on the Splunk server. """ - body: dict[str, Any] = {"name": filename, "eai:data": contents} + body: dict[str, Any] = {"name": filename, "eai:data": server_file} return client.write("POST", ns_path(_FILES, owner=owner, app=app), body) diff --git a/tests/unit/test_cli_matrix.py b/tests/unit/test_cli_matrix.py index c647b2a..c44b5dc 100644 --- a/tests/unit/test_cli_matrix.py +++ b/tests/unit/test_cli_matrix.py @@ -54,6 +54,9 @@ ("search", "cancel"): ["sid1", "--dry-run"], ("saved-search", "run"): ["nightly", "--app", "my_app", "--earliest", "-1h"], ("health", "check"): [], + ("hec", "rotate"): ["token", "--dry-run"], + ("hec", "global-enable"): ["--dry-run"], + ("hec", "global-disable"): ["--dry-run"], ("inspect",): [], ("kvstore", "records"): ["records"], ("kvstore", "get"): ["records", "key"], @@ -68,6 +71,8 @@ ("kvstore", "delete"): ["records", "key", "--dry-run"], ("kvstore", "purge"): ["records", "--dry-run"], ("license", "usage"): [], + ("lookup", "upload"): ["--server-file", "/var/tmp/table.csv", "--app", "my_app", "--dry-run"], + ("datamodel", "accelerate"): ["model", "--app", "my_app", "--dry-run"], ("server", "restart"): ["--dry-run"], ("server", "settings", "get"): [], ("server", "settings", "set"): ["--set", "host=x", "--dry-run"], diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index fc8c695..bd160bb 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -400,27 +400,34 @@ def handler(req: httpx.Request) -> httpx.Response: assert '"dry_run": true' in result.output -def test_lookup_upload_requires_app(cli_env, monkeypatch, tmp_path): +def test_lookup_upload_requires_app(cli_env, monkeypatch): monkeypatch.delenv("SPLUNK_APP", raising=False) - csv = tmp_path / "t.csv" - csv.write_text("a,b\n1,2\n", encoding="utf-8") # No --app and no SPLUNK_APP -> the namespaced write must refuse (exit 2). - result = CliRunner().invoke(cli, ["lookup", "upload", "--file", str(csv), "--output", "json"]) + result = CliRunner().invoke( + cli, ["lookup", "upload", "--server-file", "/var/tmp/t.csv", "--output", "json"] + ) assert result.exit_code == 2 assert "usage_error" in result.output -def test_lookup_upload_dry_run_previews_namespace(cli_env, patch_client, tmp_path): - csv = tmp_path / "t.csv" - csv.write_text("a,b\n1,2\n", encoding="utf-8") - +def test_lookup_upload_dry_run_previews_namespace(cli_env, patch_client): def handler(req: httpx.Request) -> httpx.Response: raise AssertionError("dry-run must not send a request") patch_client(handler) result = CliRunner().invoke( cli, - ["lookup", "upload", "--file", str(csv), "--app", "a", "--dry-run", "--output", "json"], + [ + "lookup", + "upload", + "--server-file", + "/var/tmp/t.csv", + "--app", + "a", + "--dry-run", + "--output", + "json", + ], ) assert result.exit_code == 0 assert '"dry_run": true' in result.output diff --git a/tests/unit/test_hec_knowledge_contracts.py b/tests/unit/test_hec_knowledge_contracts.py new file mode 100644 index 0000000..33d0091 --- /dev/null +++ b/tests/unit/test_hec_knowledge_contracts.py @@ -0,0 +1,149 @@ +"""Exact wire contracts for HEC and hand-written knowledge-object operations.""" + +from __future__ import annotations + +from urllib.parse import parse_qs + +import httpx +import pytest +from click.testing import CliRunner + +from vct_splunk.cli import cli +from vct_splunk.core.errors import APIError +from vct_splunk.core.hec import rotate_token + + +def test_hec_rotate_exact_contract_and_official_response(cli_env, patch_client): + seen: dict[str, object] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen.update( + method=req.method, + path=req.url.raw_path.decode().partition("?")[0], + form=parse_qs(req.content.decode()), + ) + return httpx.Response( + 200, json={"entry": [{"name": "east west", "content": {"token": "new-secret"}}]} + ) + + patch_client(handler) + result = CliRunner().invoke(cli, ["hec", "rotate", "east west", "--yes", "--output", "json"]) + + assert result.exit_code == 0 + assert '"token": "new-secret"' in result.output + assert seen == { + "method": "POST", + "path": "/services/data/inputs/http/east%20west/rotate", + "form": {}, + } + + +def test_hec_rotate_requires_token_in_response(client_for): + client = client_for(lambda req: httpx.Response(200, json={"entry": [{"content": {}}]})) + + with pytest.raises(APIError, match="did not contain a new token"): + rotate_token(client, "token") + + +@pytest.mark.parametrize("name", ["..", "a/b", "a\\b", "%252fetc", "a\nb"]) +@pytest.mark.parametrize("command", ["hec", "datamodel"]) +def test_dynamic_names_refuse_traversal_before_request(cli_env, patch_client, name, command): + requests: list[httpx.Request] = [] + patch_client(lambda req: requests.append(req) or httpx.Response(200, json={})) + argv = ( + ["hec", "rotate", name, "--yes", "--output", "json"] + if command == "hec" + else [ + "datamodel", + "accelerate", + name, + "--app", + "a", + "--yes", + "--output", + "json", + ] + ) + + result = CliRunner().invoke(cli, argv) + + assert result.exit_code == 2 + assert requests == [] + + +def test_datamodel_accelerate_encodes_name_and_sends_exact_body(cli_env, patch_client): + seen: dict[str, object] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen.update( + method=req.method, + path=req.url.raw_path.decode().partition("?")[0], + form=parse_qs(req.content.decode()), + ) + return httpx.Response(200, json={"entry": []}) + + patch_client(handler) + result = CliRunner().invoke( + cli, + [ + "datamodel", + "accelerate", + "Auth Model", + "--app", + "search", + "--yes", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + assert seen == { + "method": "POST", + "path": "/servicesNS/nobody/search/datamodel/model/Auth%20Model", + "form": {"acceleration": ['{"enabled": true}']}, + } + + +def test_lookup_upload_sends_server_staging_path(cli_env, patch_client): + seen: dict[str, object] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen.update( + method=req.method, + path=req.url.path, + form=parse_qs(req.content.decode()), + ) + return httpx.Response(201, json={"entry": [{"name": "table.csv", "content": {}}]}) + + patch_client(handler) + result = CliRunner().invoke( + cli, + [ + "lookup", + "upload", + "--server-file", + "/var/tmp/staged/table.csv", + "--app", + "search", + "--yes", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + assert seen == { + "method": "POST", + "path": "/servicesNS/nobody/search/data/lookup-table-files", + "form": {"name": ["table.csv"], "eai:data": ["/var/tmp/staged/table.csv"]}, + } + + +def test_lookup_help_exposes_only_server_file(): + result = CliRunner().invoke(cli, ["lookup", "upload", "--help"]) + + assert result.exit_code == 0 + assert "--server-file" in result.output + assert "--file " not in result.output + assert "readable by splunkd" in result.output From 313420e84a554e697366f8a6fb55247dd08dbd36 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:11:19 -0400 Subject: [PATCH 34/46] fix: harden ACS reads and public contract --- .env.example | 3 + README.md | 2 + src/vct_splunk/core/acs/client.py | 61 ++++++++--- .../core/acs/openapi/adminconfig-v2.json | 79 ++++++++++++-- src/vct_splunk/core/acs/operations.py | 41 +++++-- tests/integration/test_acs_live.py | 35 ++++++ tests/integration/test_acs_public_spec.py | 38 +++++++ tests/unit/test_acs.py | 102 ++++++++++++++++-- tests/unit/test_cli_matrix.py | 25 +++++ 9 files changed, 353 insertions(+), 33 deletions(-) create mode 100644 tests/integration/test_acs_live.py create mode 100644 tests/integration/test_acs_public_spec.py diff --git a/.env.example b/.env.example index 5173efd..215610b 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,9 @@ SPLUNK_TOKEN= # ACS authentication token (Bearer). The stack name is derived from SPLUNK_URL; # set SPLUNK_ACS_STACK only to override it. # SPLUNK_ACS_TOKEN= +# ACS API origin. Commercial Cloud defaults to https://admin.splunk.com. +# FedRAMP stacks use https://admin.splunkcloudgc.com. +# SPLUNK_ACS_BASE_URL= # --- Session login + config profiles (#13) ----------------------------------- # Alternative to SPLUNK_TOKEN: a session key sent as `Authorization: Splunk `. # Mint one with `splunk auth login`. SPLUNK_TOKEN (Bearer) wins if both are set. diff --git a/README.md b/README.md index 9180ca4..26fd5e9 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ against the documented REST API. It does not use, bundle, or proxy any Splunk-di `*.splunkcloud.com` host, the CLI deduces the Cloud backend and the *same flat commands* read via the Cloud ACS API instead of splunkd — you never pick a backend. ACS reads need a `SPLUNK_ACS_TOKEN` (the stack is derived from the URL). +The ACS origin defaults to the commercial endpoint `https://admin.splunk.com`; +set `SPLUNK_ACS_BASE_URL=https://admin.splunkcloudgc.com` for FedRAMP stacks. Cloud coverage is **read-only** and not yet certified against a live stack, so an operation it can't serve stops with a clean `unsupported_backend` error (exit 4) rather than guessing. `splunk inspect` reports the deduced backend and what it diff --git a/src/vct_splunk/core/acs/client.py b/src/vct_splunk/core/acs/client.py index d54cb49..90716f2 100644 --- a/src/vct_splunk/core/acs/client.py +++ b/src/vct_splunk/core/acs/client.py @@ -10,6 +10,8 @@ from __future__ import annotations import os +import re +import time from dataclasses import dataclass from typing import Any @@ -18,6 +20,8 @@ from ..errors import APIError, AuthError, NotFoundError, TransportError, UsageError ACS_BASE_URL = "https://admin.splunk.com" +_STACK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*$") +_MAX_RETRIES = 3 @dataclass @@ -36,7 +40,7 @@ def acs_config_from_env(stack: str | None = None) -> AcsConfig: explicit override. ``SPLUNK_ACS_TOKEN`` (a Bearer token, separate from the Enterprise auth token) is always required for ACS operations. """ - stack = stack or os.environ.get("SPLUNK_ACS_STACK") + stack = os.environ.get("SPLUNK_ACS_STACK") or stack token = os.environ.get("SPLUNK_ACS_TOKEN") if not stack: raise UsageError( @@ -45,13 +49,21 @@ def acs_config_from_env(stack: str | None = None) -> AcsConfig: ) if not token: raise UsageError("No ACS token. Set SPLUNK_ACS_TOKEN for Splunk Cloud operations.") - return AcsConfig(stack=stack, token=token) + if not _STACK_RE.fullmatch(stack): + raise UsageError( + "Invalid ACS stack name. Use only letters, numbers, and hyphens, " + "starting with a letter or number." + ) + base_url = os.environ.get("SPLUNK_ACS_BASE_URL", ACS_BASE_URL).rstrip("/") + return AcsConfig(stack=stack, token=token, base_url=base_url) class AcsClient: """Read-only GET access to one Splunk Cloud stack's ACS adminconfig/v2 API.""" def __init__(self, config: AcsConfig, *, transport: httpx.BaseTransport | None = None) -> None: + if not _STACK_RE.fullmatch(config.stack): + raise UsageError("Invalid ACS stack name.") self.config = config self._http = httpx.Client( base_url=f"{config.base_url}/{config.stack}/adminconfig/v2", @@ -66,17 +78,38 @@ def __enter__(self) -> AcsClient: def __exit__(self, exc_type: object, exc: object, tb: object) -> None: self._http.close() - def get(self, path: str) -> Any: + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: """GET an ACS read endpoint and return the parsed JSON.""" url = "/" + path.lstrip("/") - try: - resp = self._http.get(url) - except httpx.HTTPError as exc: - raise TransportError(f"Could not reach ACS at {self.config.base_url}: {exc}") from exc - if resp.status_code in (401, 403): - raise AuthError(f"ACS auth failed ({resp.status_code}). Check SPLUNK_ACS_TOKEN.") - if resp.status_code == 404: - raise NotFoundError(f"ACS endpoint not found: {url}") - if resp.status_code >= 400: - raise APIError(f"ACS returned {resp.status_code} for GET {url}") - return resp.json() if resp.content else {} + for attempt in range(_MAX_RETRIES + 1): + try: + resp = self._http.get(url, params=params) + except httpx.HTTPError as exc: + raise TransportError( + f"Could not reach ACS at {self.config.base_url}: {exc}" + ) from exc + if (resp.status_code == 429 or 500 <= resp.status_code < 600) and ( + attempt < _MAX_RETRIES + ): + time.sleep(_retry_after(resp, attempt)) + continue + if resp.status_code in (401, 403): + raise AuthError(f"ACS auth failed ({resp.status_code}). Check SPLUNK_ACS_TOKEN.") + if resp.status_code == 404: + raise NotFoundError(f"ACS endpoint not found: {url}") + if resp.status_code >= 400: + raise APIError(f"ACS returned {resp.status_code} for GET {url}") + if not resp.content: + return {} + try: + return resp.json() + except ValueError as exc: + raise APIError(f"ACS returned malformed JSON for GET {url}") from exc + raise TransportError("ACS retries exhausted") # pragma: no cover + + +def _retry_after(resp: httpx.Response, attempt: int) -> float: + value = resp.headers.get("Retry-After") + if value and value.isdigit(): + return float(value) + return min(2.0**attempt, 8.0) diff --git a/src/vct_splunk/core/acs/openapi/adminconfig-v2.json b/src/vct_splunk/core/acs/openapi/adminconfig-v2.json index ebda6a3..4063205 100644 --- a/src/vct_splunk/core/acs/openapi/adminconfig-v2.json +++ b/src/vct_splunk/core/acs/openapi/adminconfig-v2.json @@ -1,14 +1,79 @@ { "openapi": "3.0.0", "info": { - "title": "Splunk Cloud Admin Config Service (ACS) adminconfig/v2", - "version": "v2-pinned-subset" + "title": "Splunk Cloud ACS implemented contract snapshot", + "version": "2026-07-30" }, - "x-note": "Hand-pinned subset of the read-only ACS paths this CLI uses. The full spec lives at https://docs.splunk.com/Documentation/SplunkCloud/latest/Config/ACSIntro -- this file pins the exact paths our client calls so a contract test fails if the two drift.", - "servers": [{ "url": "https://admin.splunk.com/{stack}/adminconfig/v2" }], + "x-source-url": "https://admin.splunk.com/service/info/specs/v2/openapi.json", + "x-retrieved": "2026-07-30", + "x-snapshot": "Exact GET path, pagination parameter, and success-envelope fragments implemented by this CLI.", "paths": { - "/indexes": { "get": { "summary": "List indexes." } }, - "/inputs/http-event-collectors": { "get": { "summary": "List HEC tokens." } }, - "/roles": { "get": { "summary": "List roles." } } + "/{stack}/adminconfig/v2/indexes": { + "get": { + "operationId": "ListIndexes", + "parameters": ["stack", "count", "offset"], + "response": { + "indexes": { + "type": "array", + "items": "IndexResponse" + } + } + } + }, + "/{stack}/adminconfig/v2/inputs/http-event-collectors": { + "get": { + "operationId": "ListHECs", + "parameters": ["stack", "count", "offset"], + "response": { + "http_event_collectors": { + "type": "array", + "items": "HecInfo" + } + } + } + }, + "/{stack}/adminconfig/v2/roles": { + "get": { + "operationId": "ListRoles", + "parameters": ["stack", "count", "offset"], + "response": { + "roles": { + "type": "array", + "items": "RolesResponse" + } + } + } + } + }, + "parameters": { + "count": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + "offset": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "stack": { + "type": "string", + "required": true + } + }, + "schemas": { + "HecInfo": { + "properties": { + "spec": "HecSpec", + "token": "string" + } + }, + "IndexResponse": { + "required": ["name", "datatype", "searchableDays", "maxDataSizeMB"] + }, + "RolesResponse": { + "required": ["name"] + } } } diff --git a/src/vct_splunk/core/acs/operations.py b/src/vct_splunk/core/acs/operations.py index 4c60473..48f3143 100644 --- a/src/vct_splunk/core/acs/operations.py +++ b/src/vct_splunk/core/acs/operations.py @@ -5,6 +5,7 @@ from typing import Any +from ..errors import APIError from .client import AcsClient # ACS read paths used by this CLI. Keep in sync with openapi/adminconfig-v2.json @@ -17,16 +18,44 @@ READ_PATHS = (INDEXES, HEC_TOKENS, ROLES) -def list_cloud_indexes(client: AcsClient) -> Any: +def list_cloud_indexes(client: AcsClient) -> list[dict[str, Any]]: """List indexes on the Cloud stack (ACS).""" - return client.get(INDEXES) + return _list(client, INDEXES, "indexes") -def list_hec_tokens(client: AcsClient) -> Any: +def list_hec_tokens(client: AcsClient) -> list[dict[str, Any]]: """List HTTP Event Collector tokens on the Cloud stack (ACS).""" - return client.get(HEC_TOKENS) + return [_without_tokens(item) for item in _list(client, HEC_TOKENS, "http_event_collectors")] -def list_cloud_roles(client: AcsClient) -> Any: +def list_cloud_roles(client: AcsClient) -> list[dict[str, Any]]: """List roles on the Cloud stack (ACS).""" - return client.get(ROLES) + return _list(client, ROLES, "roles") + + +def _list(client: AcsClient, path: str, envelope: str) -> list[dict[str, Any]]: + """Read every page from one official ACS list envelope.""" + output: list[dict[str, Any]] = [] + offset = 0 + while True: + body = client.get(path, {"count": 100, "offset": offset}) + if not isinstance(body, dict) or not isinstance(body.get(envelope), list): + raise APIError(f"ACS response is missing the {envelope!r} list envelope.") + page = body[envelope] + if not all(isinstance(item, dict) for item in page): + raise APIError(f"ACS response contains malformed items in {envelope!r}.") + output.extend(page) + if len(page) < 100: + return output + offset += len(page) + + +def _without_tokens(value: Any) -> Any: + """Remove token fields before HEC data leaves the ACS operation boundary.""" + if isinstance(value, dict): + return { + key: _without_tokens(item) for key, item in value.items() if key.casefold() != "token" + } + if isinstance(value, list): + return [_without_tokens(item) for item in value] + return value diff --git a/tests/integration/test_acs_live.py b/tests/integration/test_acs_live.py new file mode 100644 index 0000000..56389ef --- /dev/null +++ b/tests/integration/test_acs_live.py @@ -0,0 +1,35 @@ +"""Credential-gated read-only ACS canary.""" + +from __future__ import annotations + +import os + +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 + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def acs_client(): + if os.environ.get("SPLUNK_ACS_LIVE_TEST") != "true": + pytest.skip("set SPLUNK_ACS_LIVE_TEST=true with ACS credentials") + with AcsClient(acs_config_from_env(cloud_stack_from_url())) as client: + yield client + + +def test_live_acs_index_list(acs_client): + assert isinstance(operations.list_cloud_indexes(acs_client), list) + + +def test_live_acs_role_list(acs_client): + assert isinstance(operations.list_cloud_roles(acs_client), list) + + +def test_live_acs_hec_list_has_no_tokens(acs_client): + result = operations.list_hec_tokens(acs_client) + assert isinstance(result, list) + assert all("token" not in item for item in result) diff --git a/tests/integration/test_acs_public_spec.py b/tests/integration/test_acs_public_spec.py new file mode 100644 index 0000000..1af8cbe --- /dev/null +++ b/tests/integration/test_acs_public_spec.py @@ -0,0 +1,38 @@ +"""Credential-free drift check against Splunk's public ACS OpenAPI.""" + +from __future__ import annotations + +import os + +import httpx +import pytest + +from vct_splunk.core.acs import pinned_spec + +pytestmark = pytest.mark.integration + +_SOURCE = "https://admin.splunk.com/service/info/specs/v2/openapi.json" + + +def test_implemented_acs_contract_matches_public_spec(): + if os.environ.get("SPLUNK_ACS_SPEC_TEST") != "true": + pytest.skip("set SPLUNK_ACS_SPEC_TEST=true to check the public ACS OpenAPI") + + public = httpx.get(_SOURCE, timeout=30).raise_for_status().json() + snapshot = pinned_spec() + assert snapshot["x-source-url"] == _SOURCE + + for path, item in snapshot["paths"].items(): + operation = public["paths"][path]["get"] + expected = item["get"] + assert operation["operationId"] == expected["operationId"] + assert [parameter["$ref"].rsplit("/", 1)[-1] for parameter in operation["parameters"]] == ( + expected["parameters"] + ) + schema = operation["responses"]["200"]["content"]["application/json"]["schema"] + envelope = next(iter(expected["response"])) + assert schema["properties"][envelope]["type"] == "array" + assert ( + schema["properties"][envelope]["items"]["$ref"].rsplit("/", 1)[-1] + == expected["response"][envelope]["items"] + ) diff --git a/tests/unit/test_acs.py b/tests/unit/test_acs.py index b9bc1c9..a52acad 100644 --- a/tests/unit/test_acs.py +++ b/tests/unit/test_acs.py @@ -61,7 +61,7 @@ def _patch_rest(monkeypatch, handler) -> None: def test_acs_read_paths_are_pinned_to_the_spec(): declared = set(pinned_spec()["paths"]) for path in operations.READ_PATHS: - assert f"/{path}" in declared # never call a path the spec does not pin + assert f"/{{stack}}/adminconfig/v2/{path}" in declared def test_list_cloud_indexes_hits_indexes_path(): @@ -69,16 +69,60 @@ def test_list_cloud_indexes_hits_indexes_path(): def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return httpx.Response(200, json=[{"name": "main"}, {"name": "audit"}]) + return httpx.Response(200, json={"indexes": [{"name": "main"}, {"name": "audit"}]}) result = operations.list_cloud_indexes(_acs(handler)) assert seen["path"].endswith("/adminconfig/v2/indexes") assert [i["name"] for i in result] == ["main", "audit"] -def test_acs_auth_error_maps_typed(): +def test_acs_list_paginates_with_count_and_offset(): + offsets: list[int] = [] + + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.params["count"] == "100" + offset = int(req.url.params["offset"]) + offsets.append(offset) + size = 100 if offset == 0 else 2 + return httpx.Response( + 200, json={"roles": [{"name": f"role-{offset + i}"} for i in range(size)]} + ) + + result = operations.list_cloud_roles(_acs(handler)) + + assert offsets == [0, 100] + assert len(result) == 102 + + +@pytest.mark.parametrize("body", [{}, {"roles": {}}, {"roles": ["bad"]}]) +def test_acs_rejects_missing_or_malformed_envelope(body): + with pytest.raises(APIError): + operations.list_cloud_roles(_acs(lambda req: httpx.Response(200, json=body))) + + +def test_acs_hec_tokens_are_removed_at_operation_boundary(): + result = operations.list_hec_tokens( + _acs( + lambda req: httpx.Response( + 200, + json={ + "http_event_collectors": [ + {"spec": {"name": "one"}, "token": "secret"}, + {"spec": {"name": "two", "token": "nested-secret"}}, + ] + }, + ) + ) + ) + + assert result == [{"spec": {"name": "one"}}, {"spec": {"name": "two"}}] + assert "secret" not in json.dumps(result) + + +@pytest.mark.parametrize("status", [401, 403]) +def test_acs_auth_error_maps_typed(status): with pytest.raises(AuthError): - operations.list_cloud_roles(_acs(lambda req: httpx.Response(401, json={}))) + operations.list_cloud_roles(_acs(lambda req: httpx.Response(status, json={}))) def test_acs_404_maps_not_found(): @@ -86,11 +130,35 @@ def test_acs_404_maps_not_found(): operations.list_cloud_roles(_acs(lambda req: httpx.Response(404, json={}))) -def test_acs_5xx_maps_api_error(): +def test_acs_5xx_maps_api_error(monkeypatch): + monkeypatch.setattr("vct_splunk.core.acs.client.time.sleep", lambda delay: None) with pytest.raises(APIError): operations.list_cloud_roles(_acs(lambda req: httpx.Response(500, json={}))) +def test_acs_malformed_json_maps_api_error(): + with pytest.raises(APIError, match="malformed JSON"): + operations.list_cloud_roles(_acs(lambda req: httpx.Response(200, content=b"{not-json"))) + + +@pytest.mark.parametrize("status", [429, 500, 501, 502, 503, 504]) +def test_acs_retries_bounded_statuses(monkeypatch, status): + calls = 0 + sleeps: list[float] = [] + + def handler(req: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls < 3: + 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) + assert operations.list_cloud_roles(_acs(handler)) == [] + assert calls == 3 + assert sleeps == [7.0, 7.0] + + def test_acs_unreachable_maps_transport_error(): def handler(req: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused") @@ -113,6 +181,23 @@ def test_acs_config_requires_token(monkeypatch): acs_config_from_env("acme") +@pytest.mark.parametrize("stack", ["../other", "a/b", ".hidden", "two words", ""]) +def test_acs_config_rejects_invalid_stack(monkeypatch, stack): + monkeypatch.setenv("SPLUNK_ACS_TOKEN", "T") + monkeypatch.setenv("SPLUNK_ACS_STACK", stack) + with pytest.raises(UsageError, match="stack"): + acs_config_from_env() + + +def test_acs_config_supports_base_url_override(monkeypatch): + monkeypatch.setenv("SPLUNK_ACS_TOKEN", "T") + monkeypatch.setenv("SPLUNK_ACS_BASE_URL", "https://admin.splunkcloudgc.com/") + + config = acs_config_from_env("fed-stack") + + assert config.base_url == "https://admin.splunkcloudgc.com" + + # --- Backend deduction from the URL ------------------------------------------ @@ -157,7 +242,12 @@ def test_flat_list_routes_to_acs_on_cloud(monkeypatch, argv, acs_path): def handler(req: httpx.Request) -> httpx.Response: seen["path"] = req.url.path - return httpx.Response(200, json=[{"name": "main"}]) + envelope = { + "indexes": "indexes", + "roles": "roles", + "http-event-collectors": "http_event_collectors", + }[req.url.path.rsplit("/", 1)[-1]] + return httpx.Response(200, json={envelope: [{"name": "main"}]}) _patch_acs(monkeypatch, handler) result = CliRunner().invoke(cli, [*argv, "--output", "json"]) diff --git a/tests/unit/test_cli_matrix.py b/tests/unit/test_cli_matrix.py index c44b5dc..35d502d 100644 --- a/tests/unit/test_cli_matrix.py +++ b/tests/unit/test_cli_matrix.py @@ -193,3 +193,28 @@ def test_every_leaf_runs_with_common_inputs(path, cli_env, patch_client, monkeyp assert "data" in payload # the success envelope, for reads and previews alike if "--dry-run" in argv: assert payload["data"]["dry_run"] is True # writes never hit the wire here + + +def test_every_write_leaf_refuses_cloud_before_client_creation(cli_env, monkeypatch, tmp_path): + """Every catalogued write stops at the shared Cloud guard before any client.""" + monkeypatch.setenv("SPLUNK_URL", "https://acme.splunkcloud.com") + monkeypatch.setenv("SPLUNK_APP", "my_app") + monkeypatch.setenv("SPLUNK_PASSWORD", "secret") + monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(tmp_path / "audit.log")) + + def unexpected_client(*args, **kwargs): + raise AssertionError("Cloud write must not construct an ACS or splunkd client") + + monkeypatch.setattr("vct_splunk.commands.context.Ctx.client", unexpected_client) + monkeypatch.setattr("vct_splunk.commands.context.Ctx.acs_client", unexpected_client) + + writes = [ + (path, _args_for(path) or []) + for path, _ in _iter_leaves(cli) + if "--dry-run" in (_args_for(path) or []) + ] + assert writes + for path, args in writes: + result = CliRunner().invoke(cli, [*path, *args, "--output", "json"]) + assert result.exit_code == 4, f"{' '.join(path)}: {result.output}" + assert "unsupported_backend" in result.output From 8d7cc3104449072993ab338ab2657983271973f6 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:25:09 -0400 Subject: [PATCH 35/46] fix: harden profile and login authentication --- src/vct_splunk/commands/auth.py | 37 ++++++++---- src/vct_splunk/commands/context.py | 2 +- src/vct_splunk/core/client.py | 89 ++++++++++++++++------------ src/vct_splunk/core/profiles.py | 37 ++++++++---- tests/conftest.py | 11 +++- tests/unit/test_auth.py | 91 +++++++++++++++++++++++++++++ tests/unit/test_client.py | 10 +++- tests/unit/test_profiles.py | 30 ++++++++++ tests/unit/test_session_key_auth.py | 25 ++++---- 9 files changed, 261 insertions(+), 71 deletions(-) diff --git a/src/vct_splunk/commands/auth.py b/src/vct_splunk/commands/auth.py index 59fe688..8b36ae6 100644 --- a/src/vct_splunk/commands/auth.py +++ b/src/vct_splunk/commands/auth.py @@ -14,8 +14,8 @@ import click from ..core import auth as core +from ..core.client import auth_status_from_env from ..core.errors import UsageError -from ..core.profiles import load_profile from . import output as out from .context import command @@ -32,7 +32,7 @@ def _resolve_username(username: str | None) -> str: username = username or os.environ.get("SPLUNK_USERNAME") if username: return username - if not sys.stdin.isatty(): + if not (sys.stdin.isatty() and sys.stderr.isatty()): raise UsageError("No username. Set SPLUNK_USERNAME or pass --username.") return click.prompt("Username", err=True) @@ -46,7 +46,7 @@ def _resolve_password() -> str: password = os.environ.get("SPLUNK_PASSWORD") if password: return password - if not sys.stdin.isatty(): + if not (sys.stdin.isatty() and sys.stderr.isatty()): raise UsageError("No password. Set SPLUNK_PASSWORD (run interactively to be prompted).") return click.prompt("Password", hide_input=True, err=True) @@ -69,6 +69,22 @@ def login(ctx, username: str | None) -> None: url = ctx.base_url if not url: raise UsageError("No Splunk URL. Set SPLUNK_URL, select a profile, or pass --base-url.") + if ctx.dry_run: + preview = { + "dry_run": True, + "request": { + "method": "POST", + "path": "/services/auth/login", + "body": { + "username": username or os.environ.get("SPLUNK_USERNAME") or "", + "password": "", + "output_mode": "json", + }, + }, + "target": url, + } + out.emit(preview, ctx.output_mode, ctx.meta()) + return key = core.login(url, _resolve_username(username), _resolve_password(), verify=_verify()) out.emit({"session_key": key}, ctx.output_mode, ctx.meta()) @@ -77,12 +93,9 @@ def login(ctx, username: str | None) -> None: @command def status(ctx) -> None: """Report the resolved target URL and active auth scheme (no secret shown).""" - prof = load_profile(ctx.profile) - url = ctx.base_url or os.environ.get("SPLUNK_URL") or prof.get("url") - if os.environ.get("SPLUNK_TOKEN") or prof.get("token"): - scheme = "Bearer" - elif os.environ.get("SPLUNK_SESSION_KEY") or prof.get("session_key"): - scheme = "Splunk" - else: - scheme = "none" - out.emit({"target": url, "auth_scheme": scheme}, ctx.output_mode, ctx.meta()) + resolved = auth_status_from_env(ctx.base_url, profile=ctx.profile) + out.emit( + {"target": resolved.base_url, "auth_scheme": resolved.auth_scheme}, + ctx.output_mode, + ctx.meta(), + ) diff --git a/src/vct_splunk/commands/context.py b/src/vct_splunk/commands/context.py index eac7686..b81abec 100644 --- a/src/vct_splunk/commands/context.py +++ b/src/vct_splunk/commands/context.py @@ -152,7 +152,7 @@ def wrapper(output, table, dry_run, yes, base_url, app, owner, profile, **kwargs # in **kwargs and are forwarded straight through to fn. try: profile = profile or os.environ.get("SPLUNK_PROFILE") - prof = load_profile(profile) + prof = load_profile(profile, credentials=False) target = base_url or os.environ.get("SPLUNK_URL") or prof.get("url") ctx = Ctx( out.resolve_mode(output, table), diff --git a/src/vct_splunk/core/client.py b/src/vct_splunk/core/client.py index 298525a..203e41e 100644 --- a/src/vct_splunk/core/client.py +++ b/src/vct_splunk/core/client.py @@ -14,8 +14,9 @@ import httpx +from . import auth from .errors import APIError, AuthError, NotFoundError, TransportError, UsageError -from .profiles import load_profile +from .profiles import load_profile, require_private_profile _RETRY_STATUS = {429, 503} _MAX_RETRIES = 3 @@ -33,30 +34,12 @@ class ClientConfig: auth_scheme: str = "Bearer" -def _login(base_url: str, username: str, password: str, verify: bool | str) -> str: - """Exchange a username and password for a Splunk session key. +@dataclass(frozen=True) +class AuthStatus: + """Resolved target and authentication scheme without performing login.""" - POSTs to ``/services/auth/login`` and returns the ``sessionKey``. This is a - last-resort path (mainly for CI); username/password is not the encouraged way - to authenticate. The password lives only in this request body, never logged. - """ - try: - resp = httpx.post( - f"{base_url}/services/auth/login", - data={"username": username, "password": password, "output_mode": "json"}, - verify=verify, - timeout=30.0, - ) - except httpx.HTTPError as exc: - raise TransportError(f"Could not reach Splunk at {base_url}: {exc}") from exc - if resp.status_code in {401, 403}: - raise AuthError("Splunk rejected the username and password.") - if resp.status_code >= 400: - raise APIError(f"Splunk login failed ({resp.status_code}).", details=resp.text) - key = resp.json().get("sessionKey") - if not key: - raise AuthError("Splunk login returned no session key.") - return key + base_url: str + auth_scheme: str def config_from_env(base_url: str | None = None, *, profile: str | None = None) -> ClientConfig: @@ -81,29 +64,28 @@ def config_from_env(base_url: str | None = None, *, profile: str | None = None) Raises: UsageError: If no URL or no credential can be resolved. """ - 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.") - url = url.rstrip("/") - ca = os.environ.get("SPLUNK_CA_BUNDLE") - verify = ca or ( - os.environ.get("SPLUNK_VERIFY", "true").strip().lower() not in {"0", "false", "no"} - ) + 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. - token = os.environ.get("SPLUNK_TOKEN") or prof.get("token") - session_key = os.environ.get("SPLUNK_SESSION_KEY") or prof.get("session_key") + 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", _login(url, username, password, verify) + scheme, credential = "Splunk", auth.login(url, username, password, verify=verify) else: raise UsageError( "No auth. Set SPLUNK_TOKEN (a JWT) or SPLUNK_SESSION_KEY " @@ -112,6 +94,41 @@ def config_from_env(base_url: str | None = None, *, profile: str | None = None) 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 diff --git a/src/vct_splunk/core/profiles.py b/src/vct_splunk/core/profiles.py index b54b88d..8d7acae 100644 --- a/src/vct_splunk/core/profiles.py +++ b/src/vct_splunk/core/profiles.py @@ -38,7 +38,7 @@ def config_path() -> Path: return base / "vct-splunk" / "config" -def load_profile(name: str | None) -> dict[str, str]: +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: @@ -57,17 +57,34 @@ def load_profile(name: str | None) -> dict[str, str]: parser = configparser.ConfigParser(interpolation=None) try: parser.read(path) - except (OSError, configparser.Error): + 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] - values = {key: section[key] for key in PROFILE_KEYS if key in section} - if os.name == "posix" and any(values.get(key) for key in ("token", "session_key")): - try: - mode = path.stat().st_mode & 0o777 - except OSError: - return {} - if mode & 0o077: - raise UsageError(f"Profile file {path} contains credentials and must have mode 0600.") + 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/tests/conftest.py b/tests/conftest.py index 4c1fb0f..e4ae08b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,7 +27,16 @@ def cli_env(monkeypatch): """Point the CLI at a test Splunk and clear ambient vars that change behavior.""" monkeypatch.setenv("SPLUNK_URL", _TEST_URL) monkeypatch.setenv("SPLUNK_TOKEN", "T") - for var in ("SPLUNK_APP", "SPLUNK_OWNER", "SPLUNK_USER_PASSWORD"): + for var in ( + "SPLUNK_APP", + "SPLUNK_OWNER", + "SPLUNK_USER_PASSWORD", + "SPLUNK_SESSION_KEY", + "SPLUNK_USERNAME", + "SPLUNK_PASSWORD", + "SPLUNK_PROFILE", + "VCT_SPLUNK_CONFIG", + ): monkeypatch.delenv(var, raising=False) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index d66dd9b..391f3ae 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from types import SimpleNamespace + import httpx import pytest from click.testing import CliRunner @@ -82,6 +85,16 @@ def test_login_missing_session_key_raises_auth(): ) +def test_login_malformed_json_raises_auth(): + with pytest.raises(AuthError): + core.login( + "https://splunk.test:8089", + "admin", + "secret", + transport=httpx.MockTransport(lambda req: httpx.Response(200, content=b"{")), + ) + + def test_login_500_raises_api(): with pytest.raises(APIError): core.login( @@ -117,6 +130,48 @@ def test_auth_login_refuses_without_password_noninteractive(monkeypatch): assert "usage_error" in result.output +def test_auth_login_dry_run_sends_nothing_and_redacts_password(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_USERNAME", "admin") + monkeypatch.setenv("SPLUNK_PASSWORD", "super-secret") + monkeypatch.setattr( + "vct_splunk.commands.auth.core.login", + lambda *a, **k: pytest.fail("dry-run must not log in"), + ) + result = CliRunner().invoke(cli, ["auth", "login", "--dry-run", "--output", "json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["data"]["dry_run"] is True + assert payload["data"]["request"] == { + "method": "POST", + "path": "/services/auth/login", + "body": { + "username": "admin", + "password": "", + "output_mode": "json", + }, + } + assert "super-secret" not in result.output + + +def test_auth_prompt_requires_stdin_and_stderr_tty(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + streams = SimpleNamespace( + stdin=SimpleNamespace(isatty=lambda: True), + stderr=SimpleNamespace(isatty=lambda: False), + ) + monkeypatch.setattr("vct_splunk.commands.auth.sys", streams) + monkeypatch.setattr( + "vct_splunk.commands.auth.click.prompt", + lambda *a, **k: pytest.fail("must not prompt without stderr TTY"), + ) + result = CliRunner().invoke(cli, ["auth", "login", "--output", "json"]) + assert result.exit_code == 2 + assert "usage_error" in result.output + + def test_auth_status_reports_bearer(monkeypatch): _clear_auth_env(monkeypatch) monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") @@ -142,3 +197,39 @@ def test_auth_status_reports_none_when_unset(monkeypatch): result = CliRunner().invoke(cli, ["auth", "status", "--output", "json"]) assert result.exit_code == 0 assert '"auth_scheme": "none"' in result.output + + +def test_auth_status_reports_username_password_without_logging_in(monkeypatch): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("SPLUNK_URL", "https://splunk.test:8089") + monkeypatch.setenv("SPLUNK_USERNAME", "admin") + monkeypatch.setenv("SPLUNK_PASSWORD", "secret") + monkeypatch.setattr( + "vct_splunk.core.auth.login", + lambda *a, **k: pytest.fail("status must not log in"), + ) + result = CliRunner().invoke(cli, ["auth", "status", "--output", "json"]) + assert result.exit_code == 0 + assert '"auth_scheme": "Splunk"' in result.output + assert "secret" not in result.output + + +def test_inspect_does_not_select_insecure_profile_credentials(tmp_path, monkeypatch): + _clear_auth_env(monkeypatch) + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod]\nurl = https://splunk.test:8089\ntoken = secret\n") + cfgfile.chmod(0o644) + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + result = CliRunner().invoke(cli, ["inspect", "--profile", "prod", "--output", "json"]) + assert result.exit_code == 0 + assert "secret" not in result.output + + +def test_non_utf8_profile_is_clean_cli_error(tmp_path, monkeypatch): + _clear_auth_env(monkeypatch) + cfgfile = tmp_path / "config" + cfgfile.write_bytes(b"[prod]\nurl = \xff\n") + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + result = CliRunner().invoke(cli, ["inspect", "--profile", "prod", "--output", "json"]) + assert result.exit_code == 2 + assert json.loads(result.stderr)["error"]["code"] == "usage_error" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 2c6cc5c..c4e8f05 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -174,7 +174,15 @@ def handler(req: httpx.Request) -> httpx.Response: def _clear_auth_env(monkeypatch): - for var in ("SPLUNK_URL", "SPLUNK_TOKEN", "SPLUNK_SESSION_KEY", "VCT_SPLUNK_CONFIG"): + for var in ( + "SPLUNK_URL", + "SPLUNK_TOKEN", + "SPLUNK_SESSION_KEY", + "SPLUNK_USERNAME", + "SPLUNK_PASSWORD", + "SPLUNK_PROFILE", + "VCT_SPLUNK_CONFIG", + ): monkeypatch.delenv(var, raising=False) diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py index d8c71f4..a5c333e 100644 --- a/tests/unit/test_profiles.py +++ b/tests/unit/test_profiles.py @@ -6,6 +6,7 @@ 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 @@ -73,5 +74,34 @@ def test_secret_profile_rejects_group_or_world_access(tmp_path, monkeypatch): cfgfile.write_text("[prod]\ntoken = secret\n") cfgfile.chmod(0o644) 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") + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits required") +def test_insecure_profile_credential_is_ignored_when_env_token_wins(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod]\nurl = https://profile:8089\ntoken = profile-secret\n") + cfgfile.chmod(0o644) + 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") + assert (cfg.base_url, cfg.token) == ("https://env:8089", "env-token") + + +def test_non_utf8_profile_raises_usage_error(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_bytes(b"[prod]\nurl = \xff\n") + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + with pytest.raises(UsageError, match="valid UTF-8"): + load_profile("prod") + + +def test_malformed_profile_raises_usage_error(tmp_path, monkeypatch): + cfgfile = tmp_path / "config" + cfgfile.write_text("[prod\nurl = https://splunk.test:8089\n") + monkeypatch.setenv("VCT_SPLUNK_CONFIG", str(cfgfile)) + with pytest.raises(UsageError, match="is malformed"): load_profile("prod") diff --git a/tests/unit/test_session_key_auth.py b/tests/unit/test_session_key_auth.py index 32c7ab0..b65773c 100644 --- a/tests/unit/test_session_key_auth.py +++ b/tests/unit/test_session_key_auth.py @@ -18,6 +18,8 @@ def _clean_env(monkeypatch): "SPLUNK_PASSWORD", "SPLUNK_CA_BUNDLE", "SPLUNK_VERIFY", + "SPLUNK_PROFILE", + "VCT_SPLUNK_CONFIG", ): monkeypatch.delenv(var, raising=False) @@ -55,18 +57,17 @@ def test_username_password_login(monkeypatch): seen: dict[str, object] = {} - def fake_post(url, *, data, verify, timeout): - seen["url"] = url - seen["data"] = data - return httpx.Response(200, json={"sessionKey": "LOGGEDIN"}) + def fake_login(url, username, password, *, verify): + seen.update({"url": url, "username": username, "password": password, "verify": verify}) + return "LOGGEDIN" - monkeypatch.setattr("vct_splunk.core.client.httpx.post", fake_post) + monkeypatch.setattr("vct_splunk.core.client.auth.login", fake_login) cfg = config_from_env() assert cfg.auth_scheme == "Splunk" assert cfg.token == "LOGGEDIN" - assert str(seen["url"]).endswith("/services/auth/login") - assert seen["data"]["username"] == "admin" # type: ignore[index] + assert seen["url"] == "https://splunk.test:8089" + assert seen["username"] == "admin" def _login_env(monkeypatch): @@ -86,7 +87,11 @@ def _login_env(monkeypatch): ) def test_login_error_responses_map_typed(_clean_env, monkeypatch, response, expected): _login_env(monkeypatch) - monkeypatch.setattr("vct_splunk.core.client.httpx.post", lambda *a, **k: response) + + 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() @@ -95,9 +100,9 @@ def test_login_unreachable_raises_transport_error(_clean_env, monkeypatch): _login_env(monkeypatch) def raise_connect(*a, **k): - raise httpx.ConnectError("connection refused") + raise TransportError("connection refused") - monkeypatch.setattr("vct_splunk.core.client.httpx.post", raise_connect) + monkeypatch.setattr("vct_splunk.core.client.auth.login", raise_connect) with pytest.raises(TransportError): config_from_env() From 293d40bb708cda4ccfd74d000f5eee079fa880f4 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:25:11 -0400 Subject: [PATCH 36/46] fix: report unavailable health checks neutrally --- src/vct_splunk/commands/health.py | 6 ++- src/vct_splunk/core/health.py | 43 ++++++++++++++-------- tests/unit/test_commands.py | 22 +++++++++++ tests/unit/test_health.py | 61 ++++++++++++++++++++++++------- 4 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/vct_splunk/commands/health.py b/src/vct_splunk/commands/health.py index 9e6ad1e..73b34ef 100644 --- a/src/vct_splunk/commands/health.py +++ b/src/vct_splunk/commands/health.py @@ -20,7 +20,11 @@ def check(ctx) -> None: """Check Splunk health. Exits 5 if any finding is warn or fail.""" with ctx.client() as c: verdicts = core.check_health(c) - out.emit(verdicts, ctx.output_mode, ctx.meta()) + out.emit( + verdicts, + ctx.output_mode, + {**ctx.meta(), "health_checks_version": core.HEALTH_CHECKS_VERSION}, + ) if any(v["finding"] in ("warn", "fail") for v in verdicts): # Exit 5 is reserved for health findings so scripts can tell a sick # Splunk (the check itself succeeded) from a failed request (exit 1). diff --git a/src/vct_splunk/core/health.py b/src/vct_splunk/core/health.py index 8d7ea19..bc78bee 100644 --- a/src/vct_splunk/core/health.py +++ b/src/vct_splunk/core/health.py @@ -14,13 +14,11 @@ from typing import Any from .client import SplunkClient -from .errors import SplunkError +from .errors import AuthError, NotFoundError, SplunkError from .search import run_search _FINDING = {"green": "pass", "yellow": "warn", "red": "fail"} -# Health checks ship as versioned data so a consumer can tell which generation of -# thresholds and SPL produced a verdict. Bump when a check's meaning changes. HEALTH_CHECKS_VERSION = "1" # Calibration knobs for the resource/introspection checks. They are named module @@ -29,6 +27,7 @@ _MEM_WARN_PCT = 90.0 # warn when used memory exceeds this percentage of total _DISK_WARN_FREE_PCT = 10.0 # warn when a partition's free space drops below this _ERROR_WARN_COUNT = 100 # warn when splunkd ERROR events in the window exceed this +_INTERNAL_ERROR_WINDOW = "15m" @dataclass @@ -42,7 +41,6 @@ class Verdict: def check_health(client: SplunkClient) -> list[dict[str, Any]]: verdicts = [ - Verdict("checks_version", "applicable", "completed", "pass", HEALTH_CHECKS_VERSION), _reachable(client), *_splunkd(client), *_resource_usage(client), @@ -70,15 +68,18 @@ def _splunkd(client: SplunkClient) -> list[Verdict]: try: body = client.get("/services/server/health/splunkd/details") except SplunkError as exc: - return [Verdict("splunkd_health", "unknown", "error", "fail", exc.message)] + return [_unavailable("splunkd_health", exc)] content = (body.get("entry") or [{}])[0].get("content", {}) + overall = content.get("health") + if overall not in _FINDING: + return [_unknown("splunkd_health", "missing or unrecognized overall health")] out = [ Verdict( "splunkd_overall", "applicable", "completed", - _FINDING.get(content.get("health"), "warn"), - f"health={content.get('health')}", + _FINDING[overall], + f"health={overall}", ) ] for name, feature in sorted((content.get("features") or {}).items()): @@ -110,7 +111,7 @@ def _resource_usage(client: SplunkClient) -> list[Verdict]: client.get("/services/server/status/resource-usage/hostwide").get("entry") or [{}] )[0].get("content", {}) except SplunkError as exc: - return [Verdict("resource_usage", "unknown", "error", "fail", exc.message)] + return [_unavailable("resource_usage", exc)] cpu_system = _to_float(content.get("cpu_system_pct")) cpu_user = _to_float(content.get("cpu_user_pct")) @@ -155,9 +156,9 @@ def _disk_space(client: SplunkClient) -> list[Verdict]: to read the endpoint collapses into a single error verdict. """ try: - entries = client.get("/services/server/status/partitions-space").get("entry") or [] + entries = client.get_collection("/services/server/status/partitions-space") except SplunkError as exc: - return [Verdict("disk_space", "unknown", "error", "fail", exc.message)] + return [_unavailable("disk_space", exc)] if not entries: return [_unknown("disk_space", "partition endpoint returned no data")] @@ -205,12 +206,12 @@ def _internal_errors(client: SplunkClient) -> list[Verdict]: body = run_search( client, "index=_internal sourcetype=splunkd log_level=ERROR | stats count as error_count", - earliest="-15m", + earliest=f"-{_INTERNAL_ERROR_WINDOW}", latest="now", max_rows=1, ) except SplunkError as exc: - return [Verdict("internal_errors", "unknown", "error", "fail", exc.message)] + return [_unavailable("internal_errors", exc)] results = body.get("results") or [] if not results or not isinstance(results[0], dict): @@ -225,14 +226,26 @@ def _internal_errors(client: SplunkClient) -> list[Verdict]: "applicable", "completed", "warn" if count > _ERROR_WARN_COUNT else "pass", - f"{count} splunkd ERROR events in 15m (warn>{_ERROR_WARN_COUNT})", + ( + f"{count} splunkd ERROR events in {_INTERNAL_ERROR_WINDOW} " + f"(warn>{_ERROR_WARN_COUNT})" + ), ) ] def _unknown(check: str, evidence: str) -> Verdict: - """Return a failed verdict for data whose health cannot be determined.""" - return Verdict(check, "unknown", "error", "fail", evidence) + """Return a neutral verdict for data whose health cannot be determined.""" + return Verdict(check, "unknown", "error", "na", evidence) + + +def _unavailable(check: str, exc: SplunkError) -> Verdict: + """Describe an optional check that the target cannot or will not expose.""" + if isinstance(exc, NotFoundError): + return Verdict(check, "not_applicable", "completed", "na", exc.message) + if isinstance(exc, AuthError): + return Verdict(check, "unknown", "permission_denied", "na", exc.message) + return Verdict(check, "unknown", "error", "na", exc.message) def _to_float(value: Any) -> float | None: diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index f127d7e..604cc68 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -10,6 +10,7 @@ from __future__ import annotations import httpx +import pytest from click.testing import CliRunner from vct_splunk.cli import cli @@ -116,6 +117,27 @@ def handler(req: httpx.Request) -> httpx.Response: assert '"finding": "fail"' in result.output +@pytest.mark.parametrize("status", [403, 404]) +def test_health_optional_checks_unavailable_exit_zero(cli_env, patch_client, status): + def handler(req: httpx.Request) -> httpx.Response: + if req.url.path == "/services/server/info": + return httpx.Response( + 200, + json={"entry": [{"content": {"version": "9.4", "serverName": "sh"}}]}, + ) + return httpx.Response(status, json={"messages": []}) + + patch_client(handler) + result = CliRunner().invoke(cli, ["health", "check", "--output", "json"]) + + assert result.exit_code == 0 + assert '"finding": "na"' in result.output + expected = "permission_denied" if status == 403 else "not_applicable" + assert f'"{expected}"' in result.output + assert '"health_checks_version": "1"' in result.output + assert '"check": "checks_version"' not in result.output + + def test_saved_search_create_requires_app(cli_env): # No --app and no SPLUNK_APP -> the write must refuse (exit 2), not target 'search'. result = CliRunner().invoke( diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index 3d9719a..ce06838 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -2,6 +2,7 @@ from collections.abc import Callable from typing import Any +from urllib.parse import parse_qs import httpx import pytest @@ -37,8 +38,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert verdicts["splunkd_overall"]["finding"] == "pass" assert verdicts["feature:Indexing"]["finding"] == "warn" assert verdicts["feature:Indexing"]["applicability"] == "applicable" - # Checks ship as versioned data, surfaced as its own verdict. - assert verdicts["checks_version"]["evidence"] == health.HEALTH_CHECKS_VERSION + assert "checks_version" not in verdicts def _resource_handler(content: dict[str, Any]) -> Callable[[httpx.Request], httpx.Response]: @@ -75,10 +75,10 @@ def test_resource_usage_normal_passes(client_for): {"cpu_system_pct": "nan", "cpu_user_pct": "10"}, ], ) -def test_resource_usage_unknown_cpu_is_error(client_for, content): +def test_resource_usage_unknown_cpu_is_neutral(client_for, content): verdicts = {v.check: v for v in health._resource_usage(client_for(_resource_handler(content)))} cpu = verdicts["resource_cpu"] - assert (cpu.applicability, cpu.execution, cpu.finding) == ("unknown", "error", "fail") + assert (cpu.applicability, cpu.execution, cpu.finding) == ("unknown", "error", "na") @pytest.mark.parametrize( @@ -91,13 +91,13 @@ def test_resource_usage_unknown_cpu_is_error(client_for, content): {"mem": "100", "mem_used": "garbage"}, ], ) -def test_resource_usage_unknown_memory_is_error(client_for, content): +def test_resource_usage_unknown_memory_is_neutral(client_for, content): verdicts = {v.check: v for v in health._resource_usage(client_for(_resource_handler(content)))} memory = verdicts["resource_memory"] assert (memory.applicability, memory.execution, memory.finding) == ( "unknown", "error", - "fail", + "na", ) @@ -125,13 +125,13 @@ def handler(req: httpx.Request) -> httpx.Response: return handler -def test_disk_space_empty_results_are_error(client_for): +def test_disk_space_empty_results_are_neutral(client_for): verdict = health._disk_space(client_for(_disk_handler([])))[0] assert (verdict.check, verdict.applicability, verdict.execution, verdict.finding) == ( "disk_space", "unknown", "error", - "fail", + "na", ) @@ -145,12 +145,12 @@ def test_disk_space_empty_results_are_error(client_for): {"capacity": "100", "free": "garbage"}, ], ) -def test_disk_space_unknown_partition_data_is_error(client_for, content): +def test_disk_space_unknown_partition_data_is_neutral(client_for, content): verdict = health._disk_space(client_for(_disk_handler([{"content": content}])))[0] assert (verdict.applicability, verdict.execution, verdict.finding) == ( "unknown", "error", - "fail", + "na", ) @@ -164,10 +164,14 @@ def test_disk_space_valid_zero_free_warns(client_for): def test_internal_errors_high_count_warns(client_for): # error_count comes back from Splunk as a string; the check must coerce it. def handler(req: httpx.Request) -> httpx.Response: + assert parse_qs(req.content.decode())["earliest_time"] == [ + f"-{health._INTERNAL_ERROR_WINDOW}" + ] return httpx.Response(200, json={"results": [{"error_count": "500"}]}) verdicts = {v.check: v for v in health._internal_errors(client_for(handler))} assert verdicts["internal_errors"].finding == "warn" # 500 > 100 threshold + assert health._INTERNAL_ERROR_WINDOW in verdicts["internal_errors"].evidence def test_internal_errors_low_count_passes(client_for): @@ -189,7 +193,7 @@ def handler(req: httpx.Request) -> httpx.Response: {"results": [{"error_count": "nan"}]}, ], ) -def test_internal_errors_unknown_data_is_error(client_for, body): +def test_internal_errors_unknown_data_is_neutral(client_for, body): def handler(req: httpx.Request) -> httpx.Response: return httpx.Response(200, json=body) @@ -197,7 +201,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert (verdict.applicability, verdict.execution, verdict.finding) == ( "unknown", "error", - "fail", + "na", ) @@ -228,7 +232,7 @@ def handler(req: httpx.Request) -> httpx.Response: assert (reachable["execution"], reachable["finding"]) == ("error", "fail") -def test_health_endpoint_error_reports_unknown_applicability(client_for): +def test_health_endpoint_error_reports_neutral_unknown_applicability(client_for): def handler(req: httpx.Request) -> httpx.Response: if req.url.path.endswith("/server/info"): return httpx.Response(200, json={"entry": [{"content": {"version": "9.4"}}]}) @@ -241,5 +245,34 @@ def handler(req: httpx.Request) -> httpx.Response: assert (splunkd["applicability"], splunkd["execution"], splunkd["finding"]) == ( "unknown", "error", - "fail", + "na", ) + + +def test_disk_space_paginates_all_partitions(client_for): + offsets: list[int] = [] + + def handler(req: httpx.Request) -> httpx.Response: + offset = int(req.url.params["offset"]) + offsets.append(offset) + size = 200 if offset == 0 else 1 + entries = [ + { + "content": { + "mount_point": f"/disk-{offset + index}", + "capacity": "100", + "free": "50", + } + } + for index in range(size) + ] + return httpx.Response( + 200, + json={"entry": entries, "paging": {"total": 201}}, + ) + + verdicts = health._disk_space(client_for(handler)) + + assert offsets == [0, 200] + assert len(verdicts) == 201 + assert all(verdict.finding == "pass" for verdict in verdicts) From cece464f363c6af906759bae6f9233212fc12d0a Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:25:13 -0400 Subject: [PATCH 37/46] docs: align authentication and health guidance --- .env.example | 24 ++++++++++-------------- AGENTS.md | 8 +++++--- CHANGELOG.md | 14 +++++++------- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index 5173efd..59bb66b 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,16 @@ SPLUNK_TOKEN= # (all owners); writes default to "nobody" (an app-level shared object). # SPLUNK_OWNER= +# --- Config profiles --------------------------------------------------------- + +# Select an INI section that can supply url/token/session_key/app/owner. +# Explicit flags win over environment values, which win over profile values. +# SPLUNK_PROFILE= + +# Override the profile path (default: +# $XDG_CONFIG_HOME/vct-splunk/config, else ~/.config/vct-splunk/config). +# VCT_SPLUNK_CONFIG= + # --- Splunk Cloud (ACS) ------------------------------------------------------ # Read-only this release. The backend is deduced from SPLUNK_URL: on a # *.splunkcloud.com host, supported reads route via the ACS API automatically @@ -55,18 +65,4 @@ SPLUNK_TOKEN= # ACS authentication token (Bearer). The stack name is derived from SPLUNK_URL; # set SPLUNK_ACS_STACK only to override it. # SPLUNK_ACS_TOKEN= -# --- Session login + config profiles (#13) ----------------------------------- -# Alternative to SPLUNK_TOKEN: a session key sent as `Authorization: Splunk `. -# Mint one with `splunk auth login`. SPLUNK_TOKEN (Bearer) wins if both are set. -# SPLUNK_SESSION_KEY= - -# For `splunk auth login`. The password is never a flag; it prompts if unset. -# SPLUNK_USERNAME= -# SPLUNK_PASSWORD= - -# Config-file profiles: choose an INI [section] (precedence flag > env > profile). -# SPLUNK_PROFILE= -# Override the config path (else $XDG_CONFIG_HOME/vct-splunk/config, ~/.config/vct-splunk/config). -# VCT_SPLUNK_CONFIG= - # SPLUNK_ACS_STACK= diff --git a/AGENTS.md b/AGENTS.md index 670e803..10e615f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ uv pip install -e ".[dev]" # editable install with dev tools splunk --help # or: python -m vct_splunk --help ``` -Authentication is environment-only (never a CLI flag): +Credentials come from the environment or a selected config profile; secret +credentials are never accepted directly as CLI flags: ```bash export SPLUNK_URL="https://your-search-head:8089" # REST mgmt port, not :8000 @@ -32,13 +33,14 @@ 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), `errors`, `audit`, `namespace` + 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`). - `src/vct_splunk/commands/` — Click adapters, one module per hand-written - command group (`server`, `api`, `search`, `health`, `inspect`, plus + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 635d169..70a93f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,13 +38,13 @@ This is the 0.2.0 development line (version bumped from 0.0.1). running real create -> verify -> cleanup, with integration coverage for the namespaced saved-search and factory user lifecycles (#14). - A Nix flake dev shell (`nix develop` / direnv) per the workspace convention (#15). -- Deeper health checks (resource usage, disk space, internal-error rate) shipped as - versioned check data (#11). -- Session-key auth: a credential in `SPLUNK_SESSION_KEY` is sent as - `Authorization: Splunk ` (alongside the existing `SPLUNK_TOKEN` -> - `Authorization: Bearer `), plus `auth login` (exchange a - username/password for a session key) and `auth status` (report the resolved - target and active scheme without revealing the secret) (#13). +- Deeper health checks for resource usage, disk space, and the internal-error + rate. The internal-error check uses a bounded search of `index=_internal`; + when the credential lacks that search capability, the check reports that it + is unavailable without marking the instance unhealthy (#11). +- `auth login` exchanges a username/password for a session key, and `auth + status` reports the resolved target and active scheme without revealing the + secret (#13). - Config-file profiles: a `--profile` option (and `$SPLUNK_PROFILE`) selects a named section in an INI file (`$VCT_SPLUNK_CONFIG`, else `$XDG_CONFIG_HOME/vct-splunk/config`) supplying `url` / `token` / From 091fbed6179991cb0be998644ec83cd53894ede1 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:26:56 -0400 Subject: [PATCH 38/46] fix: harden generated resource requests --- src/vct_splunk/commands/factory.py | 10 ++++-- src/vct_splunk/core/client.py | 12 ++----- src/vct_splunk/core/kvstore.py | 4 +-- src/vct_splunk/core/resource.py | 25 +++++++------ tests/unit/test_factory_cmd.py | 55 +++++++++++++++++++++++++++++ tests/unit/test_resource_factory.py | 50 ++++++++++++++++++++++++-- 6 files changed, 130 insertions(+), 26 deletions(-) diff --git a/src/vct_splunk/commands/factory.py b/src/vct_splunk/commands/factory.py index 4303362..ec18c7e 100644 --- a/src/vct_splunk/commands/factory.py +++ b/src/vct_splunk/commands/factory.py @@ -18,6 +18,8 @@ from ..core.errors import UsageError from ..core.namespace import resolve_ns +from ..core.parsing import parse_key_value_pairs +from ..core.path import path_segment from ..core.resource import CrudResource, Field, Spec from . import output as out from .context import AliasedGroup, command @@ -93,6 +95,7 @@ def _list(ctx) -> None: @click.argument("name") @command def _get(ctx, name) -> None: + path_segment(name, label=f"{spec.name} name") owner, app = _ns(ctx, spec, for_write=False) with ctx.client() as c: out.emit(res.get(c, name, owner=owner, app=app), ctx.output_mode, ctx.meta()) @@ -122,6 +125,7 @@ def _create(ctx, name, **opts) -> None: @_field_options(spec) @command def _update(ctx, name, **opts) -> None: + path_segment(name, label=f"{spec.name} name") owner, app = _ns(ctx, spec, for_write=True) fields, sets = _collect_fields(spec, opts) if not sets and all(v in (None, ()) for v in fields.values()): @@ -141,6 +145,7 @@ def _update(ctx, name, **opts) -> None: @click.argument("name") @command def _delete(ctx, name) -> None: + path_segment(name, label=f"{spec.name} name") owner, app = _ns(ctx, spec, for_write=True) action, event = _gate_args(spec, "delete", name, owner, app) result = do_write( @@ -165,6 +170,7 @@ def _add_control(grp: click.Group, spec: Spec, res: CrudResource, verb: str) -> @click.argument("name") @command def _control(ctx, name) -> None: + path_segment(name, label=f"{spec.name} name") owner, app = _ns(ctx, spec, for_write=True) action, event = _gate_args(spec, verb, name, owner, app) result = do_write( @@ -223,10 +229,10 @@ 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], tuple[str, ...]]: +def _collect_fields(spec: Spec, 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: tuple[str, ...] = tuple(values.pop("_set", ())) + sets = parse_key_value_pairs(values.pop("_set", ())) for f in spec.fields: if not f.secret: continue diff --git a/src/vct_splunk/core/client.py b/src/vct_splunk/core/client.py index 0cae11d..7773dbd 100644 --- a/src/vct_splunk/core/client.py +++ b/src/vct_splunk/core/client.py @@ -131,16 +131,8 @@ def __enter__(self) -> SplunkClient: 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) -> dict[str, Any]: - return self._request("GET", path, params=params) - - def get_json(self, path: str, params: dict[str, Any] | None = None) -> Any: - """GET a JSON document endpoint (e.g. KV Store data) and return it as-is. - - Unlike :meth:`get`, the response is plain JSON (an array for a collection, - an object for one record) rather than the ``entry[].content`` envelope, so - the return type is the raw parsed value. - """ + 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( diff --git a/src/vct_splunk/core/kvstore.py b/src/vct_splunk/core/kvstore.py index b2653d1..fc71fee 100644 --- a/src/vct_splunk/core/kvstore.py +++ b/src/vct_splunk/core/kvstore.py @@ -49,7 +49,7 @@ def list_records( params["query"] = query if limit is not None: params["limit"] = limit - return client.get_json(_path(collection, owner=owner, app=app), params or None) + return client.get(_path(collection, owner=owner, app=app), params or None) def get_record(client: SplunkClient, collection: str, key: str, *, owner: str, app: str) -> Any: @@ -59,7 +59,7 @@ def get_record(client: SplunkClient, collection: str, key: str, *, owner: str, a NotFoundError: If the record does not exist (a 404 already maps to NotFoundError in the client; an empty body is treated the same). """ - record = client.get_json(_path(collection, key=key, owner=owner, app=app)) + record = client.get(_path(collection, key=key, owner=owner, app=app)) if not record: raise NotFoundError(f"Record {key!r} not found in collection {collection!r}.") return record diff --git a/src/vct_splunk/core/resource.py b/src/vct_splunk/core/resource.py index 46b64f1..330c4a7 100644 --- a/src/vct_splunk/core/resource.py +++ b/src/vct_splunk/core/resource.py @@ -19,6 +19,7 @@ from .client import SplunkClient from .errors import NotFoundError from .namespace import ns_path +from .path import path_segment Verb = Literal["list", "get", "create", "update", "delete", "enable", "disable"] FieldType = Literal["str", "int", "float", "bool"] @@ -91,7 +92,8 @@ def list( def get( self, client: SplunkClient, name: str, *, owner: str | None = None, app: str | None = None ) -> dict[str, Any]: - entries = client.get(f"{self._base(owner, app)}/{name}").get("entry") or [] + encoded = path_segment(name, label=f"{self.spec.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.") return self._out(entries[0]) @@ -102,7 +104,7 @@ def create( name: str, *, fields: dict[str, Any], - sets: tuple[str, ...] = (), + sets: dict[str, str] | None = None, owner: str | None = None, app: str | None = None, ) -> dict[str, Any]: @@ -116,20 +118,22 @@ def update( name: str, *, fields: dict[str, Any], - sets: tuple[str, ...] = (), + sets: dict[str, str] | None = None, owner: str | None = None, app: str | None = None, ) -> dict[str, Any]: # Splunk's POST to the named object merges server-side, so only the # provided settings are sent (no read-modify-write). + encoded = path_segment(name, label=f"{self.spec.name} name") return self._unwrap( - client.write("POST", f"{self._base(owner, app)}/{name}", self._body(fields, sets)) + client.write("POST", f"{self._base(owner, app)}/{encoded}", self._body(fields, sets)) ) def delete( self, client: SplunkClient, name: str, *, owner: str | None = None, app: str | None = None ) -> dict[str, Any]: - return client.write("DELETE", f"{self._base(owner, app)}/{name}", {}) + encoded = path_segment(name, label=f"{self.spec.name} name") + return client.write("DELETE", f"{self._base(owner, app)}/{encoded}", {}) def control( self, @@ -141,7 +145,10 @@ def control( app: str | None = None, ) -> dict[str, Any]: """Run a control action (``enable`` / ``disable``) on one object.""" - return self._unwrap(client.write("POST", f"{self._base(owner, app)}/{name}/{action}", {})) + encoded = path_segment(name, label=f"{self.spec.name} name") + return self._unwrap( + client.write("POST", f"{self._base(owner, app)}/{encoded}/{action}", {}) + ) def _base(self, owner: str | None, app: str | None) -> str: if self.spec.namespaced: @@ -149,7 +156,7 @@ def _base(self, owner: str | None, app: str | None) -> str: return ns_path(self.spec.path, owner=owner or "-", app=app or "-") return self.spec.path - def _body(self, fields: dict[str, Any], sets: tuple[str, ...]) -> dict[str, Any]: + 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} data: dict[str, Any] = {} @@ -163,9 +170,7 @@ def _body(self, fields: dict[str, Any], sets: tuple[str, ...]) -> dict[str, Any] data[f.key] = int(bool(value)) else: data[f.key] = value - for pair in sets: - key, _, val = pair.partition("=") - data[key] = val + data.update(sets or {}) return data def _unwrap(self, result: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/unit/test_factory_cmd.py b/tests/unit/test_factory_cmd.py index d4912bb..31163dd 100644 --- a/tests/unit/test_factory_cmd.py +++ b/tests/unit/test_factory_cmd.py @@ -6,6 +6,7 @@ from __future__ import annotations import httpx +import pytest from click.testing import CliRunner from vct_splunk.cli import cli @@ -112,3 +113,57 @@ def handler(req: httpx.Request) -> httpx.Response: assert result.exit_code == 0, result.output assert seen["method"] == "POST" assert seen["path"] == "/services/data/inputs/monitor/mon1/disable" + + +@pytest.mark.parametrize( + "sets", + [ + ["--set", "missing"], + ["--set", "=value"], + ["--set", "key=one", "--set", "key=two"], + ], +) +def test_generated_set_rejects_malformed_pairs_before_request(cli_env, patch_client, sets): + requests: list[httpx.Request] = [] + patch_client(lambda req: requests.append(req) or httpx.Response(200, json={})) + + result = CliRunner().invoke( + cli, ["user", "create", "alice", *sets, "--yes", "--output", "json"] + ) + + assert result.exit_code == 2 + assert "usage_error" in result.output + assert requests == [] + + +def test_generated_set_preserves_explicit_empty_value(cli_env): + result = CliRunner().invoke( + cli, + [ + "user", + "create", + "alice", + "--set", + "email=", + "--dry-run", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + assert '"email": ""' in result.output + + +@pytest.mark.parametrize("verb", ["get", "update", "delete", "disable"]) +def test_generated_dynamic_name_refuses_traversal_before_request(cli_env, patch_client, verb): + requests: list[httpx.Request] = [] + patch_client(lambda req: requests.append(req) or httpx.Response(200, json={})) + extra = ["--set", "disabled=1", "--yes"] if verb == "update" else [] + if verb in {"delete", "disable"}: + extra = ["--yes"] + + result = CliRunner().invoke(cli, ["monitor-input", verb, "../etc", *extra, "--output", "json"]) + + assert result.exit_code == 2 + assert requests == [] diff --git a/tests/unit/test_resource_factory.py b/tests/unit/test_resource_factory.py index f414130..64142ca 100644 --- a/tests/unit/test_resource_factory.py +++ b/tests/unit/test_resource_factory.py @@ -10,7 +10,7 @@ import httpx import pytest -from vct_splunk.core.errors import NotFoundError +from vct_splunk.core.errors import NotFoundError, UsageError from vct_splunk.core.resource import CrudResource, Field, Spec GLOBAL_SPEC = Spec( @@ -46,7 +46,7 @@ def handler(req: httpx.Request) -> httpx.Response: client_for(handler), "w1", fields={"size_gb": 2, "color": "red"}, - sets=("extra=1",), + sets={"extra": "1"}, ) body = seen["body"] assert seen["method"] == "POST" @@ -82,3 +82,49 @@ def test_get_missing_raises_notfound(client_for): CrudResource(GLOBAL_SPEC).get( client_for(lambda req: httpx.Response(200, json={"entry": []})), "nope" ) + + +@pytest.mark.parametrize("operation", ["get", "update", "delete", "control"]) +def test_dynamic_name_paths_are_encoded(client_for, operation): + seen: dict[str, str] = {} + + 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) + client = client_for(handler) + if operation == "get": + resource.get(client, "east west") + suffix = "" + elif operation == "update": + resource.update(client, "east west", fields={"color": "blue"}) + suffix = "" + elif operation == "delete": + resource.delete(client, "east west") + suffix = "" + else: + resource.control(client, "east west", "enable") + suffix = "/enable" + + assert seen["path"] == f"/services/data/widgets/east%20west{suffix}" + + +@pytest.mark.parametrize("operation", ["get", "update", "delete", "control"]) +@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) + client = client_for(lambda req: requests.append(req) or httpx.Response(200, json={"entry": []})) + + with pytest.raises(UsageError): + if operation == "get": + resource.get(client, name) + elif operation == "update": + resource.update(client, name, fields={"color": "blue"}) + elif operation == "delete": + resource.delete(client, name) + else: + resource.control(client, name, "enable") + + assert requests == [] From ed36714657a8d54dc65d417a60a2af098a497d1d Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:27:29 -0400 Subject: [PATCH 39/46] fix: redact app install dry-run sources --- CHANGELOG.md | 5 +++-- src/vct_splunk/commands/apps.py | 2 +- src/vct_splunk/core/apps.py | 16 ++++++++++++--- tests/unit/test_app_deploy_contracts.py | 27 ++++++++++++++++++++++++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 030128c..dbe4271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,9 @@ This is the 0.2.0 development line (version bumped from 0.0.1). schemas, system messages, and app lifecycle (#5, #6, #8, #9, #10). - `kvstore records` / `get` / `insert` / `update` / `delete` / `purge` manage KV Store data records as a namespaced JSON document store; writes require an app (#9). -- `app install` adds an app from a local `--file` or a `--url`, with `--update` to - overwrite. It is a gated write, so it previews with `--dry-run` (#5). +- `app install` adds an app from a splunkd-readable `--server-file` or `--url`, + with `--update` to overwrite. It is a gated write, so it previews with + `--dry-run` (#5). - `deploy` reads the deployment server: `deploy client list` and `deploy serverclass list` / `get`. The gated writes `deploy serverclass create` / `update` (each needs at least one `--set KEY=VALUE`) and `deploy reload` change diff --git a/src/vct_splunk/commands/apps.py b/src/vct_splunk/commands/apps.py index 9d09cca..8449e0c 100644 --- a/src/vct_splunk/commands/apps.py +++ b/src/vct_splunk/commands/apps.py @@ -37,7 +37,7 @@ def app_install(ctx, server_file, url, update) -> None: ctx, action=f"install app from '{safe_source}'" + (" (overwrite)" if update else ""), audit_event={"action": "app.install", "source": safe_source, "update": update}, - run=lambda c: core.install_app(c, source, update=update), + run=lambda c: core.install_app(c, source, update=update, preview_source=safe_source), ) out.emit(result, ctx.output_mode, ctx.meta()) diff --git a/src/vct_splunk/core/apps.py b/src/vct_splunk/core/apps.py index 2a485db..3e7a91b 100644 --- a/src/vct_splunk/core/apps.py +++ b/src/vct_splunk/core/apps.py @@ -17,14 +17,24 @@ _PATH = "/services/apps/local" -def install_app(client: SplunkClient, source: str, *, update: bool = False) -> dict[str, Any]: +def install_app( + client: SplunkClient, + source: str, + *, + update: bool = False, + preview_source: str | None = None, +) -> dict[str, Any]: """Install an app from a server-readable path or an http(s) URL. ``source`` is a server-side path (``.tar.gz``/``.spl``) or the URL; - Splunk reads it server-side. ``update=True`` allows overwriting an app that + Splunk reads it server-side. ``preview_source`` is a sanitized equivalent + used only in dry-run output. ``update=True`` allows overwriting an app that is already installed. This is a gated write (dry-run aware via the client). """ - data: dict[str, Any] = {"name": source} + display_source = ( + preview_source if client.config.dry_run and preview_source is not None else source + ) + data: dict[str, Any] = {"name": display_source} if update: data["update"] = "true" return client.write("POST", _PATH, data) diff --git a/tests/unit/test_app_deploy_contracts.py b/tests/unit/test_app_deploy_contracts.py index 64dd43e..0972d48 100644 --- a/tests/unit/test_app_deploy_contracts.py +++ b/tests/unit/test_app_deploy_contracts.py @@ -44,13 +44,20 @@ def test_app_install_audit_uses_sanitized_url(cli_env, patch_client, monkeypatch audit = tmp_path / "audit.log" monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(audit)) source = "https://user:password@example.test:8443/apps/example.spl?token=secret#fragment" - patch_client(lambda req: httpx.Response(201, json={"entry": []})) + seen: dict[str, list[str]] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen.update(parse_qs(req.content.decode())) + return httpx.Response(201, json={"entry": []}) + + patch_client(handler) result = CliRunner().invoke( cli, ["app", "install", "--url", source, "--yes", "--output", "json"] ) assert result.exit_code == 0 + assert seen == {"name": [source]} record = json.loads(audit.read_text()) assert record["source"] == "https://example.test:8443/apps/example.spl" assert "user" not in audit.read_text() @@ -60,6 +67,24 @@ def test_app_install_audit_uses_sanitized_url(cli_env, patch_client, monkeypatch assert "fragment" not in audit.read_text() +def test_app_install_dry_run_uses_sanitized_url(cli_env, patch_client): + source = "https://user:password@example.test:8443/apps/example.spl?token=secret#fragment" + patch_client( + lambda req: (_ for _ in ()).throw(AssertionError("dry-run must not send a request")) + ) + + result = CliRunner().invoke( + cli, + ["app", "install", "--url", source, "--dry-run", "--output", "json"], + ) + + assert result.exit_code == 0 + body = json.loads(result.output) + assert body["data"]["request"]["body"] == {"name": "https://example.test:8443/apps/example.spl"} + for secret in ("user", "password", "token", "secret", "fragment"): + assert secret not in result.output + + def test_app_install_help_has_no_caller_local_file_option(): result = CliRunner().invoke(cli, ["app", "install", "--help"]) From 482d93791ca0a04c2a8887b40f49850334ab3d29 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:28:00 -0400 Subject: [PATCH 40/46] fix: preserve data model acceleration tuning --- CHANGELOG.md | 2 +- src/vct_splunk/core/datamodel.py | 39 ++++++++---- tests/unit/test_hec_knowledge_contracts.py | 71 +++++++++++++++++++--- 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b37506e..e36c9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ This is the 0.2.0 development line (version bumped from 0.0.1). whole HTTP Event Collector on or off. Both are gated writes (#6). - `tag` and `datamodel` join the generated CRUD groups for field-value tags and data models; large fields go through `--set`. `datamodel accelerate` toggles a - data model's acceleration, and `lookup upload --file PATH --app APP` adds a CSV + data model's acceleration, and `lookup upload --server-file PATH --app APP` adds a CSV lookup table file to an app. Both are gated, namespaced writes (#8). - `kvstore records` / `get` / `insert` / `update` / `delete` / `purge` manage KV Store data records as a namespaced JSON document store; writes require an app (#9). diff --git a/src/vct_splunk/core/datamodel.py b/src/vct_splunk/core/datamodel.py index 6dba418..a75e8be 100644 --- a/src/vct_splunk/core/datamodel.py +++ b/src/vct_splunk/core/datamodel.py @@ -6,9 +6,11 @@ from __future__ import annotations +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 @@ -20,16 +22,31 @@ def accelerate( ) -> dict[str, Any]: """Toggle acceleration on a data model. - Posts to the model stanza with an ``acceleration`` JSON field carrying - ``{"enabled": true|false}``. Splunk merges this into the model's - acceleration settings; only the ``enabled`` flag is changed. - - ponytail: we send only ``acceleration={"enabled": ...}`` to the model - endpoint -- the simplest form Splunk accepts. Other acceleration knobs - (earliest time, cron) are reachable via ``datamodel update --set`` and are - not modeled here until a real need appears. + Splunk replaces the model's ``acceleration`` document rather than merging + its members. Applied writes therefore read the current document first and + change only ``enabled``, preserving tuning such as earliest time and cron. + Dry-runs remain request-free and preview the requested flag. """ - flag = "true" if enabled else "false" - body = {"acceleration": f'{{"enabled": {flag}}}'} encoded = path_segment(name, label="data model name") - return client.write("POST", ns_path(f"{_MODEL}/{encoded}", owner=owner, app=app), body) + path = ns_path(f"{_MODEL}/{encoded}", owner=owner, app=app) + acceleration: dict[str, Any] = {} + if not client.config.dry_run: + entries = client.get(path).get("entry") or [] + if not entries: + raise NotFoundError(f"Data model {name!r} not found.") + raw = (entries[0].get("content") or {}).get("acceleration") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (TypeError, json.JSONDecodeError) as exc: + raise APIError( + "Data model response contained malformed acceleration settings." + ) from exc + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise APIError("Data model response contained malformed acceleration settings.") + acceleration = dict(raw) + acceleration["enabled"] = enabled + body = {"acceleration": json.dumps(acceleration, separators=(",", ":"))} + return client.write("POST", path, body) diff --git a/tests/unit/test_hec_knowledge_contracts.py b/tests/unit/test_hec_knowledge_contracts.py index 33d0091..f41329a 100644 --- a/tests/unit/test_hec_knowledge_contracts.py +++ b/tests/unit/test_hec_knowledge_contracts.py @@ -72,14 +72,33 @@ def test_dynamic_names_refuse_traversal_before_request(cli_env, patch_client, na def test_datamodel_accelerate_encodes_name_and_sends_exact_body(cli_env, patch_client): - seen: dict[str, object] = {} + seen: list[dict[str, object]] = [] def handler(req: httpx.Request) -> httpx.Response: - seen.update( - method=req.method, - path=req.url.raw_path.decode().partition("?")[0], - form=parse_qs(req.content.decode()), + seen.append( + { + "method": req.method, + "path": req.url.raw_path.decode().partition("?")[0], + "form": parse_qs(req.content.decode()), + } ) + if req.method == "GET": + return httpx.Response( + 200, + json={ + "entry": [ + { + "name": "Auth Model", + "content": { + "acceleration": ( + '{"enabled":false,"earliest_time":"-30d",' + '"cron_schedule":"15 * * * *"}' + ) + }, + } + ] + }, + ) return httpx.Response(200, json={"entry": []}) patch_client(handler) @@ -98,11 +117,43 @@ def handler(req: httpx.Request) -> httpx.Response: ) assert result.exit_code == 0 - assert seen == { - "method": "POST", - "path": "/servicesNS/nobody/search/datamodel/model/Auth%20Model", - "form": {"acceleration": ['{"enabled": true}']}, - } + assert seen == [ + { + "method": "GET", + "path": "/servicesNS/nobody/search/datamodel/model/Auth%20Model", + "form": {}, + }, + { + "method": "POST", + "path": "/servicesNS/nobody/search/datamodel/model/Auth%20Model", + "form": { + "acceleration": [ + '{"enabled":true,"earliest_time":"-30d","cron_schedule":"15 * * * *"}' + ] + }, + }, + ] + + +def test_datamodel_accelerate_refuses_malformed_current_settings(cli_env, patch_client): + requests: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + requests.append(req) + return httpx.Response( + 200, + json={"entry": [{"name": "model", "content": {"acceleration": "not-json"}}]}, + ) + + patch_client(handler) + result = CliRunner().invoke( + cli, + ["datamodel", "accelerate", "model", "--app", "search", "--yes", "--output", "json"], + ) + + assert result.exit_code == 1 + assert [request.method for request in requests] == ["GET"] + assert "malformed acceleration settings" in result.output def test_lookup_upload_sends_server_staging_path(cli_env, patch_client): From fd38bcb5ca22930ffab5b1467d6b75e01cf03686 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:29:22 -0400 Subject: [PATCH 41/46] test: catalog every canonical CLI command --- src/vct_splunk/commands/saved_search.py | 3 + tests/cli_catalog.py | 150 ++++++++++++++++ tests/unit/test_cli_matrix.py | 218 +++++++----------------- tests/unit/test_resource_factory.py | 19 +++ 4 files changed, 232 insertions(+), 158 deletions(-) create mode 100644 tests/cli_catalog.py diff --git a/src/vct_splunk/commands/saved_search.py b/src/vct_splunk/commands/saved_search.py index 8893b6c..5d283f7 100644 --- a/src/vct_splunk/commands/saved_search.py +++ b/src/vct_splunk/commands/saved_search.py @@ -12,6 +12,7 @@ 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 .context import command @@ -38,6 +39,8 @@ def run(ctx, name, trigger_actions, earliest, latest) -> None: so the namespace resolves like a write — explicit app, owner defaulting to ``nobody`` (found live against Splunk 10.2). """ + if ctx.backend == "cloud": + raise UnsupportedBackendError("saved-search", "run", "cloud") owner, app = resolve_ns(ctx.owner, ctx.app, for_write=True) if ctx.dry_run: # Built by the same core function as the real request, so the preview diff --git a/tests/cli_catalog.py b/tests/cli_catalog.py new file mode 100644 index 0000000..611ba8f --- /dev/null +++ b/tests/cli_catalog.py @@ -0,0 +1,150 @@ +"""Canonical CLI leaf catalog shared by exhaustive tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +import click + +from vct_splunk.commands.context import AliasedGroup +from vct_splunk.commands.registry import INDEX, REGISTRY, SAVED_SEARCH + +Kind = Literal["read", "write"] + + +@dataclass(frozen=True) +class Case: + """One canonical leaf with its semantic kind and representative invocations.""" + + path: tuple[str, ...] + kind: Kind + argvs: tuple[tuple[str, ...], ...] + + +_VERB_ARGS: dict[str, tuple[str, ...]] = { + "list": (), + "get": ("example",), + "create": ("example", "--dry-run"), + "update": ("example", "--set", "key=value", "--dry-run"), + "delete": ("example", "--dry-run"), + "enable": ("example", "--dry-run"), + "disable": ("example", "--dry-run"), +} + +_SPECIAL: tuple[Case, ...] = ( + Case(("api", "get"), "read", (("/services/server/info", "-q", "count=1"),)), + Case(("app", "install"), "write", (("--server-file", "/tmp/app.spl", "--dry-run"),)), + Case(("auth", "login"), "read", (("--username", "admin"),)), + Case(("auth", "status"), "read", ((),)), + Case(("cluster", "status"), "read", ((),)), + Case(("datamodel", "accelerate"), "write", (("model", "--app", "my_app", "--dry-run"),)), + Case(("deploy", "client", "list"), "read", ((),)), + Case(("deploy", "reload"), "write", (("--dry-run",),)), + Case(("deploy", "serverclass", "list"), "read", ((),)), + Case(("deploy", "serverclass", "get"), "read", (("class",),)), + Case( + ("deploy", "serverclass", "create"), + "write", + (("class", "--set", "whitelist.0=*", "--dry-run"),), + ), + Case( + ("deploy", "serverclass", "update"), + "write", + (("class", "--set", "whitelist.0=*", "--dry-run"),), + ), + Case(("health", "check"), "read", ((),)), + Case(("hec", "global-disable"), "write", (("--dry-run",),)), + Case(("hec", "global-enable"), "write", (("--dry-run",),)), + Case(("hec", "rotate"), "write", (("token", "--dry-run"),)), + Case(("inspect",), "read", ((),)), + Case(("kvstore", "records"), "read", (("records",),)), + Case(("kvstore", "get"), "read", (("records", "key"),)), + Case( + ("kvstore", "insert"), + "write", + (("records", "--data", '{"value":"x"}', "--dry-run"),), + ), + Case( + ("kvstore", "update"), + "write", + (("records", "key", "--data", '{"value":"x"}', "--dry-run"),), + ), + Case(("kvstore", "delete"), "write", (("records", "key", "--dry-run"),)), + Case(("kvstore", "purge"), "write", (("records", "--dry-run"),)), + Case(("license", "usage"), "read", ((),)), + Case(("license", "get"), "read", (("license",),)), + Case(("license", "list"), "read", ((),)), + Case( + ("lookup", "upload"), + "write", + (("--server-file", "/var/tmp/table.csv", "--app", "my_app", "--dry-run"),), + ), + Case( + ("saved-search", "run"), + "write", + (("nightly", "--app", "my_app", "--earliest", "-1h", "--dry-run"),), + ), + Case(("search", "cancel"), "write", (("sid1", "--dry-run"),)), + Case(("search", "get"), "read", (("sid1",),)), + Case(("search", "list"), "read", ((),)), + Case( + ("search", "run"), + "read", + (("--query", "index=_internal", "--earliest", "-1h", "--max-rows", "5"),), + ), + Case(("server", "info"), "read", ((),)), + Case(("server", "restart"), "write", (("--dry-run",),)), + Case(("server", "settings", "get"), "read", ((),)), + Case(("server", "settings", "set"), "write", (("--set", "host=x", "--dry-run"),)), + Case(("shcluster", "status"), "read", ((),)), +) + + +def _generated_cases() -> tuple[Case, ...]: + cases: list[Case] = [] + for spec in (INDEX, SAVED_SEARCH, *REGISTRY): + for verb in spec.verbs: + args = _VERB_ARGS[verb] + if spec is SAVED_SEARCH and verb == "create": + args = (*args, "--search", "index=x") + cases.append( + Case( + (spec.name, verb), + "read" if verb in {"list", "get"} else "write", + (args,), + ) + ) + return tuple(cases) + + +CATALOG: tuple[Case, ...] = (*_generated_cases(), *_SPECIAL) + + +def iter_leaves(group: click.Group, path: tuple[str, ...] = ()): + """Yield canonical Click leaves, excluding aliases.""" + for name, command in sorted(group.commands.items()): + if isinstance(command, click.Group): + yield from iter_leaves(command, (*path, name)) + else: + yield (*path, name), command + + +def iter_groups(group: click.Group, path: tuple[str, ...] = ()): + """Yield canonical Click groups.""" + yield path, group + for name, command in sorted(group.commands.items()): + if isinstance(command, click.Group): + yield from iter_groups(command, (*path, name)) + + +def help_invocations(root: click.Group) -> list[list[str]]: + """Return canonical and alias help invocations without cataloguing aliases.""" + argvs: list[list[str]] = [["--help"]] + for path, group in iter_groups(root): + if path: + argvs.append([*path, "--help"]) + if isinstance(group, AliasedGroup): + argvs.extend([*path, alias, "--help"] for alias in group._aliases) + argvs.extend([*path, "--help"] for path, _ in iter_leaves(root)) + return argvs diff --git a/tests/unit/test_cli_matrix.py b/tests/unit/test_cli_matrix.py index 35d502d..09ee500 100644 --- a/tests/unit/test_cli_matrix.py +++ b/tests/unit/test_cli_matrix.py @@ -1,130 +1,20 @@ -"""Exhaustive wiring test: every command in the tree works with common inputs. - -Two guarantees, kept automatically as commands are added: - -1. ``--help`` succeeds for the root, every group, every leaf command, and every - registered alias — a broken decorator, bad option declaration, or import slip - anywhere in the tree fails here. -2. Every leaf command runs once with representative arguments against the mocked - transport: reads must exit 0 with the ``{data, meta}`` envelope; writes run - under ``--dry-run`` and must preview. A new command that this table cannot - infer arguments for fails the completeness check until it gets an entry. -""" +"""Exhaustive execution and completeness checks for the canonical CLI catalog.""" from __future__ import annotations import json +from collections import Counter -import click import httpx import pytest from click.testing import CliRunner +from cli_catalog import CATALOG, help_invocations, iter_leaves from vct_splunk.cli import cli -from vct_splunk.commands.context import AliasedGroup - -# Non-CRUD leaves the generic verb rules below cannot infer. Writes include -# --dry-run; reads run for real against the mock. -_SPECIAL: dict[tuple[str, ...], list[str]] = { - ("app", "install"): ["--server-file", "/tmp/app.spl", "--dry-run"], - ("auth", "login"): ["--username", "admin"], - ("auth", "status"): [], - ("cluster", "status"): [], - ("deploy", "client", "list"): [], - ("deploy", "reload"): ["--dry-run"], - ("deploy", "serverclass", "list"): [], - ("deploy", "serverclass", "get"): ["class"], - ("deploy", "serverclass", "create"): [ - "class", - "--set", - "whitelist.0=*", - "--dry-run", - ], - ("deploy", "serverclass", "update"): [ - "class", - "--set", - "whitelist.0=*", - "--dry-run", - ], - ("server", "info"): [], - ("api", "get"): ["/services/server/info", "-q", "count=1"], - ("search", "run"): ["--query", "index=_internal", "--earliest", "-1h", "--max-rows", "5"], - ("search", "list"): [], - ("search", "get"): ["sid1"], - ("search", "cancel"): ["sid1", "--dry-run"], - ("saved-search", "run"): ["nightly", "--app", "my_app", "--earliest", "-1h"], - ("health", "check"): [], - ("hec", "rotate"): ["token", "--dry-run"], - ("hec", "global-enable"): ["--dry-run"], - ("hec", "global-disable"): ["--dry-run"], - ("inspect",): [], - ("kvstore", "records"): ["records"], - ("kvstore", "get"): ["records", "key"], - ("kvstore", "insert"): ["records", "--data", '{"value":"x"}', "--dry-run"], - ("kvstore", "update"): [ - "records", - "key", - "--data", - '{"value":"x"}', - "--dry-run", - ], - ("kvstore", "delete"): ["records", "key", "--dry-run"], - ("kvstore", "purge"): ["records", "--dry-run"], - ("license", "usage"): [], - ("lookup", "upload"): ["--server-file", "/var/tmp/table.csv", "--app", "my_app", "--dry-run"], - ("datamodel", "accelerate"): ["model", "--app", "my_app", "--dry-run"], - ("server", "restart"): ["--dry-run"], - ("server", "settings", "get"): [], - ("server", "settings", "set"): ["--set", "host=x", "--dry-run"], - ("shcluster", "status"): [], -} - -# Generic argument rules by CRUD verb (factory-generated and factory-shaped groups). -_BY_VERB: dict[str, list[str]] = { - "list": [], - "get": ["x"], - "create": ["x", "--dry-run"], - "update": ["x", "--set", "k=v", "--dry-run"], - "delete": ["x", "--dry-run"], - "enable": ["x", "--dry-run"], - "disable": ["x", "--dry-run"], -} - -# Required create options the verb rule must add, per group. -_REQUIRED_CREATE: dict[str, list[str]] = { - "saved-search": ["--search", "index=x"], -} - - -def _iter_leaves(group: click.Group, path: tuple[str, ...] = ()): - for name, cmd in sorted(group.commands.items()): - if isinstance(cmd, click.Group): - yield from _iter_leaves(cmd, (*path, name)) - else: - yield (*path, name), cmd - - -def _iter_groups(group: click.Group, path: tuple[str, ...] = ()): - yield path, group - for name, cmd in sorted(group.commands.items()): - if isinstance(cmd, click.Group): - yield from _iter_groups(cmd, (*path, name)) - - -def _args_for(path: tuple[str, ...]) -> list[str] | None: - if path in _SPECIAL: - return list(_SPECIAL[path]) - if len(path) == 2 and path[1] in _BY_VERB: - args = list(_BY_VERB[path[1]]) - extra = _REQUIRED_CREATE.get(path[0]) - if path[1] == "create" and extra: - args += extra - return args - return None def _handler(req: httpx.Request) -> httpx.Response: - """One canned Splunk that satisfies every read the tree performs.""" + """One request recorder response set that satisfies every read leaf.""" path = req.url.path if path.endswith("/dispatch"): return httpx.Response(201, json={"sid": "sid1"}) @@ -142,7 +32,7 @@ def _handler(req: httpx.Request) -> httpx.Response: json={ "entry": [ { - "name": "x", + "name": "example", "content": content, "acl": {"app": "a", "owner": "o", "sharing": "app"}, } @@ -152,51 +42,70 @@ def _handler(req: httpx.Request) -> httpx.Response: ) -def _all_help_invocations() -> list[list[str]]: - argvs: list[list[str]] = [["--help"]] - for path, group in _iter_groups(cli): - if path: - argvs.append([*path, "--help"]) - # Aliases resolve through AliasedGroup.get_command; --help must work there too. - if isinstance(group, AliasedGroup): - argvs.extend([*path, alias, "--help"] for alias in group._aliases) - argvs.extend([*path, "--help"] for path, _ in _iter_leaves(cli)) - return argvs - - -@pytest.mark.parametrize("argv", _all_help_invocations(), ids=" ".join) +@pytest.mark.parametrize("argv", help_invocations(cli), ids=" ".join) def test_every_help_screen_renders(argv): result = CliRunner().invoke(cli, argv) assert result.exit_code == 0, f"{argv}: {result.output}" assert "Usage:" in result.output -def test_every_leaf_has_representative_args(): - # Completeness gate: a newly added command must be covered by a verb rule or - # get a _SPECIAL entry — otherwise this test names it and fails. - uncovered = [" ".join(path) for path, _ in _iter_leaves(cli) if _args_for(path) is None] - assert not uncovered, f"add matrix args for: {uncovered}" +def test_catalog_is_exactly_complete_and_unique(): + live = {path for path, _ in iter_leaves(cli)} + counts = Counter(case.path for case in CATALOG) + duplicates = sorted(" ".join(path) for path, count in counts.items() if count != 1) + catalogued = set(counts) + + assert duplicates == [], f"duplicate catalog commands: {duplicates}" + assert sorted(" ".join(path) for path in live - catalogued) == [], "missing catalog commands" + assert sorted(" ".join(path) for path in catalogued - live) == [], "stale catalog commands" + assert len(CATALOG) == 153 + assert sum(case.kind == "read" for case in CATALOG) == 61 + assert sum(case.kind == "write" for case in CATALOG) == 92 + assert all(1 <= len(case.argvs) <= 2 for case in CATALOG) -@pytest.mark.parametrize("path", [p for p, _ in _iter_leaves(cli)], ids=lambda p: " ".join(p)) -def test_every_leaf_runs_with_common_inputs(path, cli_env, patch_client, monkeypatch, tmp_path): - monkeypatch.setenv("SPLUNK_APP", "my_app") # satisfies namespaced writes +@pytest.mark.parametrize("case", CATALOG, ids=lambda case: " ".join(case.path)) +def test_every_leaf_executes(case, cli_env, patch_client, monkeypatch, tmp_path): + monkeypatch.setenv("SPLUNK_APP", "my_app") monkeypatch.setenv("SPLUNK_PASSWORD", "secret") monkeypatch.setenv("VCT_SPLUNK_AUDIT", str(tmp_path / "audit.log")) - if path == ("auth", "login"): - monkeypatch.setattr("vct_splunk.commands.auth.core.login", lambda *a, **k: "SK") - patch_client(_handler) - argv = [*path, *(_args_for(path) or []), "--output", "json"] - result = CliRunner().invoke(cli, argv) - assert result.exit_code == 0, f"{argv}: {result.output}" - payload = json.loads(result.output) - assert "data" in payload # the success envelope, for reads and previews alike - if "--dry-run" in argv: - assert payload["data"]["dry_run"] is True # writes never hit the wire here + requests: list[httpx.Request] = [] + + def record(req: httpx.Request) -> httpx.Response: + requests.append(req) + return _handler(req) + + patch_client(record) + if case.path == ("auth", "login"): + monkeypatch.setattr("vct_splunk.commands.auth.core.login", lambda *args, **kwargs: "SK") + + for args in case.argvs: + argv = [*case.path, *args, "--output", "json"] + result = CliRunner().invoke(cli, argv) + assert result.exit_code == 0, f"{argv}: {result.output}" + assert "secret" not in result.output + payload = json.loads(result.output) + assert set(payload) == {"data", "meta"} + if case.kind == "write": + preview = payload["data"] + assert preview["dry_run"] is True + assert preview["request"]["method"] in {"POST", "DELETE"} + assert preview["request"]["path"].startswith("/") + assert "body" in preview["request"] + else: + assert isinstance(payload["data"], (dict, list)) + offline = {("auth", "login"), ("auth", "status"), ("inspect",)} + if case.kind == "read" and case.path not in offline: + assert requests, f"{' '.join(case.path)} did not exercise its transport" -def test_every_write_leaf_refuses_cloud_before_client_creation(cli_env, monkeypatch, tmp_path): - """Every catalogued write stops at the shared Cloud guard before any client.""" + +@pytest.mark.parametrize( + "case", + [case for case in CATALOG if case.kind == "write"], + ids=lambda case: " ".join(case.path), +) +def test_every_write_refuses_cloud_before_client_creation(case, cli_env, monkeypatch, tmp_path): monkeypatch.setenv("SPLUNK_URL", "https://acme.splunkcloud.com") monkeypatch.setenv("SPLUNK_APP", "my_app") monkeypatch.setenv("SPLUNK_PASSWORD", "secret") @@ -207,14 +116,7 @@ def unexpected_client(*args, **kwargs): monkeypatch.setattr("vct_splunk.commands.context.Ctx.client", unexpected_client) monkeypatch.setattr("vct_splunk.commands.context.Ctx.acs_client", unexpected_client) - - writes = [ - (path, _args_for(path) or []) - for path, _ in _iter_leaves(cli) - if "--dry-run" in (_args_for(path) or []) - ] - assert writes - for path, args in writes: - result = CliRunner().invoke(cli, [*path, *args, "--output", "json"]) - assert result.exit_code == 4, f"{' '.join(path)}: {result.output}" + for args in case.argvs: + result = CliRunner().invoke(cli, [*case.path, *args, "--output", "json"]) + assert result.exit_code == 4, f"{' '.join(case.path)}: {result.output}" assert "unsupported_backend" in result.output diff --git a/tests/unit/test_resource_factory.py b/tests/unit/test_resource_factory.py index f414130..ea0ec4f 100644 --- a/tests/unit/test_resource_factory.py +++ b/tests/unit/test_resource_factory.py @@ -10,6 +10,7 @@ import httpx import pytest +from vct_splunk.commands.registry import INDEX, REGISTRY, SAVED_SEARCH from vct_splunk.core.errors import NotFoundError from vct_splunk.core.resource import CrudResource, Field, Spec @@ -82,3 +83,21 @@ def test_get_missing_raises_notfound(client_for): CrudResource(GLOBAL_SPEC).get( client_for(lambda req: httpx.Response(200, json={"entry": []})), "nope" ) + + +@pytest.mark.parametrize("spec", [INDEX, SAVED_SEARCH, *REGISTRY], ids=lambda spec: spec.name) +def test_every_registry_spec_uses_the_generic_read_engine(spec, client_for): + seen: dict[str, str] = {} + + def handler(req: httpx.Request) -> httpx.Response: + seen["method"] = req.method + 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) == [] + expected = ( + f"/servicesNS/nobody/my_app/{spec.path.lstrip('/')}" if spec.namespaced else spec.path + ) + assert seen == {"method": "GET", "path": expected} From f52f4b27ec0e54f237c873cab71d6ce40f46b504 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:40:25 -0400 Subject: [PATCH 42/46] chore: adopt the MIT license --- CHANGELOG.md | 2 + LICENSE | 222 +++++-------------------------------------------- README.md | 2 +- pyproject.toml | 2 +- 4 files changed, 25 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c061df..bd0298d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,8 @@ This is the 0.2.0 development line (version bumped from 0.0.1). ### Changed +- Project licensing changed from Apache-2.0 to the MIT License. + - `index` and `saved-search` CRUD now ride the same declarative engine as every other resource group (specs in the registry), instead of hand-written near-duplicates of it; only `saved-search run` (dispatch) stays hand-written. diff --git a/LICENSE b/LICENSE index 261eeb9..d25a50a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2026 VisiCore Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 26fd5e9..24c48b3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A small, scriptable CLI to read, search, health-check, and safely administer **Splunk Enterprise** over its documented REST API — built for AI CLI agents and humans alike. [![CI](https://github.com/VisiCore/vct-splunk-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/VisiCore/vct-splunk-cli/actions/workflows/ci.yml) -[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Python](https://img.shields.io/badge/python-3.10%E2%80%933.14-blue.svg)](https://www.python.org/) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/) diff --git a/pyproject.toml b/pyproject.toml index 17cc94a..92ff0d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "vct-splunk-cli" description = "splunk — read, search, health-check, and safely administer Splunk Enterprise over its REST API" readme = "README.md" requires-python = ">=3.10" -license = "Apache-2.0" +license = "MIT" license-files = ["LICENSE"] authors = [{ name = "VisiCore" }] keywords = ["splunk", "cli", "rest-api", "observability", "siem"] From 24608c895a664977fce836491b2a8c43cfcd877a Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:19:53 -0400 Subject: [PATCH 43/46] refactor: use deployment command names --- CHANGELOG.md | 8 ++++---- src/vct_splunk/cli.py | 5 +++-- src/vct_splunk/commands/deploy.py | 20 ++++++++++---------- tests/cli_catalog.py | 12 ++++++------ tests/unit/test_app_deploy_contracts.py | 8 ++++---- tests/unit/test_commands.py | 20 +++++++++++++++----- 6 files changed, 42 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0298d..d5c1fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,10 +39,10 @@ This is the 0.2.0 development line (version bumped from 0.0.1). - `app install` adds an app from a splunkd-readable `--server-file` or `--url`, with `--update` to overwrite. It is a gated write, so it previews with `--dry-run` (#5). -- `deploy` reads the deployment server: `deploy client list` and - `deploy serverclass list` / `get`. The gated writes `deploy serverclass create` / - `update` (each needs at least one `--set KEY=VALUE`) and `deploy reload` change - and reload server-class config (#5). +- `deploy-client list` reads deployment clients; `deploy-server serverclass list` / + `get` reads deployment-server configuration. The gated writes `deploy-server + serverclass create` / `update` (each needs at least one `--set KEY=VALUE`) and + `deploy-server reload` change and reload server-class config (#5). - `cluster status` and `shcluster status` read indexer-cluster and search-head cluster health, and `license list` / `get` / `usage` report licensing (#10). - `server restart` and `server settings get` / `set` manage the instance. diff --git a/src/vct_splunk/cli.py b/src/vct_splunk/cli.py index 869bc75..21234c3 100644 --- a/src/vct_splunk/cli.py +++ b/src/vct_splunk/cli.py @@ -10,7 +10,7 @@ from .commands.auth import auth from .commands.cluster import cluster from .commands.datamodel import datamodel_accelerate -from .commands.deploy import deploy +from .commands.deploy import deploy_client, deploy_server from .commands.factory import build_group from .commands.health import health from .commands.hec import hec @@ -43,7 +43,8 @@ def cli() -> None: cluster, shcluster, license, - deploy, + deploy_server, + deploy_client, hec, lookup, ): diff --git a/src/vct_splunk/commands/deploy.py b/src/vct_splunk/commands/deploy.py index 91b1710..24915d4 100644 --- a/src/vct_splunk/commands/deploy.py +++ b/src/vct_splunk/commands/deploy.py @@ -1,4 +1,4 @@ -"""`splunk deploy` commands for the deployment server. Shell layer (imports Click). +"""Deployment-server and deployment-client commands. Shell layer (imports Click). These are system-level endpoints (not namespaced), so there is no --app/--owner logic. Writes (serverclass create/update, reload) route through the shared @@ -18,17 +18,17 @@ from .write import do_write -@click.group(name="deploy") -def deploy() -> None: - """Splunk deployment server (clients, server classes, config reload).""" +@click.group(name="deploy-server") +def deploy_server() -> None: + """Manage Splunk deployment-server configuration.""" -@deploy.group("client") -def client_grp() -> None: - """Deployment clients.""" +@click.group(name="deploy-client") +def deploy_client() -> None: + """Inspect Splunk deployment clients.""" -@client_grp.command("list") +@deploy_client.command("list") @command def client_list(ctx) -> None: """List the deployment clients phoning home.""" @@ -37,7 +37,7 @@ def client_list(ctx) -> None: out.emit(data, ctx.output_mode, ctx.meta()) -@deploy.group("serverclass") +@deploy_server.group("serverclass") def serverclass_grp() -> None: """Deployment server classes.""" @@ -100,7 +100,7 @@ def serverclass_update(ctx, name, _set) -> None: out.emit(result, ctx.output_mode, ctx.meta()) -@deploy.command("reload") +@deploy_server.command("reload") @command def reload(ctx) -> None: """Reload the deployment server's server-class config. Gated write.""" diff --git a/tests/cli_catalog.py b/tests/cli_catalog.py index 611ba8f..32b5738 100644 --- a/tests/cli_catalog.py +++ b/tests/cli_catalog.py @@ -39,17 +39,17 @@ class Case: Case(("auth", "status"), "read", ((),)), Case(("cluster", "status"), "read", ((),)), Case(("datamodel", "accelerate"), "write", (("model", "--app", "my_app", "--dry-run"),)), - Case(("deploy", "client", "list"), "read", ((),)), - Case(("deploy", "reload"), "write", (("--dry-run",),)), - Case(("deploy", "serverclass", "list"), "read", ((),)), - Case(("deploy", "serverclass", "get"), "read", (("class",),)), + Case(("deploy-client", "list"), "read", ((),)), + Case(("deploy-server", "reload"), "write", (("--dry-run",),)), + Case(("deploy-server", "serverclass", "list"), "read", ((),)), + Case(("deploy-server", "serverclass", "get"), "read", (("class",),)), Case( - ("deploy", "serverclass", "create"), + ("deploy-server", "serverclass", "create"), "write", (("class", "--set", "whitelist.0=*", "--dry-run"),), ), Case( - ("deploy", "serverclass", "update"), + ("deploy-server", "serverclass", "update"), "write", (("class", "--set", "whitelist.0=*", "--dry-run"),), ), diff --git a/tests/unit/test_app_deploy_contracts.py b/tests/unit/test_app_deploy_contracts.py index 0972d48..7535901 100644 --- a/tests/unit/test_app_deploy_contracts.py +++ b/tests/unit/test_app_deploy_contracts.py @@ -116,7 +116,7 @@ def handler(req: httpx.Request) -> httpx.Response: patch_client(handler) result = CliRunner().invoke( - cli, ["deploy", "serverclass", "get", "east west", "--output", "json"] + cli, ["deploy-server", "serverclass", "get", "east west", "--output", "json"] ) assert result.exit_code == 0 @@ -142,7 +142,7 @@ def handler(req: httpx.Request) -> httpx.Response: result = CliRunner().invoke( cli, [ - "deploy", + "deploy-server", "serverclass", verb, "east west", @@ -171,7 +171,7 @@ def handler(req: httpx.Request) -> httpx.Response: raise AssertionError("malformed settings must not send a request") patch_client(handler) - args = ["deploy", "serverclass", verb, "example", "--set", setting] + args = ["deploy-server", "serverclass", verb, "example", "--set", setting] if setting == "x=1": args.extend(["--set", "x=2"]) result = CliRunner().invoke(cli, [*args, "--yes", "--output", "json"]) @@ -186,7 +186,7 @@ def handler(req: httpx.Request) -> httpx.Response: raise AssertionError("unsafe names must not send a request") patch_client(handler) - args = ["deploy", "serverclass", verb, name] + args = ["deploy-server", "serverclass", verb, name] if verb != "get": args.extend(["--set", "x=1", "--yes"]) result = CliRunner().invoke(cli, [*args, "--output", "json"]) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 6aa160d..8ebd17d 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -292,7 +292,7 @@ def test_deploy_client_list_renders(cli_env, patch_client): }, ), ) - result = CliRunner().invoke(cli, ["deploy", "client", "list", "--output", "json"]) + result = CliRunner().invoke(cli, ["deploy-client", "list", "--output", "json"]) assert result.exit_code == 0 assert '"name": "client1"' in result.output @@ -307,14 +307,14 @@ def test_deploy_serverclass_list_renders(cli_env, patch_client): }, ), ) - result = CliRunner().invoke(cli, ["deploy", "serverclass", "list", "--output", "json"]) + result = CliRunner().invoke(cli, ["deploy-server", "serverclass", "list", "--output", "json"]) assert result.exit_code == 0 assert '"name": "sc1"' in result.output def test_deploy_reload_refuses_without_yes_noninteractive(cli_env): # Must refuse before any network call, so no client patch is needed. - result = CliRunner().invoke(cli, ["deploy", "reload", "--output", "json"]) + result = CliRunner().invoke(cli, ["deploy-server", "reload", "--output", "json"]) assert result.exit_code == 2 assert "usage_error" in result.output @@ -324,7 +324,7 @@ def handler(req: httpx.Request) -> httpx.Response: raise AssertionError("dry-run must not send a request") patch_client(handler) - result = CliRunner().invoke(cli, ["deploy", "reload", "--dry-run", "--output", "json"]) + result = CliRunner().invoke(cli, ["deploy-server", "reload", "--dry-run", "--output", "json"]) assert result.exit_code == 0 assert '"dry_run": true' in result.output @@ -336,7 +336,17 @@ def handler(req: httpx.Request) -> httpx.Response: patch_client(handler) result = CliRunner().invoke( cli, - ["deploy", "serverclass", "create", "foo", "--set", "x=1", "--dry-run", "--output", "json"], + [ + "deploy-server", + "serverclass", + "create", + "foo", + "--set", + "x=1", + "--dry-run", + "--output", + "json", + ], ) assert result.exit_code == 0 assert '"dry_run": true' in result.output From 9fb6a925c466c9c266561a3c1401139c8fceb863 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:22:35 -0400 Subject: [PATCH 44/46] ci: run the PR test on latest Python --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6463da3..30e6f54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,11 +10,8 @@ permissions: contents: read jobs: - # Lint / format / type / test via the org-wide reusable gate. Every `run:` - # step and the zizmor policy live ONCE in dryvist/.github — this repo carries - # neither. `python_ci` runs _python-ci.yml: `uv run --extra dev pre-commit run - # --all-files` (ruff + ruff-format + pyright + hygiene + markdownlint) + - # central zizmor + pytest across the matrix. Gated by the `python` filter. + # Run the shared non-Python gate first. The Python job below depends on this + # result, so tests do not start when an earlier CI check has failed. gate: permissions: contents: read @@ -22,9 +19,7 @@ jobs: actions: write # required: _ci-gate.yml's queue watchdog cancels stuck jobs uses: dryvist/.github/.github/workflows/_ci-gate.yml@main with: - python_ci: true - # This repo narrows to min + latest; the org default is all non-EOL. - python_ci_versions: '["3.10", "3.14"]' + python_ci: false filters: | python: - '**/*.py' @@ -34,6 +29,27 @@ jobs: - '.markdownlint-cli2.yaml' - '.github/workflows/**' + # Run only the latest supported Python on PRs. A push to main (which occurs + # after a merge) runs the supported min/latest matrix. This is deliberately + # downstream of the shared non-Python gate above. + python: + needs: gate + if: >- + ${{ + needs.gate.result == 'success' && + (github.event_name == 'pull_request' || github.event_name == 'push') + }} + uses: dryvist/.github/.github/workflows/_python-ci.yml@main + with: + python_versions: >- + ${{ + github.event_name == 'pull_request' && + '["3.14"]' || + '["3.10", "3.14"]' + }} + gate_python_version: "3.14" + runner_label: ubuntu-latest + # Real create -> verify -> cleanup against a Dockerized Splunk Enterprise 10.x. # COST CONTROL: this is expensive (boots a Splunk container, several minutes), so # it runs ONLY on manual dispatch -- never on push or pull_request -- and is From 22160be18fc63daddba063eb7f1ed0bf98be04f1 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:33:13 -0400 Subject: [PATCH 45/46] ci: run only applicable pull-request checks --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30e6f54..41ded82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,34 +10,38 @@ permissions: contents: read jobs: - # Run the shared non-Python gate first. The Python job below depends on this - # result, so tests do not start when an earlier CI check has failed. - gate: + # Review dependency changes on every pull request. This repository is public, + # so GitHub can provide the dependency graph used by this official action. + dependency-review: permissions: contents: read - pull-requests: read - actions: write # required: _ci-gate.yml's queue watchdog cancels stuck jobs - uses: dryvist/.github/.github/workflows/_ci-gate.yml@main + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5 + with: + fail-on-severity: moderate + + # Keep workflow-policy enforcement centralized without instantiating the + # unrelated Nix, Ansible, Markdown, or file-size jobs from _ci-gate. + workflow-security: + if: github.event_name == 'pull_request' + uses: dryvist/.github/.github/workflows/_zizmor.yml@main with: - python_ci: false - filters: | - python: - - '**/*.py' - - '**/*.md' - - 'pyproject.toml' - - '.pre-commit-config.yaml' - - '.markdownlint-cli2.yaml' - - '.github/workflows/**' + runner_label: ubuntu-latest # Run only the latest supported Python on PRs. A push to main (which occurs # after a merge) runs the supported min/latest matrix. This is deliberately - # downstream of the shared non-Python gate above. + # downstream of every applicable pull-request check above. python: - needs: gate + needs: [dependency-review, workflow-security] if: >- ${{ - needs.gate.result == 'success' && - (github.event_name == 'pull_request' || github.event_name == 'push') + !cancelled() && + (github.event_name == 'push' || + (needs.dependency-review.result == 'success' && + needs.workflow-security.result == 'success')) }} uses: dryvist/.github/.github/workflows/_python-ci.yml@main with: From 093620034264781b753bcd3f8e55221d912c8f19 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans-personal@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:43:52 -0400 Subject: [PATCH 46/46] ci: test every supported Python after merges --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41ded82..72f83f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: runner_label: ubuntu-latest # Run only the latest supported Python on PRs. A push to main (which occurs - # after a merge) runs the supported min/latest matrix. This is deliberately + # after a merge) runs every supported Python. This is deliberately # downstream of every applicable pull-request check above. python: needs: [dependency-review, workflow-security] @@ -49,7 +49,7 @@ jobs: ${{ github.event_name == 'pull_request' && '["3.14"]' || - '["3.10", "3.14"]' + '["3.10", "3.11", "3.12", "3.13", "3.14"]' }} gate_python_version: "3.14" runner_label: ubuntu-latest