splunkctl 0.5.0__tar.gz → 0.6.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.
Files changed (66) hide show
  1. {splunkctl-0.5.0 → splunkctl-0.6.0}/PKG-INFO +1 -1
  2. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/__init__.py +1 -1
  3. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/knowledge.py +21 -7
  4. splunkctl-0.6.0/splunkctl/commands/server.py +179 -0
  5. splunkctl-0.5.0/splunkctl/commands/server.py → splunkctl-0.6.0/splunkctl/commands/server_topology.py +125 -115
  6. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/server.py +52 -11
  7. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/tools.py +78 -78
  8. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/PKG-INFO +1 -1
  9. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/SOURCES.txt +1 -0
  10. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_version.py +1 -1
  11. {splunkctl-0.5.0 → splunkctl-0.6.0}/LICENSE +0 -0
  12. {splunkctl-0.5.0 → splunkctl-0.6.0}/README.md +0 -0
  13. {splunkctl-0.5.0 → splunkctl-0.6.0}/pyproject.toml +0 -0
  14. {splunkctl-0.5.0 → splunkctl-0.6.0}/setup.cfg +0 -0
  15. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/__main__.py +0 -0
  16. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/client.py +0 -0
  17. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/__init__.py +0 -0
  18. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/alerts.py +0 -0
  19. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/apps.py +0 -0
  20. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/audit.py +0 -0
  21. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/audit_parse.py +0 -0
  22. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/commands_meta.py +0 -0
  23. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/common.py +0 -0
  24. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/conf.py +0 -0
  25. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/conf_ops.py +0 -0
  26. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/config_cmd.py +0 -0
  27. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/dashboards.py +0 -0
  28. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/datamodels.py +0 -0
  29. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/doctor.py +0 -0
  30. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/es.py +0 -0
  31. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/hec.py +0 -0
  32. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/indexes.py +0 -0
  33. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/info.py +0 -0
  34. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/inputs.py +0 -0
  35. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/kvstore.py +0 -0
  36. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/lookups.py +0 -0
  37. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/lookups_wiring.py +0 -0
  38. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/mcp_cmd.py +0 -0
  39. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/parsers.py +0 -0
  40. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/parsers_io.py +0 -0
  41. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/rules.py +0 -0
  42. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/rules_io.py +0 -0
  43. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/search.py +0 -0
  44. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state.py +0 -0
  45. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_io.py +0 -0
  46. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_io_blobs.py +0 -0
  47. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_io_confs.py +0 -0
  48. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_types.py +0 -0
  49. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/users.py +0 -0
  50. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/config.py +0 -0
  51. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/errors.py +0 -0
  52. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/guard.py +0 -0
  53. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/main.py +0 -0
  54. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/__init__.py +0 -0
  55. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/resources.py +0 -0
  56. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/output.py +0 -0
  57. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  58. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/entry_points.txt +0 -0
  59. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/requires.txt +0 -0
  60. {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/top_level.txt +0 -0
  61. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_client.py +0 -0
  62. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_config.py +0 -0
  63. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_guard.py +0 -0
  64. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_main_errors.py +0 -0
  65. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_main_hoisting.py +0 -0
  66. {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise SIEM operations."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
@@ -17,6 +17,7 @@ uses.
17
17
  """
18
18
 
19
19
  import re
20
+ import urllib.parse
20
21
  from typing import Any
21
22
 
22
23
  import click
@@ -275,9 +276,15 @@ def eventtypes_get(ctx: click.Context, name: str, app: str | None) -> None:
275
276
  # each key inside the stanza is a tag name whose value is enabled/disabled.
276
277
  # `disabled` itself is the stanza-level flag, not a tag, and eai:* keys are
277
278
  # read-only REST metadata — both are excluded from the tag-name views below.
279
+ # REST returns stanza names URL-encoded (`eventtype=cim%3Aauth`); the views
280
+ # decode them, and `tags get` accepts either form.
278
281
  _TAG_METADATA_KEYS = {"disabled"}
279
282
 
280
283
 
284
+ def _decode_stanza(name: str) -> str:
285
+ return urllib.parse.unquote(name)
286
+
287
+
281
288
  def _is_tag_key(key: str) -> bool:
282
289
  return key not in _TAG_METADATA_KEYS and not key.startswith("eai:")
283
290
 
@@ -335,7 +342,7 @@ def tags_list(
335
342
  )
336
343
  rows: list[dict[str, Any]] = [
337
344
  {
338
- "field_value": s.name,
345
+ "field_value": _decode_stanza(s.name),
339
346
  "tags": ";".join(_enabled_tag_names(dict(s.content))),
340
347
  "app": dict(s.access).get("app", ""),
341
348
  }
@@ -359,9 +366,16 @@ def tags_get(ctx: click.Context, field_value: str, app: str | None) -> None:
359
366
  try:
360
367
  entity = conf_ops.get_stanza(client, "tags", field_value, app=app)
361
368
  except KeyError:
362
- output.error(
363
- f"Stanza '{field_value}' not found in tags.conf.", kind="not_found"
364
- )
365
- ctx.exit(1)
366
- return
367
- output.render(ctx, {"name": entity.name, **_tag_states(dict(entity.content))})
369
+ encoded = urllib.parse.quote(field_value, safe="=")
370
+ try:
371
+ entity = conf_ops.get_stanza(client, "tags", encoded, app=app)
372
+ except KeyError:
373
+ output.error(
374
+ f"Stanza '{field_value}' not found in tags.conf.", kind="not_found"
375
+ )
376
+ ctx.exit(1)
377
+ return
378
+ output.render(
379
+ ctx,
380
+ {"name": _decode_stanza(entity.name), **_tag_states(dict(entity.content))},
381
+ )
@@ -0,0 +1,179 @@
1
+ """Server operations — messages, license, KV store, topology health."""
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from splunkctl import guard, output
10
+ from splunkctl.client import get_client
11
+
12
+
13
+ def _human_bytes(n: int) -> str:
14
+ size = float(n)
15
+ for unit in ("B", "KB", "MB", "GB", "TB"):
16
+ if abs(size) < 1024 or unit == "TB":
17
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
18
+ size /= 1024
19
+ return f"{n} B"
20
+
21
+
22
+ @click.group("server")
23
+ def server_group() -> None:
24
+ """Server operations — messages, license, KV store, topology health."""
25
+
26
+
27
+ @server_group.command("messages")
28
+ @guard.guarded
29
+ @click.option("--dismiss", default=None, help="Delete a message by name.")
30
+ @click.pass_context
31
+ def messages(ctx: click.Context, dismiss: str | None) -> None:
32
+ """List system messages, or dismiss one with --dismiss NAME --yes."""
33
+ client = get_client(ctx)
34
+ svc = client.service
35
+
36
+ if dismiss is not None:
37
+ if not guard.check(ctx, f"Dismiss system message '{dismiss}'"):
38
+ return
39
+ try:
40
+ msg = svc.messages[dismiss]
41
+ except KeyError:
42
+ output.error(f"Message '{dismiss}' not found.", kind="not_found")
43
+ ctx.exit(1)
44
+ return
45
+ msg.delete()
46
+ output.info(f"Message '{dismiss}' dismissed.")
47
+ return
48
+
49
+ rows: list[dict[str, Any]] = []
50
+ for msg in svc.messages.list():
51
+ c: dict[str, Any] = msg.content
52
+ rows.append(
53
+ {
54
+ "name": msg.name,
55
+ "severity": c.get("severity", ""),
56
+ "message": c.get("message", ""),
57
+ "time_created": c.get("timeCreated_iso", c.get("timeCreated", "")),
58
+ }
59
+ )
60
+ output.render(ctx, rows, empty="No system messages.")
61
+
62
+
63
+ # Splunk encodes "never expires" as INT32_MAX seconds.
64
+ _PERPETUAL = 2**31 - 1
65
+
66
+
67
+ def _render_license_usage(ctx: click.Context, svc: Any) -> None:
68
+ """Render today's indexed volume vs quota plus license expiry."""
69
+ resp = svc.get("/services/licenser/usage", output_mode="json")
70
+ body: dict[str, Any] = json.loads(resp.body.read())
71
+ entries: list[dict[str, Any]] = body.get("entry", [])
72
+ c: dict[str, Any] = entries[0].get("content", {}) if entries else {}
73
+ used = int(c.get("slaves_usage_bytes", 0))
74
+ quota = int(c.get("quota", 0))
75
+ pct = round(used / quota * 100, 1) if quota else 0.0
76
+
77
+ resp = svc.get("/services/licenser/licenses", output_mode="json")
78
+ lic_body: dict[str, Any] = json.loads(resp.body.read())
79
+ valid = 0
80
+ expiries: list[int] = []
81
+ for entry in lic_body.get("entry", []):
82
+ lc: dict[str, Any] = entry.get("content", {})
83
+ if lc.get("status") == "VALID":
84
+ valid += 1
85
+ exp = int(lc.get("expiration_time", _PERPETUAL))
86
+ if exp < _PERPETUAL:
87
+ expiries.append(exp)
88
+ soonest = (
89
+ datetime.fromtimestamp(min(expiries), tz=UTC).strftime("%Y-%m-%d")
90
+ if expiries
91
+ else "never"
92
+ )
93
+
94
+ output.render(
95
+ ctx,
96
+ {
97
+ "used": _human_bytes(used),
98
+ "quota": _human_bytes(quota),
99
+ "pct_used": pct,
100
+ "licenses_valid": valid,
101
+ "soonest_expiry": soonest,
102
+ },
103
+ )
104
+
105
+
106
+ @server_group.command("license")
107
+ @click.option(
108
+ "--usage",
109
+ "show_usage",
110
+ is_flag=True,
111
+ help="Today's indexed volume vs quota and soonest license expiry.",
112
+ )
113
+ @click.pass_context
114
+ def license_pools(ctx: click.Context, *, show_usage: bool) -> None:
115
+ """Show license pool usage, or daily volume and expiry with --usage."""
116
+ client = get_client(ctx)
117
+ if show_usage:
118
+ _render_license_usage(ctx, client.service)
119
+ return
120
+
121
+ resp = client.service.get("/services/licenser/pools", output_mode="json")
122
+ body: dict[str, Any] = json.loads(resp.body.read())
123
+
124
+ rows: list[dict[str, Any]] = []
125
+ for entry in body.get("entry", []):
126
+ c: dict[str, Any] = entry.get("content", {})
127
+ used = int(c.get("used_bytes", 0))
128
+ quota = int(c.get("effective_quota", 0))
129
+ rows.append(
130
+ {
131
+ "title": entry.get("name", ""),
132
+ "used": _human_bytes(used),
133
+ "quota": _human_bytes(quota),
134
+ }
135
+ )
136
+ output.render(ctx, rows, empty="No license pools found.")
137
+
138
+
139
+ @server_group.command("kvstore")
140
+ @click.pass_context
141
+ def kvstore_status(ctx: click.Context) -> None:
142
+ """Show KV store status."""
143
+ client = get_client(ctx)
144
+ resp = client.service.get("/services/kvstore/status", output_mode="json")
145
+ body: dict[str, Any] = json.loads(resp.body.read())
146
+
147
+ entries: list[dict[str, Any]] = body.get("entry", [])
148
+ if not entries:
149
+ output.error("No KV store status available.")
150
+ ctx.exit(1)
151
+ return
152
+
153
+ c: dict[str, Any] = entries[0].get("content", {})
154
+ current: dict[str, Any] = c.get("current") or {}
155
+ status_raw = current.get("status")
156
+ status = "unknown" if not status_raw else str(status_raw).lower()
157
+ row: dict[str, Any] = {
158
+ "status": status,
159
+ "port": current.get("port", ""),
160
+ "version": current.get("version", ""),
161
+ "storage_engine": current.get("storageEngine", ""),
162
+ "db_path": current.get("dbPath", ""),
163
+ }
164
+ output.render(ctx, row)
165
+
166
+
167
+ from splunkctl.commands.server_topology import ( # noqa: E402
168
+ cluster_health,
169
+ deployment_health,
170
+ health_report,
171
+ search_peers,
172
+ shcluster_health,
173
+ )
174
+
175
+ server_group.add_command(cluster_health)
176
+ server_group.add_command(shcluster_health)
177
+ server_group.add_command(deployment_health)
178
+ server_group.add_command(health_report)
179
+ server_group.add_command(search_peers)
@@ -1,23 +1,14 @@
1
- """Server operations — messages, license, KV store, topology health."""
1
+ """Server topology & health commands (cluster, SHC, deployment, peers)."""
2
2
 
3
3
  import json
4
4
  from typing import Any
5
5
 
6
6
  import click
7
7
 
8
- from splunkctl import guard, output
8
+ from splunkctl import output
9
9
  from splunkctl.client import get_client
10
10
 
11
11
 
12
- def _human_bytes(n: int) -> str:
13
- size = float(n)
14
- for unit in ("B", "KB", "MB", "GB", "TB"):
15
- if abs(size) < 1024 or unit == "TB":
16
- return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
17
- size /= 1024
18
- return f"{n} B"
19
-
20
-
21
12
  def _is_not_enabled(exc: Exception) -> bool:
22
13
  """True when an HTTPError signals a feature is not enabled (clean exit 0).
23
14
 
@@ -35,111 +26,41 @@ def _is_not_enabled(exc: Exception) -> bool:
35
26
  return "not enabled" in msg
36
27
 
37
28
 
38
- def _rest_get(svc: Any, path: str) -> dict[str, Any]:
39
- """GET a REST path and parse JSON. Thin wrapper for topology reads."""
40
- resp = svc.get(path, output_mode="json")
41
- body: dict[str, Any] = json.loads(resp.body.read())
42
- return body
43
-
44
-
45
- @click.group("server")
46
- def server_group() -> None:
47
- """Server operations — messages, license, KV store, topology health."""
48
-
49
-
50
- @server_group.command("messages")
51
- @guard.guarded
52
- @click.option("--dismiss", default=None, help="Delete a message by name.")
53
- @click.pass_context
54
- def messages(ctx: click.Context, dismiss: str | None) -> None:
55
- """List system messages, or dismiss one with --dismiss NAME --yes."""
56
- client = get_client(ctx)
57
- svc = client.service
58
-
59
- if dismiss is not None:
60
- if not guard.check(ctx, f"Dismiss system message '{dismiss}'"):
61
- return
62
- try:
63
- msg = svc.messages[dismiss]
64
- except KeyError:
65
- output.error(f"Message '{dismiss}' not found.", kind="not_found")
66
- ctx.exit(1)
67
- return
68
- msg.delete()
69
- output.info(f"Message '{dismiss}' dismissed.")
70
- return
71
-
72
- rows: list[dict[str, Any]] = []
73
- for msg in svc.messages.list():
74
- c: dict[str, Any] = msg.content
75
- rows.append(
76
- {
77
- "name": msg.name,
78
- "severity": c.get("severity", ""),
79
- "message": c.get("message", ""),
80
- "time_created": c.get("timeCreated_iso", c.get("timeCreated", "")),
81
- }
82
- )
83
- output.render(ctx, rows, empty="No system messages.")
29
+ def _disabled_detail(exc: Exception) -> str:
30
+ """Human-readable message from a feature-disabled HTTPError.
84
31
 
32
+ Splunk's error body is JSON (``{"messages": [{"text": ...}]}``) when
33
+ the request asked for ``output_mode=json``, XML otherwise; ``str(exc)``
34
+ would embed the raw bytes literal for JSON bodies.
35
+ """
36
+ raw = getattr(exc, "body", b"") or b""
37
+ body = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
38
+ try:
39
+ msgs = json.loads(body).get("messages", [])
40
+ texts = [m.get("text") for m in msgs if isinstance(m, dict)]
41
+ if any(texts):
42
+ return "; ".join(t for t in texts if t)
43
+ except (json.JSONDecodeError, AttributeError):
44
+ pass
45
+ try:
46
+ from defusedxml.ElementTree import fromstring
85
47
 
86
- @server_group.command("license")
87
- @click.pass_context
88
- def license_pools(ctx: click.Context) -> None:
89
- """Show license pool usage."""
90
- client = get_client(ctx)
91
- resp = client.service.get("/services/licenser/pools", output_mode="json")
92
- body: dict[str, Any] = json.loads(resp.body.read())
93
-
94
- rows: list[dict[str, Any]] = []
95
- for entry in body.get("entry", []):
96
- c: dict[str, Any] = entry.get("content", {})
97
- used = int(c.get("used_bytes", 0))
98
- quota = int(c.get("effective_quota", 0))
99
- rows.append(
100
- {
101
- "title": entry.get("name", ""),
102
- "used": _human_bytes(used),
103
- "quota": _human_bytes(quota),
104
- }
105
- )
106
- output.render(ctx, rows, empty="No license pools found.")
48
+ text = fromstring(body).findtext("./messages/msg")
49
+ if text:
50
+ return text
51
+ except Exception: # noqa: BLE001, S110
52
+ pass
53
+ return str(getattr(exc, "reason", "") or exc)
107
54
 
108
55
 
109
- @server_group.command("kvstore")
110
- @click.pass_context
111
- def kvstore_status(ctx: click.Context) -> None:
112
- """Show KV store status."""
113
- client = get_client(ctx)
114
- resp = client.service.get("/services/kvstore/status", output_mode="json")
56
+ def _rest_get(svc: Any, path: str, **params: Any) -> dict[str, Any]:
57
+ """GET a REST path and parse JSON. Thin wrapper for topology reads."""
58
+ resp = svc.get(path, output_mode="json", **params)
115
59
  body: dict[str, Any] = json.loads(resp.body.read())
116
-
117
- entries: list[dict[str, Any]] = body.get("entry", [])
118
- if not entries:
119
- output.error("No KV store status available.")
120
- ctx.exit(1)
121
- return
122
-
123
- c: dict[str, Any] = entries[0].get("content", {})
124
- current: dict[str, Any] = c.get("current") or {}
125
- status_raw = current.get("status")
126
- status = "unknown" if not status_raw else str(status_raw).lower()
127
- row: dict[str, Any] = {
128
- "status": status,
129
- "port": current.get("port", ""),
130
- "version": current.get("version", ""),
131
- "storage_engine": current.get("storageEngine", ""),
132
- "db_path": current.get("dbPath", ""),
133
- }
134
- output.render(ctx, row)
135
-
136
-
137
- # ---------------------------------------------------------------------------
138
- # Topology health (read-only)
139
- # ---------------------------------------------------------------------------
60
+ return body
140
61
 
141
62
 
142
- @server_group.command("cluster")
63
+ @click.command("cluster")
143
64
  @click.pass_context
144
65
  def cluster_health(ctx: click.Context) -> None:
145
66
  """Indexer cluster health — mode, peers, SF/RF status.
@@ -156,7 +77,7 @@ def cluster_health(ctx: click.Context) -> None:
156
77
  info_body = _rest_get(svc, f"/services/{prefix}/info")
157
78
  except Exception as exc:
158
79
  if _is_not_enabled(exc):
159
- output.render(ctx, {"mode": "disabled", "detail": str(exc)})
80
+ output.render(ctx, {"mode": "disabled", "detail": _disabled_detail(exc)})
160
81
  return
161
82
  # 404 -> try legacy master prefix
162
83
  if type(exc).__name__ == "HTTPError" and getattr(exc, "status", 0) == 404:
@@ -165,7 +86,10 @@ def cluster_health(ctx: click.Context) -> None:
165
86
  info_body = _rest_get(svc, f"/services/{prefix}/info")
166
87
  except Exception as inner:
167
88
  if _is_not_enabled(inner):
168
- output.render(ctx, {"mode": "disabled", "detail": str(inner)})
89
+ output.render(
90
+ ctx,
91
+ {"mode": "disabled", "detail": _disabled_detail(inner)},
92
+ )
169
93
  return
170
94
  raise
171
95
  else:
@@ -210,7 +134,7 @@ def cluster_health(ctx: click.Context) -> None:
210
134
  output.render(ctx, rows, empty="No cluster info available.")
211
135
 
212
136
 
213
- @server_group.command("shcluster")
137
+ @click.command("shcluster")
214
138
  @click.pass_context
215
139
  def shcluster_health(ctx: click.Context) -> None:
216
140
  """Search head cluster health — captain, members, replication.
@@ -224,7 +148,7 @@ def shcluster_health(ctx: click.Context) -> None:
224
148
  body = _rest_get(svc, "/services/shcluster/status")
225
149
  except Exception as exc:
226
150
  if _is_not_enabled(exc):
227
- output.render(ctx, {"mode": "disabled", "detail": str(exc)})
151
+ output.render(ctx, {"mode": "disabled", "detail": _disabled_detail(exc)})
228
152
  return
229
153
  raise
230
154
 
@@ -258,7 +182,7 @@ def shcluster_health(ctx: click.Context) -> None:
258
182
  output.render(ctx, rows, empty="No SHC status available.")
259
183
 
260
184
 
261
- @server_group.command("deployment")
185
+ @click.command("deployment")
262
186
  @click.pass_context
263
187
  def deployment_health(ctx: click.Context) -> None:
264
188
  """Deployment server clients — count, last check-in.
@@ -273,7 +197,7 @@ def deployment_health(ctx: click.Context) -> None:
273
197
  body = _rest_get(svc, "/services/deployment/server/clients")
274
198
  except Exception as exc:
275
199
  if _is_not_enabled(exc):
276
- output.render(ctx, {"status": "disabled", "detail": str(exc)})
200
+ output.render(ctx, {"status": "disabled", "detail": _disabled_detail(exc)})
277
201
  return
278
202
  raise
279
203
 
@@ -300,3 +224,89 @@ def deployment_health(ctx: click.Context) -> None:
300
224
  )
301
225
 
302
226
  output.render(ctx, rows)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # splunkd health report & distributed-search peers
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ def _reason_text(node: dict[str, Any]) -> str:
235
+ """Join a health node's nested reason strings, if any."""
236
+ reasons = node.get("reasons")
237
+ if not isinstance(reasons, dict):
238
+ return ""
239
+ texts: list[str] = []
240
+ for bucket in reasons.values():
241
+ if not isinstance(bucket, dict):
242
+ continue
243
+ for item in bucket.values():
244
+ if isinstance(item, dict) and item.get("reason"):
245
+ texts.append(str(item["reason"]))
246
+ return "; ".join(texts)
247
+
248
+
249
+ def _walk_health(node: dict[str, Any], label: str, rows: list[dict[str, Any]]) -> None:
250
+ """Flatten the health feature tree into one row per component."""
251
+ rows.append(
252
+ {
253
+ "component": label,
254
+ "health": node.get("health", ""),
255
+ "reasons": _reason_text(node),
256
+ }
257
+ )
258
+ features = node.get("features") or {}
259
+ for child_name, child in sorted(features.items()):
260
+ if not isinstance(child, dict):
261
+ continue
262
+ child_label = child_name if label == "splunkd" else f"{label} / {child_name}"
263
+ _walk_health(child, child_label, rows)
264
+
265
+
266
+ @click.command("health")
267
+ @click.pass_context
268
+ def health_report(ctx: click.Context) -> None:
269
+ """Component-level splunkd health report (scheduler, disk, KV store...).
270
+
271
+ Flattens the health-report feature tree into one row per component;
272
+ nested feature names join with " / ". This command reports — it does
273
+ not gate: red components still exit 0.
274
+ """
275
+ client = get_client(ctx)
276
+ body = _rest_get(client.service, "/services/server/health/splunkd/details")
277
+ entries: list[dict[str, Any]] = body.get("entry", [])
278
+ rows: list[dict[str, Any]] = []
279
+ if entries:
280
+ _walk_health(entries[0].get("content", {}), "splunkd", rows)
281
+ output.render(ctx, rows, empty="No health report available.")
282
+
283
+
284
+ @click.command("search-peers")
285
+ @click.pass_context
286
+ def search_peers(ctx: click.Context) -> None:
287
+ """Distributed-search peers — status, replication, version.
288
+
289
+ Lists every search peer this instance fans searches out to. Empty on
290
+ a standalone instance (exit 0).
291
+ """
292
+ client = get_client(ctx)
293
+ try:
294
+ body = _rest_get(client.service, "/services/search/distributed/peers", count=-1)
295
+ except Exception as exc:
296
+ if _is_not_enabled(exc):
297
+ output.render(ctx, {"status": "disabled", "detail": _disabled_detail(exc)})
298
+ return
299
+ raise
300
+
301
+ rows: list[dict[str, Any]] = []
302
+ for entry in body.get("entry", []):
303
+ c: dict[str, Any] = entry.get("content", {})
304
+ rows.append(
305
+ {
306
+ "peer": c.get("peerName", entry.get("name", "")),
307
+ "status": c.get("status", ""),
308
+ "replication_status": c.get("replicationStatus", ""),
309
+ "version": c.get("version", ""),
310
+ }
311
+ )
312
+ output.render(ctx, rows, empty="No search peers (standalone instance).")
@@ -9,10 +9,14 @@ import sys
9
9
  from typing import Any
10
10
 
11
11
  from mcp.server.fastmcp import Context, FastMCP
12
+ from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
12
13
  from mcp.types import ToolAnnotations
14
+ from pydantic import ConfigDict
13
15
 
16
+ from splunkctl import __version__
14
17
  from splunkctl.mcp.resources import load_guides
15
18
  from splunkctl.mcp.tools import (
19
+ SKIP_PARAMS,
16
20
  ToolEntry,
17
21
  ToolIndex,
18
22
  build_tool_index,
@@ -32,9 +36,7 @@ pass yes=true to apply (dry-run by default)."""
32
36
 
33
37
  _FORCE_FLAGS = ["--json"]
34
38
 
35
- _STRIP_PARAMS = frozenset(
36
- {"yes", "json", "debug", "format", "fields", "out", "config", "profile", "timeout"}
37
- )
39
+ _STRIP_PARAMS = SKIP_PARAMS
38
40
 
39
41
 
40
42
  def _exec_cli(args: list[str]) -> str:
@@ -63,17 +65,36 @@ def _split_command(raw: str) -> list[str]:
63
65
  return raw.split()
64
66
 
65
67
 
68
+ def _coerce_array(entry: ToolEntry, pname: str, value: Any) -> Any:
69
+ """Parse a JSON-encoded array string for array-typed params.
70
+
71
+ Some MCP clients serialize list arguments as JSON strings; the
72
+ pass-through arg model does no pre-parsing, so unwrap here.
73
+ """
74
+ prop = entry.schema.get("properties", {}).get(pname, {})
75
+ if prop.get("type") == "array" and isinstance(value, str):
76
+ try:
77
+ parsed = json.loads(value)
78
+ except json.JSONDecodeError:
79
+ return value
80
+ if isinstance(parsed, list):
81
+ return parsed
82
+ return value
83
+
84
+
66
85
  def _build_cli_args(entry: ToolEntry, params: dict[str, Any]) -> list[str]:
67
86
  """Convert typed tool parameters to CLI arg list."""
68
87
  args = list(entry.cmd_path)
69
- positional_values: list[str] = []
70
- for pname, value in params.items():
88
+ positional: dict[str, list[str]] = {}
89
+ for pname, raw in params.items():
71
90
  if pname in _STRIP_PARAMS:
72
- if pname == "yes" and value:
91
+ if pname == "yes" and raw:
73
92
  args.append("--yes")
74
93
  continue
94
+ value = _coerce_array(entry, pname, raw)
75
95
  if pname in entry.positional:
76
- positional_values.append(str(value))
96
+ items = value if isinstance(value, list) else [value]
97
+ positional[pname] = [str(v) for v in items]
77
98
  continue
78
99
  flag = f"--{pname.replace('_', '-')}"
79
100
  if isinstance(value, bool):
@@ -84,7 +105,10 @@ def _build_cli_args(entry: ToolEntry, params: dict[str, Any]) -> list[str]:
84
105
  args.extend([flag, str(item)])
85
106
  else:
86
107
  args.extend([flag, str(value)])
87
- args.extend(positional_values)
108
+ for pname in entry.arg_order:
109
+ args.extend(positional.pop(pname, []))
110
+ for leftovers in positional.values():
111
+ args.extend(leftovers)
88
112
  return args
89
113
 
90
114
 
@@ -96,6 +120,7 @@ def create_server() -> FastMCP:
96
120
  name="splunkctl",
97
121
  instructions=_INSTRUCTIONS,
98
122
  )
123
+ mcp._mcp_server.version = __version__
99
124
 
100
125
  from mcp.server.lowlevel.server import NotificationOptions
101
126
 
@@ -147,7 +172,7 @@ def create_server() -> FastMCP:
147
172
  if entry is None:
148
173
  return f"Unknown command: {command}"
149
174
 
150
- if tool_name not in focused.get("_usage", []):
175
+ if not any(tool_name in names for names in focused.values()):
151
176
  _register_tool(mcp, entry)
152
177
  focused.setdefault("_usage", []).append(tool_name)
153
178
  await ctx.session.send_tool_list_changed()
@@ -290,6 +315,23 @@ def create_server() -> FastMCP:
290
315
  return mcp
291
316
 
292
317
 
318
+ class _PassThroughArgs(ArgModelBase):
319
+ """Arg model that forwards arbitrary fields to the CLI unvalidated.
320
+
321
+ Focused tools advertise a Click-derived JSON Schema, but runtime
322
+ validation belongs to Click itself — the subprocess rejects bad args
323
+ with a usage error. FastMCP's default ``func_metadata`` would instead
324
+ validate against the runner's ``**kwargs`` signature, which no client
325
+ payload can ever satisfy.
326
+ """
327
+
328
+ model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
329
+
330
+ def model_dump_one_level(self) -> dict[str, Any]:
331
+ """Return the extra (client-sent) fields as-is."""
332
+ return dict(self.__pydantic_extra__ or {})
333
+
334
+
293
335
  def _register_tool(mcp: FastMCP, entry: ToolEntry) -> None:
294
336
  """Register a focused tool that executes via subprocess.
295
337
 
@@ -297,7 +339,6 @@ def _register_tool(mcp: FastMCP, entry: ToolEntry) -> None:
297
339
  Schema instead of FastMCP's auto-generation from function signatures.
298
340
  """
299
341
  from mcp.server.fastmcp.tools import Tool as MCPTool
300
- from mcp.server.fastmcp.utilities.func_metadata import func_metadata
301
342
 
302
343
  annotations = ToolAnnotations(
303
344
  readOnlyHint=not entry.guarded,
@@ -314,7 +355,7 @@ def _register_tool(mcp: FastMCP, entry: ToolEntry) -> None:
314
355
  title=None,
315
356
  description=entry.description,
316
357
  parameters=entry.schema,
317
- fn_metadata=func_metadata(_runner),
358
+ fn_metadata=FuncMetadata(arg_model=_PassThroughArgs),
318
359
  is_async=True,
319
360
  context_kwarg=None,
320
361
  annotations=annotations,
@@ -6,12 +6,13 @@ import click
6
6
 
7
7
  from splunkctl.guard import is_guarded
8
8
 
9
- _SKIP_FLAGS = frozenset(
9
+ SKIP_PARAMS = frozenset(
10
10
  {
11
11
  "help",
12
12
  "json",
13
13
  "use_json",
14
14
  "fmt",
15
+ "format",
15
16
  "fields",
16
17
  "out",
17
18
  "debug",
@@ -22,10 +23,16 @@ _SKIP_FLAGS = frozenset(
22
23
  }
23
24
  )
24
25
 
26
+ _YES_PROP: dict[str, Any] = {
27
+ "type": "boolean",
28
+ "default": False,
29
+ "description": "Apply the mutation (omit or false = dry-run preview).",
30
+ }
31
+
25
32
 
26
33
  def _param_schema(p: click.Parameter) -> dict[str, Any] | None:
27
34
  """Convert a Click parameter to a JSON Schema property."""
28
- if p.name in _SKIP_FLAGS or p.name == "help":
35
+ if p.name in SKIP_PARAMS or p.name == "help":
29
36
  return None
30
37
  if isinstance(p, click.Option) and p.hidden:
31
38
  return None
@@ -45,6 +52,12 @@ def _param_schema(p: click.Parameter) -> dict[str, Any] | None:
45
52
  else:
46
53
  prop["type"] = "string"
47
54
 
55
+ multi = (isinstance(p, click.Option) and p.multiple) or (
56
+ isinstance(p, click.Argument) and p.nargs == -1
57
+ )
58
+ if multi:
59
+ prop = {"type": "array", "items": prop}
60
+
48
61
  if isinstance(p, click.Option) and p.help:
49
62
  prop["description"] = p.help
50
63
  elif isinstance(p, click.Argument) and p.type.name != "TEXT":
@@ -67,7 +80,15 @@ type ToolIndex = dict[str, "ToolEntry"]
67
80
  class ToolEntry:
68
81
  """A single tool derived from a Click command."""
69
82
 
70
- __slots__ = ("name", "description", "schema", "cmd_path", "guarded", "positional")
83
+ __slots__ = (
84
+ "name",
85
+ "description",
86
+ "schema",
87
+ "cmd_path",
88
+ "guarded",
89
+ "positional",
90
+ "arg_order",
91
+ )
71
92
 
72
93
  def __init__( # noqa: D107
73
94
  self,
@@ -78,6 +99,7 @@ class ToolEntry:
78
99
  *,
79
100
  guarded: bool = False,
80
101
  positional: frozenset[str] = frozenset(),
102
+ arg_order: tuple[str, ...] = (),
81
103
  ) -> None:
82
104
  self.name = name
83
105
  self.description = description
@@ -85,6 +107,7 @@ class ToolEntry:
85
107
  self.cmd_path = cmd_path
86
108
  self.guarded = guarded
87
109
  self.positional = positional
110
+ self.arg_order = arg_order
88
111
 
89
112
 
90
113
  def _tool_name(path: list[str]) -> str:
@@ -92,6 +115,54 @@ def _tool_name(path: list[str]) -> str:
92
115
  return "_".join(seg.replace("-", "_") for seg in path)
93
116
 
94
117
 
118
+ def _make_entry(cmd: click.Command, path: list[str]) -> ToolEntry:
119
+ """Build a ToolEntry (schema, positional order, guard) for one command."""
120
+ properties: dict[str, Any] = {}
121
+ required: list[str] = []
122
+ pos: list[str] = []
123
+ for p in cmd.params:
124
+ if p.name is None:
125
+ continue
126
+ prop = _param_schema(p)
127
+ if prop is None:
128
+ continue
129
+ pname = p.name.replace("-", "_")
130
+ properties[pname] = prop
131
+ if isinstance(p, click.Argument):
132
+ required.append(pname)
133
+ pos.append(pname)
134
+ elif isinstance(p, click.Option) and (p.required or p.prompt):
135
+ # prompt=True options would hang the MCP subprocess waiting
136
+ # for TTY input, so force callers to always supply them.
137
+ required.append(pname)
138
+
139
+ guarded = is_guarded(cmd)
140
+ if guarded:
141
+ properties["yes"] = dict(_YES_PROP)
142
+
143
+ schema: dict[str, Any] = {
144
+ "type": "object",
145
+ "properties": properties,
146
+ "additionalProperties": False,
147
+ }
148
+ if required:
149
+ schema["required"] = required
150
+
151
+ desc = (cmd.help or "").split("\n")[0]
152
+ if guarded:
153
+ desc += " [guarded: dry-run by default, pass yes=true to apply]"
154
+
155
+ return ToolEntry(
156
+ name=_tool_name(path),
157
+ description=desc,
158
+ schema=schema,
159
+ cmd_path=path,
160
+ guarded=guarded,
161
+ positional=frozenset(pos),
162
+ arg_order=tuple(pos),
163
+ )
164
+
165
+
95
166
  def _walk_commands(
96
167
  group: click.Group,
97
168
  prefix: list[str],
@@ -103,45 +174,8 @@ def _walk_commands(
103
174
  if isinstance(cmd, click.Group):
104
175
  _walk_commands(cmd, path, index)
105
176
  continue
106
-
107
- properties: dict[str, Any] = {}
108
- required: list[str] = []
109
- pos: set[str] = set()
110
- for p in cmd.params:
111
- if p.name is None:
112
- continue
113
- prop = _param_schema(p)
114
- if prop is None:
115
- continue
116
- pname = p.name.replace("-", "_")
117
- properties[pname] = prop
118
- if isinstance(p, click.Argument):
119
- required.append(pname)
120
- pos.add(pname)
121
- elif isinstance(p, click.Option) and p.required:
122
- required.append(pname)
123
-
124
- schema: dict[str, Any] = {
125
- "type": "object",
126
- "properties": properties,
127
- }
128
- if required:
129
- schema["required"] = required
130
-
131
- guarded = is_guarded(cmd)
132
- desc = (cmd.help or "").split("\n")[0]
133
- if guarded:
134
- desc += " [guarded: dry-run by default, pass yes=true to apply]"
135
-
136
- tool_name = _tool_name(path)
137
- index[tool_name] = ToolEntry(
138
- name=tool_name,
139
- description=desc,
140
- schema=schema,
141
- cmd_path=path,
142
- guarded=guarded,
143
- positional=frozenset(pos),
144
- )
177
+ entry = _make_entry(cmd, path)
178
+ index[entry.name] = entry
145
179
 
146
180
 
147
181
  def build_tool_index(root: click.Group) -> ToolIndex:
@@ -157,42 +191,8 @@ def build_tool_index(root: click.Group) -> ToolIndex:
157
191
  if isinstance(cmd, click.Group):
158
192
  _walk_commands(cmd, [name], index)
159
193
  else:
160
- path = [name]
161
- properties: dict[str, Any] = {}
162
- required: list[str] = []
163
- pos: set[str] = set()
164
- for p in cmd.params:
165
- if p.name is None:
166
- continue
167
- prop = _param_schema(p)
168
- if prop is None:
169
- continue
170
- pname = p.name.replace("-", "_")
171
- properties[pname] = prop
172
- if isinstance(p, click.Argument):
173
- required.append(pname)
174
- pos.add(pname)
175
- elif isinstance(p, click.Option) and p.required:
176
- required.append(pname)
177
- schema: dict[str, Any] = {
178
- "type": "object",
179
- "properties": properties,
180
- }
181
- if required:
182
- schema["required"] = required
183
- guarded = is_guarded(cmd)
184
- desc = (cmd.help or "").split("\n")[0]
185
- if guarded:
186
- desc += " [guarded: dry-run by default, pass yes=true to apply]"
187
- tool_name = _tool_name(path)
188
- index[tool_name] = ToolEntry(
189
- name=tool_name,
190
- description=desc,
191
- schema=schema,
192
- cmd_path=path,
193
- guarded=guarded,
194
- positional=frozenset(pos),
195
- )
194
+ entry = _make_entry(cmd, [name])
195
+ index[entry.name] = entry
196
196
  return index
197
197
 
198
198
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -44,6 +44,7 @@ splunkctl/commands/rules.py
44
44
  splunkctl/commands/rules_io.py
45
45
  splunkctl/commands/search.py
46
46
  splunkctl/commands/server.py
47
+ splunkctl/commands/server_topology.py
47
48
  splunkctl/commands/state.py
48
49
  splunkctl/commands/state_io.py
49
50
  splunkctl/commands/state_io_blobs.py
@@ -4,4 +4,4 @@ from splunkctl import __version__
4
4
 
5
5
 
6
6
  def test_version() -> None:
7
- assert __version__ == "0.5.0"
7
+ assert __version__ == "0.6.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes