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.
Files changed (68) hide show
  1. {splunkctl-0.5.0 → splunkctl-0.7.0}/PKG-INFO +1 -1
  2. {splunkctl-0.5.0 → splunkctl-0.7.0}/pyproject.toml +1 -1
  3. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/__init__.py +1 -1
  4. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/config_cmd.py +88 -14
  5. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/knowledge.py +21 -7
  6. splunkctl-0.7.0/splunkctl/commands/server.py +179 -0
  7. splunkctl-0.5.0/splunkctl/commands/server.py → splunkctl-0.7.0/splunkctl/commands/server_topology.py +125 -115
  8. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/config.py +90 -3
  9. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/errors.py +6 -0
  10. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/guard.py +30 -0
  11. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/main.py +2 -0
  12. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/server.py +52 -11
  13. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/tools.py +78 -78
  14. splunkctl-0.7.0/splunkctl/soar/__init__.py +0 -0
  15. splunkctl-0.7.0/splunkctl/soar/client.py +377 -0
  16. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/PKG-INFO +1 -1
  17. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/SOURCES.txt +3 -0
  18. {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_config.py +195 -0
  19. {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_version.py +1 -1
  20. {splunkctl-0.5.0 → splunkctl-0.7.0}/LICENSE +0 -0
  21. {splunkctl-0.5.0 → splunkctl-0.7.0}/README.md +0 -0
  22. {splunkctl-0.5.0 → splunkctl-0.7.0}/setup.cfg +0 -0
  23. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/__main__.py +0 -0
  24. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/client.py +0 -0
  25. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/__init__.py +0 -0
  26. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/alerts.py +0 -0
  27. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/apps.py +0 -0
  28. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/audit.py +0 -0
  29. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/audit_parse.py +0 -0
  30. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/commands_meta.py +0 -0
  31. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/common.py +0 -0
  32. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/conf.py +0 -0
  33. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/conf_ops.py +0 -0
  34. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/dashboards.py +0 -0
  35. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/datamodels.py +0 -0
  36. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/doctor.py +0 -0
  37. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/es.py +0 -0
  38. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/hec.py +0 -0
  39. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/indexes.py +0 -0
  40. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/info.py +0 -0
  41. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/inputs.py +0 -0
  42. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/kvstore.py +0 -0
  43. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/lookups.py +0 -0
  44. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/lookups_wiring.py +0 -0
  45. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/mcp_cmd.py +0 -0
  46. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/parsers.py +0 -0
  47. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/parsers_io.py +0 -0
  48. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/rules.py +0 -0
  49. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/rules_io.py +0 -0
  50. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/search.py +0 -0
  51. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state.py +0 -0
  52. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_io.py +0 -0
  53. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_io_blobs.py +0 -0
  54. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_io_confs.py +0 -0
  55. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/state_types.py +0 -0
  56. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/commands/users.py +0 -0
  57. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/__init__.py +0 -0
  58. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/mcp/resources.py +0 -0
  59. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl/output.py +0 -0
  60. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  61. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/entry_points.txt +0 -0
  62. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/requires.txt +0 -0
  63. {splunkctl-0.5.0 → splunkctl-0.7.0}/splunkctl.egg-info/top_level.txt +0 -0
  64. {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_client.py +0 -0
  65. {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_guard.py +0 -0
  66. {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_main_errors.py +0 -0
  67. {splunkctl-0.5.0 → splunkctl-0.7.0}/tests/test_main_hoisting.py +0 -0
  68. {splunkctl-0.5.0 → splunkctl-0.7.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.7.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.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__" }
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise SIEM operations."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -17,17 +17,24 @@ def config_group() -> None:
17
17
 
18
18
 
19
19
  @config_group.command()
20
- @click.option("--host", prompt=True, default="localhost", help="Splunk host.")
21
- @click.option("--port", prompt=True, default=8089, type=int, help="Splunk port.")
22
- @click.option("--username", prompt=True, default="admin", help="Splunk username.")
23
- @click.option("--password", prompt=True, hide_input=True, help="Splunk 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="https",
27
+ default=None,
28
28
  help="Connection scheme.",
29
29
  )
30
- @click.option("--verify/--no-verify", default=False, help="Verify SSL certificate.")
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
- host: str,
49
- port: int,
50
- username: str,
51
- password: str,
52
- scheme: str,
53
- verify: bool,
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 = {"profile": resolved["profile"], **cfg_mod.redact(resolved["cfg"])}
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
- 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)