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.
- {splunkctl-0.5.0 → splunkctl-0.6.0}/PKG-INFO +1 -1
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/knowledge.py +21 -7
- splunkctl-0.6.0/splunkctl/commands/server.py +179 -0
- splunkctl-0.5.0/splunkctl/commands/server.py → splunkctl-0.6.0/splunkctl/commands/server_topology.py +125 -115
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/server.py +52 -11
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/tools.py +78 -78
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/PKG-INFO +1 -1
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/SOURCES.txt +1 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_version.py +1 -1
- {splunkctl-0.5.0 → splunkctl-0.6.0}/LICENSE +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/README.md +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/pyproject.toml +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/setup.cfg +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/client.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/config_cmd.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/es.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/config.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/errors.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/guard.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/main.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl/output.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_client.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_config.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_guard.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_main_errors.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.6.0}/tests/test_output.py +0 -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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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)
|
splunkctl-0.5.0/splunkctl/commands/server.py → splunkctl-0.6.0/splunkctl/commands/server_topology.py
RENAMED
|
@@ -1,23 +1,14 @@
|
|
|
1
|
-
"""Server
|
|
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
|
|
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
|
|
39
|
-
"""
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
@
|
|
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":
|
|
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(
|
|
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
|
-
@
|
|
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":
|
|
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
|
-
@
|
|
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":
|
|
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 =
|
|
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
|
-
|
|
70
|
-
for pname,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
|
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__ = (
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|