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()