splunkctl 0.10.0__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {splunkctl-0.10.0 → splunkctl-0.11.0}/PKG-INFO +5 -5
- {splunkctl-0.10.0 → splunkctl-0.11.0}/README.md +2 -2
- {splunkctl-0.10.0 → splunkctl-0.11.0}/pyproject.toml +6 -4
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/client.py +34 -10
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/commands_meta.py +2 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/common.py +74 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/config_cmd.py +2 -1
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/dashboards.py +6 -6
- splunkctl-0.11.0/splunkctl/commands/docs_cmd.py +324 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/doctor.py +114 -1
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/es.py +184 -1
- splunkctl-0.11.0/splunkctl/commands/es_threat_intel.py +165 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/hec.py +12 -3
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/lookups.py +12 -6
- splunkctl-0.11.0/splunkctl/commands/mcp_cmd.py +99 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/rules.py +11 -0
- splunkctl-0.11.0/splunkctl/commands/rules_schedule.py +97 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/search.py +68 -16
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/server.py +8 -0
- splunkctl-0.11.0/splunkctl/commands/server_auth.py +210 -0
- splunkctl-0.11.0/splunkctl/commands/server_deployment.py +176 -0
- splunkctl-0.11.0/splunkctl/commands/server_tokens.py +139 -0
- splunkctl-0.11.0/splunkctl/commands/server_workloads.py +174 -0
- splunkctl-0.11.0/splunkctl/commands/soar/__init__.py +68 -0
- splunkctl-0.11.0/splunkctl/commands/soar/_client.py +44 -0
- splunkctl-0.11.0/splunkctl/commands/soar/_ingest_helpers.py +250 -0
- splunkctl-0.11.0/splunkctl/commands/soar/actions.py +416 -0
- splunkctl-0.11.0/splunkctl/commands/soar/admin.py +342 -0
- splunkctl-0.11.0/splunkctl/commands/soar/admin_roles_audit.py +161 -0
- splunkctl-0.11.0/splunkctl/commands/soar/admin_views.py +195 -0
- splunkctl-0.11.0/splunkctl/commands/soar/approvals.py +152 -0
- splunkctl-0.11.0/splunkctl/commands/soar/apps.py +211 -0
- splunkctl-0.11.0/splunkctl/commands/soar/artifacts.py +419 -0
- splunkctl-0.11.0/splunkctl/commands/soar/assets.py +469 -0
- splunkctl-0.11.0/splunkctl/commands/soar/cases.py +395 -0
- splunkctl-0.11.0/splunkctl/commands/soar/containers.py +275 -0
- splunkctl-0.11.0/splunkctl/commands/soar/containers_assign.py +166 -0
- splunkctl-0.11.0/splunkctl/commands/soar/containers_write.py +394 -0
- splunkctl-0.11.0/splunkctl/commands/soar/functions.py +291 -0
- splunkctl-0.11.0/splunkctl/commands/soar/indicators.py +303 -0
- splunkctl-0.11.0/splunkctl/commands/soar/ingest.py +396 -0
- splunkctl-0.11.0/splunkctl/commands/soar/lists.py +478 -0
- splunkctl-0.11.0/splunkctl/commands/soar/notes.py +229 -0
- splunkctl-0.11.0/splunkctl/commands/soar/playbook_runs.py +354 -0
- splunkctl-0.11.0/splunkctl/commands/soar/playbooks.py +478 -0
- splunkctl-0.11.0/splunkctl/commands/soar/playbooks_delete.py +79 -0
- splunkctl-0.11.0/splunkctl/commands/soar/search.py +71 -0
- splunkctl-0.11.0/splunkctl/commands/soar/system.py +192 -0
- splunkctl-0.11.0/splunkctl/commands/soar/vault.py +206 -0
- splunkctl-0.11.0/splunkctl/commands/soar/workbook_templates.py +232 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state.py +43 -11
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_io.py +44 -2
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_io_blobs.py +3 -1
- splunkctl-0.11.0/splunkctl/commands/state_io_soar.py +304 -0
- splunkctl-0.11.0/splunkctl/commands/state_io_soar_assets.py +335 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/users.py +43 -15
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/config.py +2 -2
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/errors.py +18 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/main.py +82 -3
- splunkctl-0.11.0/splunkctl/mcp/output_cap.py +84 -0
- splunkctl-0.11.0/splunkctl/mcp/prompts.py +160 -0
- splunkctl-0.11.0/splunkctl/mcp/runner.py +181 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/server.py +44 -162
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/tools.py +9 -7
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/client.py +45 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/PKG-INFO +5 -5
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/SOURCES.txt +40 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_client.py +2 -1
- splunkctl-0.11.0/tests/test_client_errors.py +249 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_config.py +25 -3
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_version.py +1 -1
- splunkctl-0.10.0/splunkctl/commands/mcp_cmd.py +0 -47
- {splunkctl-0.10.0 → splunkctl-0.11.0}/LICENSE +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/setup.cfg +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/knowledge.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/server_topology.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/guard.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/output.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/__init__.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/cimcef.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/web.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_guard.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_main_errors.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_output.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splunkctl
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: CLI
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Open-source MCP server and CLI for Splunk Enterprise SIEM and Splunk SOAR.
|
|
5
5
|
Author: dannyota
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/dannyota/splunkctl
|
|
8
8
|
Project-URL: Repository, https://github.com/dannyota/splunkctl
|
|
9
9
|
Project-URL: Issues, https://github.com/dannyota/splunkctl/issues
|
|
10
|
-
Keywords: splunk,siem,security,cli,detection,spl
|
|
10
|
+
Keywords: splunk,mcp,model-context-protocol,siem,soar,security,cli,detection,spl
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Environment :: Console
|
|
13
13
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -109,7 +109,7 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
|
109
109
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
110
110
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
111
111
|
batching.
|
|
112
|
-
- **Built for agents** — built-in MCP server with
|
|
112
|
+
- **Built for agents** — built-in MCP server with 259 auto-generated tools,
|
|
113
113
|
progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
|
|
114
114
|
guard markers on every mutation, dual output (TTY = table, pipe = JSON).
|
|
115
115
|
|
|
@@ -267,7 +267,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
267
267
|
splunkctl mcp serve # start stdio MCP server
|
|
268
268
|
```
|
|
269
269
|
|
|
270
|
-
The MCP server auto-generates
|
|
270
|
+
The MCP server auto-generates 259 typed tools from the Click command tree
|
|
271
271
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
272
272
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
273
273
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -71,7 +71,7 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
|
|
|
71
71
|
creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
|
|
72
72
|
field map, with SDI dedup, severity mapping, and last-artifact automation
|
|
73
73
|
batching.
|
|
74
|
-
- **Built for agents** — built-in MCP server with
|
|
74
|
+
- **Built for agents** — built-in MCP server with 259 auto-generated tools,
|
|
75
75
|
progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
|
|
76
76
|
guard markers on every mutation, dual output (TTY = table, pipe = JSON).
|
|
77
77
|
|
|
@@ -229,7 +229,7 @@ splunkctl mcp install # register in .mcp.json
|
|
|
229
229
|
splunkctl mcp serve # start stdio MCP server
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
The MCP server auto-generates
|
|
232
|
+
The MCP server auto-generates 259 typed tools from the Click command tree
|
|
233
233
|
(SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
|
|
234
234
|
(`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
|
|
235
235
|
schemas per command group. Subgroup-granular focus works for nested groups
|
|
@@ -5,13 +5,13 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "splunkctl"
|
|
7
7
|
dynamic = ["version"]
|
|
8
|
-
description = "CLI
|
|
8
|
+
description = "Open-source MCP server and CLI for Splunk Enterprise SIEM and Splunk SOAR."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
authors = [{ name = "dannyota" }]
|
|
14
|
-
keywords = ["splunk", "siem", "security", "cli", "detection", "spl"]
|
|
14
|
+
keywords = ["splunk", "mcp", "model-context-protocol", "siem", "soar", "security", "cli", "detection", "spl"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 3 - Alpha",
|
|
17
17
|
"Environment :: Console",
|
|
@@ -51,8 +51,10 @@ Homepage = "https://github.com/dannyota/splunkctl"
|
|
|
51
51
|
Repository = "https://github.com/dannyota/splunkctl"
|
|
52
52
|
Issues = "https://github.com/dannyota/splunkctl/issues"
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
# Auto-discover subpackages — a hardcoded list silently dropped
|
|
55
|
+
# splunkctl.commands.soar from every wheel (v0.7.0–v0.10.0).
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
include = ["splunkctl*"]
|
|
56
58
|
|
|
57
59
|
[tool.setuptools.dynamic]
|
|
58
60
|
version = { attr = "splunkctl.__version__" }
|
|
@@ -18,6 +18,20 @@ import splunklib.client as splunk_client
|
|
|
18
18
|
import urllib3
|
|
19
19
|
|
|
20
20
|
from splunkctl import config as cfg_mod
|
|
21
|
+
from splunkctl.errors import WebSessionError
|
|
22
|
+
|
|
23
|
+
_tls_warned: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _warn_tls_off() -> None:
|
|
27
|
+
"""Print one warning per process when TLS verification is disabled."""
|
|
28
|
+
global _tls_warned # noqa: PLW0603
|
|
29
|
+
if not _tls_warned:
|
|
30
|
+
_tls_warned = True
|
|
31
|
+
click.echo(
|
|
32
|
+
"Warning: TLS certificate verification is disabled for this connection.",
|
|
33
|
+
err=True,
|
|
34
|
+
)
|
|
21
35
|
|
|
22
36
|
|
|
23
37
|
class SplunkClient:
|
|
@@ -98,6 +112,7 @@ class SplunkClient:
|
|
|
98
112
|
|
|
99
113
|
if not cfg.get("verify", False):
|
|
100
114
|
connect_args["verify"] = False
|
|
115
|
+
_warn_tls_off()
|
|
101
116
|
|
|
102
117
|
if self._timeout:
|
|
103
118
|
connect_args["timeout"] = self._timeout
|
|
@@ -174,9 +189,10 @@ class _WebSession:
|
|
|
174
189
|
self._username: str = service.username
|
|
175
190
|
self._password: str = service.password
|
|
176
191
|
if not self._username or not self._password:
|
|
177
|
-
raise
|
|
192
|
+
raise WebSessionError(
|
|
178
193
|
"Lookup upload requires username/password authentication. "
|
|
179
|
-
"Token-only auth cannot authenticate to Splunk Web UI."
|
|
194
|
+
"Token-only auth cannot authenticate to Splunk Web UI.",
|
|
195
|
+
kind="auth",
|
|
180
196
|
)
|
|
181
197
|
|
|
182
198
|
web_conf = service.confs["web"]["settings"]
|
|
@@ -225,19 +241,22 @@ class _WebSession:
|
|
|
225
241
|
try:
|
|
226
242
|
body: dict[str, Any] = resp.json()
|
|
227
243
|
except ValueError:
|
|
228
|
-
raise
|
|
229
|
-
f"Splunk Web login failed: HTTP {resp.status_code}"
|
|
244
|
+
raise WebSessionError(
|
|
245
|
+
f"Splunk Web login failed: HTTP {resp.status_code}",
|
|
246
|
+
kind="auth",
|
|
230
247
|
) from None
|
|
231
248
|
if body.get("status") == "fail":
|
|
232
249
|
msg = body.get("msg", "unknown error")
|
|
233
|
-
raise
|
|
250
|
+
raise WebSessionError(f"Splunk Web login failed: {msg}", kind="auth")
|
|
234
251
|
|
|
235
252
|
for cookie in self._session.cookies:
|
|
236
253
|
if cookie.name and cookie.name.startswith("splunkweb_csrf_token"):
|
|
237
254
|
self._csrf_token = cookie.value
|
|
238
255
|
break
|
|
239
256
|
if not self._csrf_token:
|
|
240
|
-
raise
|
|
257
|
+
raise WebSessionError(
|
|
258
|
+
"Could not obtain CSRF token from Splunk Web", kind="web"
|
|
259
|
+
)
|
|
241
260
|
self._logged_in = True
|
|
242
261
|
|
|
243
262
|
def upload_lookup(
|
|
@@ -315,7 +334,9 @@ class _WebSession:
|
|
|
315
334
|
},
|
|
316
335
|
)
|
|
317
336
|
if resp.status_code >= 400:
|
|
318
|
-
raise
|
|
337
|
+
raise WebSessionError(
|
|
338
|
+
f"App install failed: HTTP {resp.status_code}", kind="web"
|
|
339
|
+
)
|
|
319
340
|
|
|
320
341
|
|
|
321
342
|
def _expect_ok(resp: requests.Response, what: str) -> None:
|
|
@@ -323,12 +344,15 @@ def _expect_ok(resp: requests.Response, what: str) -> None:
|
|
|
323
344
|
try:
|
|
324
345
|
result: dict[str, Any] = resp.json()
|
|
325
346
|
except ValueError:
|
|
326
|
-
raise
|
|
347
|
+
raise WebSessionError(
|
|
327
348
|
f"{what} failed: HTTP {resp.status_code} — "
|
|
328
|
-
f"unexpected response: {resp.text[:200]!r}"
|
|
349
|
+
f"unexpected response: {resp.text[:200]!r}",
|
|
350
|
+
kind="web",
|
|
329
351
|
) from None
|
|
330
352
|
if result.get("status") != "OK":
|
|
331
|
-
raise
|
|
353
|
+
raise WebSessionError(
|
|
354
|
+
f"{what} failed: {result.get('msg', 'unknown error')}", kind="web"
|
|
355
|
+
)
|
|
332
356
|
|
|
333
357
|
|
|
334
358
|
def rest_get_json(
|
|
@@ -39,6 +39,8 @@ def _walk(group: click.Group) -> list[dict[str, Any]]:
|
|
|
39
39
|
"""Recursively build the command tree."""
|
|
40
40
|
nodes: list[dict[str, Any]] = []
|
|
41
41
|
for name, cmd in sorted(group.commands.items()):
|
|
42
|
+
if cmd.hidden:
|
|
43
|
+
continue
|
|
42
44
|
node: dict[str, Any] = {
|
|
43
45
|
"name": name,
|
|
44
46
|
"help": (cmd.help or "").split("\n")[0],
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Shared helpers for command groups."""
|
|
2
2
|
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
3
6
|
from collections.abc import Callable, Iterable
|
|
4
7
|
from typing import Any
|
|
5
8
|
|
|
@@ -18,6 +21,29 @@ def spl_quote(value: str) -> str:
|
|
|
18
21
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
19
22
|
|
|
20
23
|
|
|
24
|
+
class UnsafeLookupNameError(click.ClickException):
|
|
25
|
+
"""Lookup name contains characters unsafe for SPL interpolation."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def spl_quote_lookup_name(name: str) -> str:
|
|
29
|
+
"""Quote a lookup name for safe interpolation into SPL commands.
|
|
30
|
+
|
|
31
|
+
Used in ``| inputlookup <name>`` to prevent SPL injection. Rejects
|
|
32
|
+
names containing newlines or NUL (which could break out of quoting
|
|
33
|
+
regardless of escaping), escapes backslashes and double quotes, and
|
|
34
|
+
wraps the result in double quotes.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
UnsafeLookupNameError: If the name contains newlines or NUL.
|
|
38
|
+
"""
|
|
39
|
+
if "\n" in name or "\r" in name or "\x00" in name:
|
|
40
|
+
raise UnsafeLookupNameError(
|
|
41
|
+
f"Lookup name {name!r} contains newline or NUL characters "
|
|
42
|
+
"and cannot be safely used in SPL."
|
|
43
|
+
)
|
|
44
|
+
return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
45
|
+
|
|
46
|
+
|
|
21
47
|
_ALERT_TYPES = (
|
|
22
48
|
"custom",
|
|
23
49
|
"number of events",
|
|
@@ -354,6 +380,54 @@ _REQUIRED_ACTION_FIELDS: dict[str, str] = {
|
|
|
354
380
|
}
|
|
355
381
|
|
|
356
382
|
|
|
383
|
+
def resolve_leaf_command(
|
|
384
|
+
group: click.Group,
|
|
385
|
+
args: list[str],
|
|
386
|
+
) -> click.Command | None:
|
|
387
|
+
"""Walk the Click tree to find the leaf command for the given args."""
|
|
388
|
+
cmd: click.Command = group
|
|
389
|
+
for tok in args:
|
|
390
|
+
if tok.startswith("-"):
|
|
391
|
+
continue
|
|
392
|
+
if isinstance(cmd, click.Group) and tok in cmd.commands:
|
|
393
|
+
cmd = cmd.commands[tok]
|
|
394
|
+
else:
|
|
395
|
+
break
|
|
396
|
+
return cmd if cmd is not group else None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def watch_loop(
|
|
400
|
+
ctx: click.Context,
|
|
401
|
+
interval: int,
|
|
402
|
+
invoke_fn: Callable[[click.Context], Any],
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Re-invoke a read-only command every *interval* seconds.
|
|
405
|
+
|
|
406
|
+
Clears the terminal between runs (like ``watch(1)``). Errors are
|
|
407
|
+
printed in-place and the loop continues. Ctrl-C (KeyboardInterrupt)
|
|
408
|
+
exits cleanly with code 0.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
ctx: The top-level Click context.
|
|
412
|
+
interval: Seconds between runs.
|
|
413
|
+
invoke_fn: Callable that re-invokes the resolved command tree.
|
|
414
|
+
"""
|
|
415
|
+
while True:
|
|
416
|
+
if sys.stdout.isatty():
|
|
417
|
+
# Hardcoded command, no user input — safe to invoke directly.
|
|
418
|
+
subprocess.run( # noqa: S603, S607
|
|
419
|
+
["cls" if sys.platform == "win32" else "clear"],
|
|
420
|
+
check=False,
|
|
421
|
+
)
|
|
422
|
+
try:
|
|
423
|
+
invoke_fn(ctx)
|
|
424
|
+
except SystemExit:
|
|
425
|
+
pass
|
|
426
|
+
except Exception as exc: # noqa: BLE001
|
|
427
|
+
output.error(str(exc))
|
|
428
|
+
time.sleep(interval)
|
|
429
|
+
|
|
430
|
+
|
|
357
431
|
def app_scope(app: str | None) -> dict[str, str]:
|
|
358
432
|
"""Build the ``app``/``owner`` kwargs for an app-scoped list call.
|
|
359
433
|
|
|
@@ -93,7 +93,8 @@ def init(
|
|
|
93
93
|
else click.prompt("Password", default="", hide_input=True)
|
|
94
94
|
)
|
|
95
95
|
scheme = scheme or "https"
|
|
96
|
-
|
|
96
|
+
if verify is None:
|
|
97
|
+
verify = click.confirm("Verify TLS certificates", default=True)
|
|
97
98
|
|
|
98
99
|
cfg: dict[str, Any] = {
|
|
99
100
|
"host": host,
|
|
@@ -176,7 +176,7 @@ def get_dashboard(
|
|
|
176
176
|
client = get_client(ctx)
|
|
177
177
|
try:
|
|
178
178
|
d = _resolve(client.service, name, app)
|
|
179
|
-
except
|
|
179
|
+
except KeyError:
|
|
180
180
|
output.error(f"Dashboard '{name}' not found.", kind="not_found")
|
|
181
181
|
ctx.exit(1)
|
|
182
182
|
return
|
|
@@ -303,7 +303,7 @@ def update_dashboard(
|
|
|
303
303
|
client = get_client(ctx)
|
|
304
304
|
try:
|
|
305
305
|
d = _resolve(client.service, name, app)
|
|
306
|
-
except
|
|
306
|
+
except KeyError:
|
|
307
307
|
output.error(f"Dashboard '{name}' not found.", kind="not_found")
|
|
308
308
|
ctx.exit(1)
|
|
309
309
|
return
|
|
@@ -332,8 +332,8 @@ def delete_dashboard(ctx: click.Context, name: str, *, app: str) -> None:
|
|
|
332
332
|
client = get_client(ctx)
|
|
333
333
|
try:
|
|
334
334
|
d = _resolve(client.service, name, app)
|
|
335
|
-
except
|
|
336
|
-
output.error(f"
|
|
335
|
+
except KeyError:
|
|
336
|
+
output.error(f"Dashboard '{name}' not found.", kind="not_found")
|
|
337
337
|
ctx.exit(1)
|
|
338
338
|
return
|
|
339
339
|
d.delete()
|
|
@@ -390,7 +390,7 @@ def export_dashboard(
|
|
|
390
390
|
client = get_client(ctx)
|
|
391
391
|
try:
|
|
392
392
|
d = _resolve(client.service, name, app)
|
|
393
|
-
except
|
|
393
|
+
except KeyError:
|
|
394
394
|
output.error(f"Dashboard '{name}' not found.", kind="not_found")
|
|
395
395
|
ctx.exit(1)
|
|
396
396
|
return
|
|
@@ -470,7 +470,7 @@ def share_dashboard(
|
|
|
470
470
|
client = get_client(ctx)
|
|
471
471
|
try:
|
|
472
472
|
d = _resolve(client.service, name, app)
|
|
473
|
-
except
|
|
473
|
+
except KeyError:
|
|
474
474
|
output.error(f"Dashboard '{name}' not found.", kind="not_found")
|
|
475
475
|
ctx.exit(1)
|
|
476
476
|
return
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""`splunkctl docs generate` — the generated command reference.
|
|
2
|
+
|
|
3
|
+
Walks the same runnable-command tree as `commands` and writes one markdown
|
|
4
|
+
page per top-level group into docs/commands/ plus an index, and syncs the
|
|
5
|
+
marker-delimited block in docs/_sidebar.md — so the docs-site reference can
|
|
6
|
+
never drift from the CLI. Hidden: a maintainer/CI tool, not an operator
|
|
7
|
+
surface. `--check` regenerates in memory and fails when the committed files
|
|
8
|
+
are stale (the CI freshness gate).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from splunkctl.guard import is_guarded
|
|
17
|
+
|
|
18
|
+
_START = "<!-- commands:start -->"
|
|
19
|
+
_END = "<!-- commands:end -->"
|
|
20
|
+
_NOTICE = "<!-- Generated by `splunkctl docs generate` — do not edit by hand. -->"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group("docs", hidden=True)
|
|
24
|
+
def docs_group() -> None:
|
|
25
|
+
"""Generate documentation and command-reference pages (maintainers)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _Entry:
|
|
30
|
+
"""One runnable command with its full path (e.g. ``soar playbooks list``)."""
|
|
31
|
+
|
|
32
|
+
path: str
|
|
33
|
+
cmd: click.Command
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _Group:
|
|
38
|
+
"""One reference page: a top-level command group and its runnable commands.
|
|
39
|
+
|
|
40
|
+
Single leaf top-level commands (doctor, info, ...) are folded into the
|
|
41
|
+
synthetic "global" group.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
short: str
|
|
46
|
+
entries: list[_Entry]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _first_line(text: str | None) -> str:
|
|
50
|
+
return (text or "").split("\n")[0].strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _walk(group: click.Group, prefix: str, out: list[_Entry]) -> None:
|
|
54
|
+
"""Collect every visible runnable command under ``group``."""
|
|
55
|
+
for name, cmd in sorted(group.commands.items()):
|
|
56
|
+
if cmd.hidden:
|
|
57
|
+
continue
|
|
58
|
+
path = f"{prefix} {name}".strip()
|
|
59
|
+
if isinstance(cmd, click.Group):
|
|
60
|
+
_walk(cmd, path, out)
|
|
61
|
+
else:
|
|
62
|
+
out.append(_Entry(path, cmd))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _collect_groups(root: click.Group) -> list[_Group]:
|
|
66
|
+
"""Bucket every runnable command by its top-level segment.
|
|
67
|
+
|
|
68
|
+
A top-level command with no subcommands lands in "global"; groups are
|
|
69
|
+
sorted by name with "global" last.
|
|
70
|
+
"""
|
|
71
|
+
entries: list[_Entry] = []
|
|
72
|
+
_walk(root, "", entries)
|
|
73
|
+
|
|
74
|
+
shorts = {name: _first_line(cmd.help) for name, cmd in root.commands.items()}
|
|
75
|
+
|
|
76
|
+
by_top: dict[str, list[_Entry]] = {}
|
|
77
|
+
for e in entries:
|
|
78
|
+
top = e.path.split(" ")[0]
|
|
79
|
+
by_top.setdefault(top, []).append(e)
|
|
80
|
+
|
|
81
|
+
groups: list[_Group] = []
|
|
82
|
+
global_entries: list[_Entry] = []
|
|
83
|
+
for top, es in sorted(by_top.items()):
|
|
84
|
+
# A single entry whose path IS the top-level name is a plain leaf
|
|
85
|
+
# command (doctor, info, commands, ...) — folded into "global".
|
|
86
|
+
if len(es) == 1 and es[0].path == top:
|
|
87
|
+
global_entries.append(es[0])
|
|
88
|
+
continue
|
|
89
|
+
groups.append(_Group(top, shorts.get(top, ""), es))
|
|
90
|
+
if global_entries:
|
|
91
|
+
groups.append(_Group("global", "Top-level commands", global_entries))
|
|
92
|
+
return groups
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cell(s: str) -> str:
|
|
96
|
+
"""Make a string safe inside a one-line markdown table cell."""
|
|
97
|
+
return " ".join(s.replace("|", "\\|").split())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _usage(e: _Entry) -> str:
|
|
101
|
+
ctx = click.Context(e.cmd, info_name=f"splunkctl {e.path}")
|
|
102
|
+
pieces = e.cmd.collect_usage_pieces(ctx)
|
|
103
|
+
return " ".join([f"splunkctl {e.path}", *pieces])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _long_help(e: _Entry) -> str:
|
|
107
|
+
r"""Body of the command help below the summary line, ``\b`` markers dropped."""
|
|
108
|
+
lines = (e.cmd.help or "").split("\n")[1:]
|
|
109
|
+
return "\n".join(ln for ln in lines if ln.strip() != "\b").strip()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _flag_default(p: click.Option) -> str:
|
|
113
|
+
d = p.default
|
|
114
|
+
if isinstance(d, bool):
|
|
115
|
+
return "true" if d else "false"
|
|
116
|
+
# Only scalar defaults are meaningful in docs — sentinels, tuples, and
|
|
117
|
+
# callables render as "-".
|
|
118
|
+
if isinstance(d, (str, int, float)) and d != "":
|
|
119
|
+
return str(d)
|
|
120
|
+
return "-"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _flag_type(p: click.Option) -> str:
|
|
124
|
+
if isinstance(p.type, click.Choice):
|
|
125
|
+
return "choice: " + ", ".join(str(c) for c in p.type.choices)
|
|
126
|
+
if p.is_flag:
|
|
127
|
+
return "flag"
|
|
128
|
+
return p.type.name
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _flag_rows(cmd: click.Command) -> list[str]:
|
|
132
|
+
"""Render the command's options as markdown table rows."""
|
|
133
|
+
rows: list[str] = []
|
|
134
|
+
for p in cmd.params:
|
|
135
|
+
if not isinstance(p, click.Option) or p.hidden or p.name == "help":
|
|
136
|
+
continue
|
|
137
|
+
shorts = [o for o in p.opts if not o.startswith("--")]
|
|
138
|
+
longs = [o for o in p.opts if o.startswith("--")]
|
|
139
|
+
name = ", ".join(shorts + longs)
|
|
140
|
+
if p.secondary_opts:
|
|
141
|
+
name += " / " + ", ".join(p.secondary_opts)
|
|
142
|
+
desc = p.help or ""
|
|
143
|
+
if p.required:
|
|
144
|
+
desc = f"{desc} [required]".strip()
|
|
145
|
+
rows.append(
|
|
146
|
+
f"| `{_cell(name)}` | {_cell(_flag_type(p))}"
|
|
147
|
+
f" | {_cell(_flag_default(p))} | {_cell(desc)} |"
|
|
148
|
+
)
|
|
149
|
+
return rows
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _render_entry(e: _Entry) -> list[str]:
|
|
153
|
+
out = [f"## {e.path}", ""]
|
|
154
|
+
if short := _first_line(e.cmd.help):
|
|
155
|
+
out += [short, ""]
|
|
156
|
+
if is_guarded(e.cmd):
|
|
157
|
+
out += ["> Guarded mutation — dry-run by default; apply with `--yes`.", ""]
|
|
158
|
+
out += ["```text", _usage(e), "```"]
|
|
159
|
+
|
|
160
|
+
# The long help is CLI help text, not authored markdown — fence it
|
|
161
|
+
# verbatim so stray tables/lists/pipes inside it can never break the page.
|
|
162
|
+
if long := _long_help(e):
|
|
163
|
+
fence = "~~~~" if "```" in long else "```"
|
|
164
|
+
out += ["", f"{fence}text", long, fence]
|
|
165
|
+
|
|
166
|
+
if rows := _flag_rows(e.cmd):
|
|
167
|
+
out += [
|
|
168
|
+
"",
|
|
169
|
+
"**Flags**",
|
|
170
|
+
"",
|
|
171
|
+
"| Flag | Type | Default | Description |",
|
|
172
|
+
"|---|---|---|---|",
|
|
173
|
+
*rows,
|
|
174
|
+
]
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _render_group(g: _Group) -> str:
|
|
179
|
+
title = "Global commands" if g.name == "global" else f"splunkctl {g.name}"
|
|
180
|
+
lines = [_NOTICE, "", f"# {title}", ""]
|
|
181
|
+
if g.short:
|
|
182
|
+
lines.append(g.short)
|
|
183
|
+
for e in g.entries:
|
|
184
|
+
lines += ["", *_render_entry(e)]
|
|
185
|
+
return "\n".join(lines) + "\n"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _render_index(groups: list[_Group]) -> str:
|
|
189
|
+
lines = [
|
|
190
|
+
_NOTICE,
|
|
191
|
+
"",
|
|
192
|
+
"# Command reference",
|
|
193
|
+
"",
|
|
194
|
+
"Generated from the Click command tree — run `splunkctl docs generate`",
|
|
195
|
+
'after changing a command. "Guarded" commands are live mutations behind',
|
|
196
|
+
"the standard dry-run/`--yes` gate; everything else is a read.",
|
|
197
|
+
"",
|
|
198
|
+
"| Group | Commands | Description |",
|
|
199
|
+
"|---|---|---|",
|
|
200
|
+
]
|
|
201
|
+
for g in groups:
|
|
202
|
+
lines.append(
|
|
203
|
+
f"| [{g.name}]({g.name}.md) | {len(g.entries)} | {_cell(g.short)} |"
|
|
204
|
+
)
|
|
205
|
+
return "\n".join(lines) + "\n"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _render_pages(groups: list[_Group]) -> dict[str, str]:
|
|
209
|
+
"""Render every group page plus the index, keyed by filename."""
|
|
210
|
+
pages = {f"{g.name}.md": _render_group(g) for g in groups}
|
|
211
|
+
pages["README.md"] = _render_index(groups)
|
|
212
|
+
return pages
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _sidebar_block(pages: dict[str, str]) -> str:
|
|
216
|
+
names = sorted(n.removesuffix(".md") for n in pages if n != "README.md")
|
|
217
|
+
lines = [_START, "- **Command reference**", " - [Overview](commands/README.md)"]
|
|
218
|
+
lines += [f" - [{n}](commands/{n}.md)" for n in names]
|
|
219
|
+
lines.append(_END)
|
|
220
|
+
return "\n".join(lines)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _patch_sidebar(content: str, block: str) -> str:
|
|
224
|
+
"""Replace the marker-delimited sidebar block, or insert before Design."""
|
|
225
|
+
if (start := content.find(_START)) >= 0:
|
|
226
|
+
end = content.find(_END)
|
|
227
|
+
if end < start:
|
|
228
|
+
raise click.ClickException(
|
|
229
|
+
f"sidebar: {_START} found without a following {_END}"
|
|
230
|
+
)
|
|
231
|
+
return content[:start] + block + content[end + len(_END) :]
|
|
232
|
+
anchor = "- **Design**"
|
|
233
|
+
if (i := content.find(anchor)) >= 0:
|
|
234
|
+
return content[:i] + block + "\n\n" + content[i:]
|
|
235
|
+
return content.rstrip("\n") + "\n\n" + block + "\n"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _stale_pages(pages: dict[str, str], out_dir: Path) -> list[str]:
|
|
239
|
+
"""Committed *.md files the current command tree no longer generates."""
|
|
240
|
+
if not out_dir.is_dir():
|
|
241
|
+
return []
|
|
242
|
+
return sorted(
|
|
243
|
+
f.name
|
|
244
|
+
for f in out_dir.iterdir()
|
|
245
|
+
if f.is_file() and f.name.endswith(".md") and f.name not in pages
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _write_docs(pages: dict[str, str], out_dir: Path, sidebar: str) -> None:
|
|
250
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
for name, content in pages.items():
|
|
252
|
+
(out_dir / name).write_text(content, encoding="utf-8")
|
|
253
|
+
stale = _stale_pages(pages, out_dir)
|
|
254
|
+
for name in stale:
|
|
255
|
+
(out_dir / name).unlink()
|
|
256
|
+
if sidebar:
|
|
257
|
+
path = Path(sidebar)
|
|
258
|
+
raw = path.read_text(encoding="utf-8")
|
|
259
|
+
path.write_text(_patch_sidebar(raw, _sidebar_block(pages)), encoding="utf-8")
|
|
260
|
+
msg = f"generated {len(pages)} page(s) in {out_dir}"
|
|
261
|
+
if stale:
|
|
262
|
+
msg += f", removed {len(stale)} stale"
|
|
263
|
+
click.echo(msg)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _check_docs(pages: dict[str, str], out_dir: Path, sidebar: str) -> list[str]:
|
|
267
|
+
problems: list[str] = []
|
|
268
|
+
for name, content in pages.items():
|
|
269
|
+
f = out_dir / name
|
|
270
|
+
if not f.is_file():
|
|
271
|
+
problems.append(f"missing: {name}")
|
|
272
|
+
elif f.read_text(encoding="utf-8") != content:
|
|
273
|
+
problems.append(f"stale: {name}")
|
|
274
|
+
problems += [f"orphaned: {name}" for name in _stale_pages(pages, out_dir)]
|
|
275
|
+
if sidebar:
|
|
276
|
+
raw = Path(sidebar).read_text(encoding="utf-8")
|
|
277
|
+
if _patch_sidebar(raw, _sidebar_block(pages)) != raw:
|
|
278
|
+
problems.append(f"stale: {sidebar} command-reference block")
|
|
279
|
+
return sorted(problems)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@docs_group.command("generate")
|
|
283
|
+
@click.option(
|
|
284
|
+
"--out",
|
|
285
|
+
"out_dir",
|
|
286
|
+
default="docs/commands",
|
|
287
|
+
help="Output directory for the generated pages.",
|
|
288
|
+
)
|
|
289
|
+
@click.option(
|
|
290
|
+
"--sidebar",
|
|
291
|
+
default="docs/_sidebar.md",
|
|
292
|
+
help="Sidebar file to sync (empty to skip).",
|
|
293
|
+
)
|
|
294
|
+
@click.option(
|
|
295
|
+
"--check",
|
|
296
|
+
is_flag=True,
|
|
297
|
+
help="Verify the committed pages are current; write nothing.",
|
|
298
|
+
)
|
|
299
|
+
@click.pass_context
|
|
300
|
+
def docs_generate(ctx: click.Context, out_dir: str, sidebar: str, check: bool) -> None:
|
|
301
|
+
"""Generate the per-group command-reference pages under docs/commands/.
|
|
302
|
+
|
|
303
|
+
Walk the command tree and write one markdown page per top-level command
|
|
304
|
+
group into docs/commands/ (plus an index README), and sync the
|
|
305
|
+
commands:start/end block in docs/_sidebar.md. With --check, nothing is
|
|
306
|
+
written: the run fails when any committed page, the index, or the
|
|
307
|
+
sidebar block is stale — the CI freshness gate.
|
|
308
|
+
"""
|
|
309
|
+
root = ctx.find_root().command
|
|
310
|
+
if not isinstance(root, click.Group):
|
|
311
|
+
raise click.ClickException("Cannot resolve CLI root.")
|
|
312
|
+
pages = _render_pages(_collect_groups(root))
|
|
313
|
+
if check:
|
|
314
|
+
if problems := _check_docs(pages, Path(out_dir), sidebar):
|
|
315
|
+
click.echo(
|
|
316
|
+
"command reference is out of date — run `splunkctl docs generate`:",
|
|
317
|
+
err=True,
|
|
318
|
+
)
|
|
319
|
+
for p in problems:
|
|
320
|
+
click.echo(f" {p}", err=True)
|
|
321
|
+
ctx.exit(1)
|
|
322
|
+
click.echo("ok: command reference is current")
|
|
323
|
+
return
|
|
324
|
+
_write_docs(pages, Path(out_dir), sidebar)
|