splunkctl 0.5.0__tar.gz → 0.7.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.7.0}/PKG-INFO +1 -1
- {splunkctl-0.5.0 → splunkctl-0.7.0}/pyproject.toml +1 -1
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/__init__.py +1 -1
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/config_cmd.py +88 -14
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/knowledge.py +21 -7
- splunkctl-0.7.0/splunkctl/commands/server.py +179 -0
- splunkctl-0.5.0/splunkctl/commands/server.py → splunkctl-0.7.0/splunkctl/commands/server_topology.py +125 -115
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/config.py +90 -3
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/errors.py +6 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/guard.py +30 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/main.py +2 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/server.py +52 -11
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/tools.py +78 -78
- splunkctl-0.7.0/splunkctl/soar/__init__.py +0 -0
- splunkctl-0.7.0/splunkctl/soar/client.py +377 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/PKG-INFO +1 -1
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/SOURCES.txt +3 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_config.py +195 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_version.py +1 -1
- {splunkctl-0.5.0 → splunkctl-0.7.0}/LICENSE +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/README.md +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/setup.cfg +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/__main__.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/client.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/__init__.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/alerts.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/apps.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/audit.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/audit_parse.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/commands_meta.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/common.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/conf.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/conf_ops.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/dashboards.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/datamodels.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/doctor.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/es.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/hec.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/indexes.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/info.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/inputs.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/kvstore.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/lookups.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/lookups_wiring.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/mcp_cmd.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/parsers.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/parsers_io.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/rules.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/rules_io.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/search.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_io.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_io_blobs.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_io_confs.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_types.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/users.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/__init__.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/resources.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/output.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/dependency_links.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/entry_points.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/requires.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/top_level.txt +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_client.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_guard.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_main_errors.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_main_hoisting.py +0 -0
- {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_output.py +0 -0
|
@@ -52,7 +52,7 @@ Repository = "https://github.com/dannyota/splunkctl"
|
|
|
52
52
|
Issues = "https://github.com/dannyota/splunkctl/issues"
|
|
53
53
|
|
|
54
54
|
[tool.setuptools]
|
|
55
|
-
packages = ["splunkctl", "splunkctl.commands", "splunkctl.mcp"]
|
|
55
|
+
packages = ["splunkctl", "splunkctl.commands", "splunkctl.mcp", "splunkctl.soar"]
|
|
56
56
|
|
|
57
57
|
[tool.setuptools.dynamic]
|
|
58
58
|
version = { attr = "splunkctl.__version__" }
|
|
@@ -17,17 +17,24 @@ def config_group() -> None:
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
@config_group.command()
|
|
20
|
-
@click.option("--host",
|
|
21
|
-
@click.option("--port",
|
|
22
|
-
@click.option("--username",
|
|
23
|
-
@click.option("--password",
|
|
20
|
+
@click.option("--host", default=None, help="Splunk host.")
|
|
21
|
+
@click.option("--port", default=None, type=int, help="Splunk port.")
|
|
22
|
+
@click.option("--username", default=None, help="Splunk username.")
|
|
23
|
+
@click.option("--password", default=None, help="Splunk password.")
|
|
24
24
|
@click.option(
|
|
25
25
|
"--scheme",
|
|
26
26
|
type=click.Choice(["https", "http"]),
|
|
27
|
-
default=
|
|
27
|
+
default=None,
|
|
28
28
|
help="Connection scheme.",
|
|
29
29
|
)
|
|
30
|
-
@click.option("--verify/--no-verify", default=
|
|
30
|
+
@click.option("--verify/--no-verify", default=None, help="Verify SSL certificate.")
|
|
31
|
+
@click.option(
|
|
32
|
+
"--soar",
|
|
33
|
+
"soar_mode",
|
|
34
|
+
is_flag=True,
|
|
35
|
+
default=False,
|
|
36
|
+
help="Add SOAR connection settings to the profile.",
|
|
37
|
+
)
|
|
31
38
|
@click.option(
|
|
32
39
|
"--profile",
|
|
33
40
|
"profile_name",
|
|
@@ -44,13 +51,16 @@ def config_group() -> None:
|
|
|
44
51
|
default=None,
|
|
45
52
|
help="Config file path (default: ~/.splunkctl/config.yaml).",
|
|
46
53
|
)
|
|
54
|
+
@click.pass_context
|
|
47
55
|
def init(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
ctx: click.Context,
|
|
57
|
+
host: str | None,
|
|
58
|
+
port: int | None,
|
|
59
|
+
username: str | None,
|
|
60
|
+
password: str | None,
|
|
61
|
+
scheme: str | None,
|
|
62
|
+
verify: bool | None,
|
|
63
|
+
soar_mode: bool,
|
|
54
64
|
profile_name: str | None,
|
|
55
65
|
path: str | None,
|
|
56
66
|
) -> None:
|
|
@@ -63,7 +73,28 @@ def init(
|
|
|
63
73
|
never clobbers an existing multi-profile file.
|
|
64
74
|
``config init --profile <name>`` targets that named profile instead —
|
|
65
75
|
use ``config use <name>`` afterwards to make it active.
|
|
76
|
+
|
|
77
|
+
``config init --soar`` prompts for SOAR connection settings and saves
|
|
78
|
+
them as a nested ``soar:`` map within the profile, preserving existing
|
|
79
|
+
SIEM fields.
|
|
66
80
|
"""
|
|
81
|
+
dest = Path(path) if path else None
|
|
82
|
+
if soar_mode:
|
|
83
|
+
_init_soar(profile_name, dest)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Prompt for SIEM fields not supplied via flags.
|
|
87
|
+
host = host or click.prompt("Host", default="localhost")
|
|
88
|
+
port = port or click.prompt("Port", default=8089, type=int)
|
|
89
|
+
username = username or click.prompt("Username", default="admin")
|
|
90
|
+
password = (
|
|
91
|
+
password
|
|
92
|
+
if password is not None
|
|
93
|
+
else click.prompt("Password", default="", hide_input=True)
|
|
94
|
+
)
|
|
95
|
+
scheme = scheme or "https"
|
|
96
|
+
verify = verify if verify is not None else False
|
|
97
|
+
|
|
67
98
|
cfg: dict[str, Any] = {
|
|
68
99
|
"host": host,
|
|
69
100
|
"port": port,
|
|
@@ -72,7 +103,6 @@ def init(
|
|
|
72
103
|
"scheme": scheme,
|
|
73
104
|
"verify": verify,
|
|
74
105
|
}
|
|
75
|
-
dest = Path(path) if path else None
|
|
76
106
|
if profile_name:
|
|
77
107
|
saved = cfg_mod.save_profile(cfg, profile_name, dest)
|
|
78
108
|
elif cfg_mod.is_v2_file(dest):
|
|
@@ -82,6 +112,40 @@ def init(
|
|
|
82
112
|
output.info(f"Config saved to {saved}")
|
|
83
113
|
|
|
84
114
|
|
|
115
|
+
def _init_soar(
|
|
116
|
+
profile_name: str | None,
|
|
117
|
+
dest: Path | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Prompt for SOAR fields and merge into the target profile."""
|
|
120
|
+
soar_host = click.prompt("SOAR host", type=str)
|
|
121
|
+
soar_port = click.prompt("SOAR port", default=8443, type=int)
|
|
122
|
+
soar_token = click.prompt("SOAR token", default="", type=str)
|
|
123
|
+
soar_user = click.prompt("SOAR username", default="", type=str)
|
|
124
|
+
soar_pass = click.prompt("SOAR password", default="", hide_input=True, type=str)
|
|
125
|
+
|
|
126
|
+
soar_cfg: dict[str, Any] = {"host": soar_host, "port": soar_port}
|
|
127
|
+
if soar_token:
|
|
128
|
+
soar_cfg["token"] = soar_token
|
|
129
|
+
if soar_user:
|
|
130
|
+
soar_cfg["username"] = soar_user
|
|
131
|
+
if soar_pass:
|
|
132
|
+
soar_cfg["password"] = soar_pass
|
|
133
|
+
|
|
134
|
+
# Resolve which profile to update (same precedence as resolve()).
|
|
135
|
+
config_path = dest or cfg_mod.DEFAULT_PATH
|
|
136
|
+
raw = cfg_mod._read_raw(config_path) # noqa: SLF001
|
|
137
|
+
name = cfg_mod._active_profile_name(raw, profile_name) # noqa: SLF001
|
|
138
|
+
|
|
139
|
+
# Load existing SIEM fields (if any) and attach the new soar: map.
|
|
140
|
+
try:
|
|
141
|
+
existing = dict(cfg_mod._profile_config(raw, name)) # noqa: SLF001
|
|
142
|
+
except cfg_mod.ProfileNotFoundError:
|
|
143
|
+
existing = {}
|
|
144
|
+
existing["soar"] = soar_cfg
|
|
145
|
+
saved = cfg_mod.save_profile(existing, name, dest)
|
|
146
|
+
output.info(f"SOAR config saved to {saved}")
|
|
147
|
+
|
|
148
|
+
|
|
85
149
|
@config_group.command()
|
|
86
150
|
@click.pass_context
|
|
87
151
|
def show(ctx: click.Context) -> None:
|
|
@@ -97,7 +161,17 @@ def show(ctx: click.Context) -> None:
|
|
|
97
161
|
explicit_profile = obj.get("profile")
|
|
98
162
|
|
|
99
163
|
resolved = cfg_mod.resolve(config_path, profile=explicit_profile)
|
|
100
|
-
payload
|
|
164
|
+
payload: dict[str, Any] = {
|
|
165
|
+
"profile": resolved["profile"],
|
|
166
|
+
**cfg_mod.redact(resolved["cfg"]),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Include redacted SOAR section if the profile has one.
|
|
170
|
+
soar = cfg_mod.resolve_soar(config_path, profile=explicit_profile)
|
|
171
|
+
# Only include soar: if the profile actually configured it (has a host).
|
|
172
|
+
if "host" in soar:
|
|
173
|
+
payload["soar"] = cfg_mod.redact_soar(soar)
|
|
174
|
+
|
|
101
175
|
output.render(ctx, payload)
|
|
102
176
|
|
|
103
177
|
if explicit_profile is None:
|
|
@@ -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)
|