From ef5a500024607343c48cf04c7be70ac6445bea84 Mon Sep 17 00:00:00 2001 From: "D. Dessy" Date: Wed, 28 Oct 2026 14:27:53 +0100 Subject: [PATCH] Add --image and --cloud, drop the Kubernetes claim, verify Docker docs (2.2.0) --image reads the Splunk version from a container image tag so it no longer has to be passed twice; --cloud restricts the pick to Splunk Cloud compatible releases and the JSON output reports cloud_compatible. Both scripts, with contract tests run against each. The description claimed Kubernetes support, but the Splunk Operator installs apps from object storage, so the claim is removed and routed away. The Docker reference is re-verified against docker-splunk, Docker Hub and the operator docs: SPLUNK_GENERAL_TERMS is only required from 10.x images, and only linux/amd64 is published. Co-Authored-By: Claude Code --- CHANGELOG.md | 13 +++++ README.md | 4 +- SKILL.md | 24 +++++++-- references/docker-compose.md | 25 ++++++++-- references/sources.md | 3 ++ scripts/get_splunkbase_urls.sh | 49 ++++++++++++++---- scripts/splunkbase_urls.py | 69 ++++++++++++++++++++++---- tests/test_cli_contract.py | 91 ++++++++++++++++++++++++++++++++-- tests/test_live.py | 8 ++- tests/test_python.py | 34 +++++++++++++ tests/test_skill_package.py | 7 ++- 11 files changed, 292 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a8c21..b3205fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ Versions follow [Semantic Versioning](https://semver.org). The newest entry must match `metadata.version` in `SKILL.md` and `__version__`/`VERSION` in both scripts (checked by `tests/test_skill_package.py`). +## [2.2.0] - 2026-10-28 + +### Added + +- `--image REF` takes the Splunk version from a container image tag (`splunk/splunk:9.4.2`, `10.6.1-rhel9`, registry ports and digests handled). `latest`, untagged images and unexpanded variables produce a warning and skip the compatibility check; combining it with `--splunk-version` is a usage error. +- `--cloud` restricts the choice to releases marked Splunk Cloud compatible, and the JSON output gains a `cloud_compatible` field (`true`, `false` or `null`). Both scripts, with contract tests for each. +- Live API check that `cloud_compatible` is still a boolean on releases. + +### Fixed + +- The skill description claimed Kubernetes support. It only produces `SPLUNK_APPS_URL` values for the `splunk/splunk` image under Docker or Docker Compose; the Splunk Operator installs apps from object storage. The description, routing rules and Docker reference now say so. +- Docker reference verified against docker-splunk, Docker Hub and the Splunk Operator docs: `SPLUNK_GENERAL_TERMS` is required only from 10.x images, `linux/amd64` is the only published architecture, and an unverified claim about `@splunk.com` logins is removed. + ## [2.1.1] - 2026-10-28 ### Changed diff --git a/README.md b/README.md index 2e7b354..5d4606a 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,13 @@ The scripts work without Claude. Python 3.9+ (standard library only): python3 scripts/splunkbase_urls.py 4353 7931 # comma-separated URLs python3 scripts/splunkbase_urls.py --format env 4353 7931 # SPLUNK_APPS_URL=... python3 scripts/splunkbase_urls.py --splunk-version 9.4 4353 # newest release supporting Splunk 9.4 +python3 scripts/splunkbase_urls.py --image splunk/splunk:9.4.2 4353 # same, version read from the image tag +python3 scripts/splunkbase_urls.py --cloud 4353 # only Splunk Cloud compatible releases python3 scripts/splunkbase_urls.py --format json 4353 # app, version, compatibility, warnings python3 scripts/splunkbase_urls.py --search "config explorer" # find IDs by name ``` -`scripts/get_splunkbase_urls.sh` (needs `curl` and `jq`, works with macOS bash 3.2) provides the same options except `--search`. +`scripts/get_splunkbase_urls.sh` (needs `curl` and `jq`, works with macOS bash 3.2) provides the same options except `--search` and the `table` format. Exit status: `0` all resolved, `1` at least one app failed (nothing on stdout unless `--partial`), `2` bad arguments. Warnings go to stderr. diff --git a/SKILL.md b/SKILL.md index 383e8ca..304d875 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,9 +1,9 @@ --- name: splunkbase-skill -description: Resolve Splunkbase apps to their current download URLs, optionally for a specific Splunk version, and find app IDs by name. Use when the user wants the latest release URL of a Splunkbase app or add-on, needs SPLUNK_APPS_URL for splunk/splunk Docker Compose or Kubernetes deployments, asks which Splunkbase release supports their Splunk version, or gives a splunkbase.splunk.com link or app ID. Route app lifecycle, upgrade, or removal advice and general product questions to their owning workflows. +description: Resolve Splunkbase apps to their current download URLs, optionally for a specific Splunk version, and find app IDs by name. Use when the user wants the latest release URL of a Splunkbase app or add-on, needs SPLUNK_APPS_URL for splunk/splunk Docker or Docker Compose deployments, asks which Splunkbase release supports their Splunk version, or gives a splunkbase.splunk.com link or app ID. Route app lifecycle, upgrade, or removal advice and general product questions to their owning workflows. license: Apache-2.0 metadata: - version: 2.1.1 + version: 2.2.0 splunk: domain: splunkbase-release-resolution products: @@ -22,6 +22,7 @@ metadata: - splunkbase.splunk.com app link not-for: - installing, upgrading, disabling, or removing an app in a deployment + - Splunk Operator for Kubernetes app installs (its App Framework reads packages from object storage) - app lifecycle, compatibility readiness, or removal-safety assessment - general Splunk product questions - downloading app packages or handling Splunkbase credentials @@ -63,7 +64,9 @@ Route elsewhere, and say so, when the request is really about: deployment, AppInspect results, or lifecycle planning: use an app and add-on lifecycle skill or the product documentation; - general Splunk product questions; -- performing the installation or download: this skill only resolves URLs. +- performing the installation or download: this skill only resolves URLs; +- Kubernetes with the Splunk Operator: its App Framework installs from S3, Azure + Blob or GCP buckets, not from Splunkbase URLs, so `SPLUNK_APPS_URL` does not apply. ## Workflow @@ -81,10 +84,17 @@ Route elsewhere, and say so, when the request is really about: python3 scripts/splunkbase_urls.py 4353 7931 # comma-separated URLs python3 scripts/splunkbase_urls.py --format env 4353 7931 # SPLUNK_APPS_URL=... python3 scripts/splunkbase_urls.py --format json --splunk-version 9.4 4353 + python3 scripts/splunkbase_urls.py --image splunk/splunk:9.4.2 4353 7931 # version from the image tag + python3 scripts/splunkbase_urls.py --cloud 4353 # only Splunk Cloud compatible releases ``` - Pass `--splunk-version` whenever the Splunk version is known, so the script - picks the newest release that supports it rather than the newest overall. + Pass the Splunk version whenever it is known, so the script picks the newest + release that supports it rather than the newest overall. When a compose file + or `docker run` command names the image, pass it with `--image`; the script + reads the tag (`9.4.2` becomes 9.4). A tag such as `latest`, a digest or an + unexpanded `${VARIABLE}` pins nothing: the script warns and skips the check, + so resolve the variable from the file or `.env`, or ask, and use + `--splunk-version`. `--image` and `--splunk-version` cannot be combined. Without Python, `scripts/get_splunkbase_urls.sh` gives the same results (no `--search`). @@ -136,6 +146,10 @@ Answer shape for each app: ID and title, resolved version, `compatibility` later or have the user run the command. - **URL later returns 401/403 in the container:** credentials or licence acceptance on the Splunkbase account, see the Docker reference. +- **`warning: image ... does not pin a Splunk version`:** the tag is `latest`, + missing or a variable; compatibility was not checked. Find the real version. +- **`no release is marked Splunk Cloud compatible`:** `--cloud` found no release + flagged `cloud_compatible`; the flag is the app author's, so verify on Splunkbase. - **Reproducibility:** `latest` moves. Suggest pinning the resolved versions and re-running on upgrades. diff --git a/references/docker-compose.md b/references/docker-compose.md index adce992..e9dd229 100644 --- a/references/docker-compose.md +++ b/references/docker-compose.md @@ -1,7 +1,14 @@ # Installing Splunkbase apps in the `splunk/splunk` image The container downloads each URL in `SPLUNK_APPS_URL` at start-up, authenticating -with your Splunkbase account. +with your Splunkbase account. This applies to the `splunk/splunk` image run with +Docker or Docker Compose. It does not apply to the Splunk Operator for Kubernetes: +its App Framework installs app packages from S3, Azure Blob or GCP storage, so +download the packages (needs a Splunkbase login) and host them there instead. + +Verified 2026-10-28 against the docker-splunk repository (`README.md`, +`docs/advanced/APP_INSTALL.md`, `uf/common-files/entrypoint.sh`), Docker Hub tags +and the Splunk Operator `docs/operate/AppFramework.md`. ## Compose example @@ -11,7 +18,7 @@ services: image: splunk/splunk:${SPLUNK_VERSION:-9.4} platform: linux/amd64 # the image has no arm64 build; needed on Apple silicon environment: - SPLUNK_GENERAL_TERMS: --accept-sgt-current-at-splunk-com + SPLUNK_GENERAL_TERMS: --accept-sgt-current-at-splunk-com # required by 10.x images, ignored by 9.x SPLUNK_START_ARGS: --accept-license SPLUNK_PASSWORD: ${SPLUNK_PASSWORD} SPLUNKBASE_USERNAME: ${SPLUNKBASE_USERNAME} @@ -24,7 +31,7 @@ services: Generate the value and keep credentials in an untracked `.env`: ```bash -python3 scripts/splunkbase_urls.py --format env --splunk-version 9.4 4353 7931 >> .env +python3 scripts/splunkbase_urls.py --format env --image splunk/splunk:9.4 4353 7931 >> .env cat >> .env <<'ENV' SPLUNK_PASSWORD=change-me-please SPLUNKBASE_USERNAME=you@example.com @@ -36,8 +43,15 @@ echo ".env" >> .gitignore Notes: - `SPLUNK_APPS_URL` is comma-separated with no spaces. -- Use your own Splunkbase login name; it is not necessarily an `@splunk.com` address. -- Match `--splunk-version` to the image tag so apps are compatible with the container. +- Use your own Splunkbase account credentials. +- Match the Splunk version to the image tag so apps are compatible with the + container: `--image` reads it from the tag, or pass `--splunk-version`. If the + compose file uses `${SPLUNK_VERSION:-9.4}`, expand the variable first. +- Image tags look like `9.4`, `9.4.2`, `10.6.1.1` and `10.6.1-rhel9`, plus `latest`. + Splunkbase tracks compatibility as major.minor, so `10.6.1.1` is treated as 10.6. +- `SPLUNK_START_ARGS: --accept-license` is required on every container. From 10.x + images `SPLUNK_GENERAL_TERMS: --accept-sgt-current-at-splunk-com` is required + too. Accepting is the user's decision; do not add it silently. - Re-run the script when upgrading; the URLs pin exact versions. ## Troubleshooting @@ -48,3 +62,4 @@ Notes: | "No such file" or garbled filename | Platform-specific release name (see the warning from the script); pick the intended build | | App installed but disabled or erroring | App does not support the Splunk version in the image; re-run with `--splunk-version` | | Slow start on Apple silicon | `linux/amd64` runs under emulation | +| Container exits at once with "License not accepted" | `SPLUNK_START_ARGS` and, on 10.x, `SPLUNK_GENERAL_TERMS` are missing | diff --git a/references/sources.md b/references/sources.md index 6553069..d235712 100644 --- a/references/sources.md +++ b/references/sources.md @@ -6,6 +6,9 @@ itself is unofficial; see [api.md](api.md) for what was verified directly. | Topic | Source | |-------|--------| | `splunk/splunk` image, `SPLUNK_APPS_URL`, `SPLUNKBASE_USERNAME`/`SPLUNKBASE_PASSWORD` | https://splunk.github.io/docker-splunk/ and https://github.com/splunk/docker-splunk | +| `SPLUNK_GENERAL_TERMS` (10.x), license acceptance, comma-separated `SPLUNK_APPS_URL`, credentials | https://github.com/splunk/docker-splunk (`README.md`, `docs/advanced/APP_INSTALL.md`, `uf/common-files/entrypoint.sh`) | +| Image tags and architectures (`linux/amd64` only) | https://hub.docker.com/r/splunk/splunk/tags | +| Kubernetes app installs (object storage, not Splunkbase URLs) | https://github.com/splunk/splunk-operator (`docs/operate/AppFramework.md`) | | App management and packaging | https://dev.splunk.com/enterprise/docs/developapps/manageapps/ | | Splunk product documentation | https://help.splunk.com | | Official Splunk agent skills (lifecycle, product questions) | https://github.com/splunk/splunk-agent-skills | diff --git a/scripts/get_splunkbase_urls.sh b/scripts/get_splunkbase_urls.sh index e410588..1524fcb 100755 --- a/scripts/get_splunkbase_urls.sh +++ b/scripts/get_splunkbase_urls.sh @@ -13,7 +13,7 @@ set -euo pipefail -VERSION="2.1.1" +VERSION="2.2.0" BASE_URL="${SPLUNKBASE_BASE_URL:-https://splunkbase.splunk.com}" BASE_URL="${BASE_URL%/}" TIMEOUT=10 @@ -31,6 +31,10 @@ APP is a Splunkbase app ID or app page URL (commas allowed: 4353,7931). Options: --splunk-version VER only pick releases supporting this Splunk version (e.g. 9.4 or 10.2.1) + --image REF take the Splunk version from a container image tag + (e.g. splunk/splunk:9.4.2); latest or untagged + images are not checked + --cloud only pick releases marked Splunk Cloud compatible --format FMT urls (default): comma-separated env: SPLUNK_APPS_URL=... json @@ -100,16 +104,19 @@ def tidy: gsub("[\\p{Cc}\\p{Zl}\\p{Zp}]+"; " ") | gsub("\\s+"; " ") | gsub("^ | def supported: if length > 0 then "\(.[-1]) to \(.[0])" else "unknown" end; ($rel | map(select(type == "object" and (.name | type) == "string" and ((.name | ascii_downcase | clean) as $n - | ($n == "" or $n == "null" or $n == "none") | not)))) as $usable -| if ($usable | length) == 0 then + | ($n == "" or $n == "null" or $n == "none") | not)))) as $downloadable +| (if $cloud == "1" then $downloadable | map(select(.cloud_compatible == true)) else $downloadable end) as $usable +| if ($downloadable | length) == 0 then {error: "no downloadable release (the release has no version name, so no download URL can be built)"} + elif ($usable | length) == 0 then + {error: "no release is marked Splunk Cloud compatible"} else (if $sv == "" then {r: $usable[0], c: "not-checked", w: []} else ([ $usable[] | select(((.product_versions // []) | any(. == $sv)) or ((.product_versions // []) | length) == 0) ] | .[0]) as $pick | if $pick == null then - {error: "no release is compatible with Splunk \($sv) (latest is \($usable[0].name), which supports \(($usable[0].product_versions // []) | supported))"} + {error: "no release is compatible with Splunk \($sv)\(if $cloud == "1" then " among Splunk Cloud compatible releases" else "" end) (latest is \($usable[0].name), which supports \(($usable[0].product_versions // []) | supported))"} elif ($pick.product_versions // []) | any(. == $sv) then {r: $pick, c: "compatible", w: []} else @@ -127,6 +134,7 @@ def supported: if length > 0 then "\(.[-1]) to \(.[0])" else "unknown" end; published: (.r.published_datetime // null), splunk_versions: (.r.product_versions // []), compatibility: .c, + cloud_compatible: (if (.r.cloud_compatible | type) == "boolean" then .r.cloud_compatible else null end), warnings: (.w + (if $app.is_archived then ["app is archived on Splunkbase"] else [] end) + (if ($v | test("^[0-9][A-Za-z0-9_.+-]*$")) then [] else @@ -134,10 +142,10 @@ def supported: if length > 0 then "\(.[-1]) to \(.[0])" else "unknown" end; end end' -# resolve_app ID SPLUNK_VERSION -> one compact JSON object on stdout. +# resolve_app ID SPLUNK_VERSION CLOUD -> one compact JSON object on stdout. # On failure prints an "error: ..." line to stderr and returns 1. resolve_app() { - local id="$1" sv="$2" app rel rc title label out + local id="$1" sv="$2" cloud="$3" app rel rc title label out rc=0 app="$(fetch_json "${BASE_URL}/api/v1/app/${id}")" || rc=$? if [ "$rc" = 2 ]; then @@ -164,7 +172,7 @@ resolve_app() { fi out="$(jq -c --argjson app "$app" --argjson rel "$rel" --arg id "$id" \ - --arg sv "$sv" --arg base "$BASE_URL" "$JQ_RESOLVE" <</dev/null; then echo "error: ${label}: $(jq -r .error <<<"$out")" >&2 return 1 @@ -174,6 +182,8 @@ resolve_app() { FORMAT="urls" SPLUNK_VERSION="" +IMAGE="" +CLOUD=0 PARTIAL=0 QUIET=0 RAW_APPS=() @@ -185,6 +195,10 @@ while [ $# -gt 0 ]; do --splunk-version) [ $# -ge 2 ] || die_usage "--splunk-version needs a value" SPLUNK_VERSION="$2"; shift 2 ;; + --image) + [ $# -ge 2 ] || die_usage "--image needs a value" + IMAGE="$2"; shift 2 ;; + --cloud) CLOUD=1; shift ;; --format) [ $# -ge 2 ] || die_usage "--format needs a value" FORMAT="$2"; shift 2 ;; @@ -202,8 +216,23 @@ case "$FORMAT" in esac [ ${#RAW_APPS[@]} -gt 0 ] || die_usage "give at least one app ID" -if [ -n "$SPLUNK_VERSION" ]; then - sv_re='^([0-9]+)\.([0-9]+)(\.[A-Za-z0-9]+)*$' +sv_re='^([0-9]+)\.([0-9]+)(\.[A-Za-z0-9]+)*$' +if [ -n "$IMAGE" ]; then + [ -z "$SPLUNK_VERSION" ] || die_usage "use either --image or --splunk-version, not both" + image_name="${IMAGE%%@*}" + image_name="${image_name##*/}" + image_tag="" + if [[ "$image_name" == *:* ]]; then + image_tag="${image_name#*:}" + image_tag="${image_tag%%-*}" + fi + if [[ "$image_tag" =~ $sv_re ]]; then + SPLUNK_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + [ "$QUIET" = 1 ] || echo "note: using Splunk ${SPLUNK_VERSION} from image ${IMAGE}" >&2 + else + warn "image ${IMAGE} does not pin a Splunk version; compatibility not checked" + fi +elif [ -n "$SPLUNK_VERSION" ]; then if [[ "$SPLUNK_VERSION" =~ $sv_re ]]; then SPLUNK_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" else @@ -238,7 +267,7 @@ RESULTS="$(mktemp)" trap 'rm -f "$RESULTS"' EXIT failed=0 for id in "${APP_IDS[@]}"; do - if line="$(resolve_app "$id" "$SPLUNK_VERSION")"; then + if line="$(resolve_app "$id" "$SPLUNK_VERSION" "$CLOUD")"; then echo "$line" >>"$RESULTS" else failed=1 diff --git a/scripts/splunkbase_urls.py b/scripts/splunkbase_urls.py index 9e33312..58c4335 100755 --- a/scripts/splunkbase_urls.py +++ b/scripts/splunkbase_urls.py @@ -29,7 +29,7 @@ from dataclasses import asdict, dataclass, field from typing import Any, Optional -__version__ = "2.1.1" +__version__ = "2.2.0" DEFAULT_BASE_URL = "https://splunkbase.splunk.com" USER_AGENT = f"splunkbase-skill/{__version__}" @@ -104,6 +104,7 @@ class Resolved: published: Optional[str] splunk_versions: list = field(default_factory=list) compatibility: str = "not-checked" # not-checked | compatible | unknown + cloud_compatible: Optional[bool] = None warnings: list = field(default_factory=list) @@ -134,6 +135,17 @@ def normalize_splunk_version(value: str) -> str: return f"{match.group(1)}.{match.group(2)}" +def splunk_version_from_image(ref: str) -> Optional[str]: + """'splunk/splunk:10.2.1-rhel9' -> '10.2'; None for latest, digests or variables.""" + name = ref.strip().split("@", 1)[0].rsplit("/", 1)[-1] + if ":" not in name: + return None + try: + return normalize_splunk_version(name.split(":", 1)[1].split("-", 1)[0]) + except ValueError: + return None + + def is_downloadable(release: dict) -> bool: """Some apps list a release whose name is the literal string 'null'.""" name = release.get("name") @@ -146,14 +158,15 @@ def _supported_range(release: dict) -> str: def select_release( - releases: list, splunk_version: Optional[str] = None + releases: list, splunk_version: Optional[str] = None, cloud_only: bool = False ) -> tuple[dict, str, list]: """Pick a release. Returns (release, compatibility, warnings). Splunkbase lists releases newest first. Without a Splunk version the first downloadable release wins. With one, the newest release that is not known to be incompatible wins: explicitly compatible, or compatibility unknown - (empty ``product_versions``, flagged with a warning). + (empty ``product_versions``, flagged with a warning). ``cloud_only`` first + drops releases not marked Splunk Cloud compatible. """ usable = [r for r in releases if isinstance(r, dict) and is_downloadable(r)] if not usable: @@ -161,6 +174,10 @@ def select_release( "no downloadable release (the release has no version name, " "so no download URL can be built)" ) + if cloud_only: + usable = [r for r in usable if r.get("cloud_compatible") is True] + if not usable: + raise SplunkbaseError("no release is marked Splunk Cloud compatible") if splunk_version is None: return usable[0], "not-checked", [] for release in usable: @@ -177,7 +194,8 @@ def select_release( ], ) raise SplunkbaseError( - f"no release is compatible with Splunk {splunk_version} " + f"no release is compatible with Splunk {splunk_version}" + f"{' among Splunk Cloud compatible releases' if cloud_only else ''} " f"(latest is {usable[0]['name']}, which supports " f"{_supported_range(usable[0])})" ) @@ -193,7 +211,10 @@ def clean_text(value, limit: int = 120) -> str: def resolve_app( - client: Client, app_id: int, splunk_version: Optional[str] = None + client: Client, + app_id: int, + splunk_version: Optional[str] = None, + cloud_only: bool = False, ) -> Resolved: try: app = client.get_json(f"/api/v1/app/{app_id}") @@ -216,7 +237,7 @@ def resolve_app( raise SplunkbaseError(f"{label}: no releases found") try: - release, compatibility, warnings = select_release(releases, splunk_version) + release, compatibility, warnings = select_release(releases, splunk_version, cloud_only) except SplunkbaseError as exc: raise SplunkbaseError(f"{label}: {exc}") from None @@ -241,18 +262,24 @@ def resolve_app( published=release.get("published_datetime"), splunk_versions=list(release.get("product_versions") or []), compatibility=compatibility, + cloud_compatible=release["cloud_compatible"] + if isinstance(release.get("cloud_compatible"), bool) + else None, warnings=warnings, ) def resolve_many( - client: Client, app_ids: list, splunk_version: Optional[str] = None + client: Client, + app_ids: list, + splunk_version: Optional[str] = None, + cloud_only: bool = False, ) -> list: """Resolve concurrently; returns [(app_id, Resolved | None, error | None)].""" def one(app_id: int) -> tuple: try: - return app_id, resolve_app(client, app_id, splunk_version), None + return app_id, resolve_app(client, app_id, splunk_version, cloud_only), None except SplunkbaseError as exc: return app_id, None, str(exc) @@ -334,6 +361,17 @@ def build_parser() -> argparse.ArgumentParser: metavar="VER", help="only pick releases that support this Splunk version (e.g. 9.4, 10.2.1)", ) + parser.add_argument( + "--image", + metavar="REF", + help="take the Splunk version from a container image tag " + "(e.g. splunk/splunk:9.4.2); latest or untagged images are not checked", + ) + parser.add_argument( + "--cloud", + action="store_true", + help="only pick releases marked Splunk Cloud compatible", + ) parser.add_argument( "--format", choices=("urls", "env", "json", "table"), @@ -386,6 +424,19 @@ def main(argv: Optional[list] = None) -> int: if not args.apps: parser.error("give at least one app ID or use --search") splunk_version = None + if args.image and args.splunk_version: + parser.error("use either --image or --splunk-version, not both") + if args.image: + splunk_version = splunk_version_from_image(args.image) + if not args.quiet: + if splunk_version: + print(f"note: using Splunk {splunk_version} from image {args.image}", file=sys.stderr) + else: + print( + f"warning: image {args.image} does not pin a Splunk version; " + "compatibility not checked", + file=sys.stderr, + ) if args.splunk_version: try: splunk_version = normalize_splunk_version(args.splunk_version) @@ -404,7 +455,7 @@ def main(argv: Optional[list] = None) -> int: continue app_ids.append(app_id) - outcomes = resolve_many(client, app_ids, splunk_version) + outcomes = resolve_many(client, app_ids, splunk_version, args.cloud) failed = [(app_id, error) for app_id, resolved, error in outcomes if error] resolved = [r for _, r, _ in outcomes if r] diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index 113383e..3d8b0cb 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -19,12 +19,15 @@ RELEASE = "/api/v1/app/%s/release" -def release(name, versions, published="2026-01-01T00:00:00Z"): - return { +def release(name, versions, published="2026-01-01T00:00:00Z", cloud=None): + data = { "name": name, "published_datetime": published, "product_versions": versions, } + if cloud is not None: + data["cloud_compatible"] = cloud + return data class ContractMixin: @@ -80,13 +83,14 @@ def test_json_format_has_stable_fields(self): self.assertEqual( set(item), {"app_id", "title", "slug", "version", "url", "published", - "splunk_versions", "compatibility", "warnings"}, + "splunk_versions", "compatibility", "cloud_compatible", "warnings"}, ) self.assertEqual(item["app_id"], 7931) self.assertEqual(item["title"], "Splunk MCP Server") self.assertEqual(item["version"], "2.1.0") self.assertEqual(item["compatibility"], "compatible") self.assertIn("10.2", item["splunk_versions"]) + self.assertIs(item["cloud_compatible"], True) def test_accepts_urls_and_comma_lists_and_ignores_duplicates(self): rc, out, err = self.run_cli( @@ -133,6 +137,87 @@ def test_unknown_compatibility_is_accepted_with_a_warning(self): self.assertEqual(json.loads(out)[0]["compatibility"], "unknown") self.assertIn("does not declare Splunk compatibility", err) + # --- Splunk Cloud filter ---------------------------------------------- + + def serve_mixed_cloud_app(self): + self.server.static(APP % 9002, 200, {"title": "Mixed Cloud App", "appid": "mixed"}) + self.server.static( + RELEASE % 9002, + 200, + [release("3.0.0", ["10.0"], cloud=False), release("2.0.0", ["9.4", "10.0"], cloud=True)], + ) + + def test_cloud_skips_releases_not_marked_cloud_compatible(self): + self.serve_mixed_cloud_app() + rc, out, _ = self.run_cli("9002") + self.assertEqual(out, self.url(9002, "3.0.0")) + rc, out, err = self.run_cli("--cloud", "9002") + self.assertEqual((rc, err), (0, "")) + self.assertEqual(out, self.url(9002, "2.0.0")) + + def test_cloud_with_no_cloud_compatible_release_fails(self): + rc, out, err = self.run_cli("--cloud", "4353") + self.assertEqual((rc, out), (1, "")) + self.assertIn("no release is marked Splunk Cloud compatible", err) + + def test_cloud_combines_with_splunk_version(self): + self.serve_mixed_cloud_app() + rc, out, _ = self.run_cli("--cloud", "--splunk-version", "9.4", "9002") + self.assertEqual((rc, out), (0, self.url(9002, "2.0.0"))) + rc, out, err = self.run_cli("--cloud", "--splunk-version", "8.0", "9002") + self.assertEqual((rc, out), (1, "")) + self.assertIn("no release is compatible with Splunk 8.0 among Splunk Cloud compatible releases", err) + + def test_cloud_flag_is_reported_in_json(self): + self.serve_mixed_cloud_app() + rc, out, _ = self.run_cli("--format", "json", "9002", "4353") + self.assertEqual([i["cloud_compatible"] for i in json.loads(out)], [False, False]) + rc, out, _ = self.run_cli("--format", "json", "--cloud", "9002") + self.assertIs(json.loads(out)[0]["cloud_compatible"], True) + + def test_missing_cloud_field_is_null_and_excluded_by_cloud(self): + self.server.static(APP % 9003, 200, {"title": "Old App", "appid": "old"}) + self.server.static(RELEASE % 9003, 200, [release("1.0.0", ["9.4"])]) + rc, out, _ = self.run_cli("--format", "json", "9003") + self.assertIsNone(json.loads(out)[0]["cloud_compatible"]) + rc, out, err = self.run_cli("--cloud", "9003") + self.assertEqual(rc, 1) + + # --- Splunk version from a container image tag ------------------------ + + def test_image_tag_sets_the_splunk_version(self): + rc, out, err = self.run_cli("--image", "splunk/splunk:10.6.1-rhel9", "4353") + self.assertEqual((rc, out), (0, self.url(4353, "1.8.24"))) + self.assertIn("note: using Splunk 10.6 from image splunk/splunk:10.6.1-rhel9", err) + + def test_image_tag_matches_explicit_splunk_version(self): + _, explicit, _ = self.run_cli("--format", "json", "--splunk-version", "10.4", "4353") + _, via_image, _ = self.run_cli("--format", "json", "--image", "splunk/splunk:10.4", "4353") + self.assertEqual(json.loads(explicit), json.loads(via_image)) + + def test_image_with_registry_port_and_digest(self): + rc, out, err = self.run_cli("--image", "localhost:5000/splunk/splunk:9.4.2@sha256:abc123", "7931") + self.assertEqual((rc, out), (0, self.url(7931, "2.1.0"))) + self.assertIn("using Splunk 9.4", err) + + def test_image_without_a_version_tag_is_not_checked(self): + for image in ("splunk/splunk:latest", "splunk/splunk", "localhost:5000/splunk/splunk", + "splunk/splunk:${SPLUNK_VERSION}", "splunk/splunk:33c3bf42cd73"): + with self.subTest(image=image): + rc, out, err = self.run_cli("--format", "json", "--image", image, "4353") + self.assertEqual(rc, 0, err) + self.assertEqual(json.loads(out)[0]["compatibility"], "not-checked") + self.assertIn("does not pin a Splunk version", err) + + def test_quiet_suppresses_the_image_note(self): + rc, out, err = self.run_cli("-q", "--image", "splunk/splunk:9.4", "7931") + self.assertEqual((rc, err), (0, "")) + + def test_image_and_splunk_version_together_are_a_usage_error(self): + rc, out, err = self.run_cli("--image", "splunk/splunk:9.4", "--splunk-version", "9.4", "7931") + self.assertEqual((rc, out), (2, "")) + self.assertIn("either --image or --splunk-version", err) + def test_rejects_a_bad_splunk_version(self): rc, out, err = self.run_cli("--splunk-version", "latest", "4353") self.assertEqual((rc, out), (2, "")) diff --git a/tests/test_live.py b/tests/test_live.py index 74b0bdd..4be949e 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -27,7 +27,7 @@ 4353: "Config Explorer", 7931: "Splunk MCP Server", } -RELEASE_KEYS = {"name", "published_datetime", "product_versions", "filename", "platform"} +RELEASE_KEYS = {"name", "published_datetime", "product_versions", "filename", "platform", "cloud_compatible"} def get(path): @@ -60,6 +60,12 @@ def test_first_release_is_still_the_newest_published(self): newest = max(releases, key=lambda r: r["published_datetime"]) self.assertEqual(releases[0]["name"], newest["name"]) + def test_cloud_flag_is_a_boolean_on_releases(self): + for app_id in ANCHORS: + with self.subTest(app_id=app_id): + for item in get("/api/v1/app/%d/release" % app_id): + self.assertIsInstance(item["cloud_compatible"], bool) + def test_search_returns_ids_and_titles(self): data = get("/api/v1/app/?query=aws&limit=5") self.assertIn(1876, [r["uid"] for r in data["results"]]) diff --git a/tests/test_python.py b/tests/test_python.py index 7bb9f14..cbd2053 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -57,7 +57,41 @@ def test_clean_text(self): self.assertEqual(sb.clean_text(123), "123") +class ImageTagTests(unittest.TestCase): + def test_splunk_version_from_image(self): + cases = { + "splunk/splunk:9.4": "9.4", + "splunk/splunk:9.4.2": "9.4", + "splunk/splunk:10.6.1.1-rhel9": "10.6", + "docker.io/splunk/universalforwarder:10.2-rhel9": "10.2", + "localhost:5000/splunk/splunk:9.4.2@sha256:abc": "9.4", + " splunk/splunk:9.4 ": "9.4", + "splunk/splunk:latest": None, + "splunk/splunk": None, + "localhost:5000/splunk/splunk": None, + "splunk/splunk:${SPLUNK_VERSION:-9.4}": None, + "splunk/splunk:33c3bf42cd73": None, + "splunk/splunk@sha256:abc": None, + "": None, + } + for ref, expected in cases.items(): + with self.subTest(ref=ref): + self.assertEqual(sb.splunk_version_from_image(ref), expected) + + class SelectReleaseTests(unittest.TestCase): + def test_cloud_only_filters_before_version_matching(self): + releases = [ + {"name": "3", "product_versions": ["10.0"], "cloud_compatible": False}, + {"name": "2", "product_versions": ["10.0"], "cloud_compatible": True}, + ] + self.assertEqual(sb.select_release(releases, None, cloud_only=True)[0]["name"], "2") + self.assertEqual(sb.select_release(releases, "10.0")[0]["name"], "3") + + def test_cloud_only_requires_an_explicit_true(self): + with self.assertRaisesRegex(sb.SplunkbaseError, "Cloud compatible"): + sb.select_release([{"name": "1"}, {"name": "2", "cloud_compatible": None}], cloud_only=True) + def test_skips_leading_releases_without_a_usable_name(self): releases = [{"name": "null"}, {"name": ""}, {"name": None}, {"name": "1.0.0"}] chosen, compat, _ = sb.select_release(releases) diff --git a/tests/test_skill_package.py b/tests/test_skill_package.py index f4873a1..e710253 100644 --- a/tests/test_skill_package.py +++ b/tests/test_skill_package.py @@ -148,7 +148,8 @@ def test_evals_are_well_formed(self): def test_skill_md_documents_every_cli_format_and_exit_code(self): text = self.skill_md.read_text() - for needle in ("--search", "--splunk-version", "--format env", "--partial", "SPLUNKBASE_PASSWORD"): + needles = ("--search", "--splunk-version", "--image", "--cloud", "--format env", "--partial") + for needle in needles + ("SPLUNKBASE_PASSWORD",): self.assertIn(needle, text) def test_license_file_matches_declared_license_and_ships(self): @@ -166,6 +167,10 @@ def test_personal_project_disclaimer_is_everywhere_users_look(self): self.assertIn("production", text) self.assertRegex(text, r"not endorsed or supported by cisco") + def test_kubernetes_is_routed_away_not_claimed(self): + self.assertNotIn("Kubernetes", self.meta["description"]) + self.assertIn("Splunk Operator", self.skill_md.read_text()) + def test_no_hard_coded_release_versions_in_skill_instructions(self): # Versions rot; the skill must tell Claude to look them up instead. text = self.skill_md.read_text()