splunkctl 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- splunkctl/__main__.py +5 -0
- splunkctl/client.py +103 -0
- splunkctl/commands/__init__.py +0 -0
- splunkctl/commands/alerts.py +94 -0
- splunkctl/commands/apps.py +189 -0
- splunkctl/commands/commands_meta.py +72 -0
- splunkctl/commands/config_cmd.py +87 -0
- splunkctl/commands/dashboards.py +216 -0
- splunkctl/commands/indexes.py +199 -0
- splunkctl/commands/info.py +29 -0
- splunkctl/commands/inputs.py +204 -0
- splunkctl/commands/lookups.py +217 -0
- splunkctl/commands/parsers.py +177 -0
- splunkctl/commands/rules.py +266 -0
- splunkctl/commands/search.py +270 -0
- splunkctl/commands/skill_cmd.py +43 -0
- splunkctl/commands/users.py +211 -0
- splunkctl/config.py +82 -0
- splunkctl/guard.py +22 -0
- splunkctl/main.py +149 -0
- splunkctl/output.py +75 -0
- splunkctl/skill/SKILL.md +325 -0
- splunkctl-0.1.0.dist-info/METADATA +118 -0
- splunkctl-0.1.0.dist-info/RECORD +29 -0
- splunkctl-0.1.0.dist-info/WHEEL +5 -0
- splunkctl-0.1.0.dist-info/entry_points.txt +2 -0
- splunkctl-0.1.0.dist-info/licenses/LICENSE +190 -0
- splunkctl-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Search commands — run, export, oneshot, jobs, job, cancel."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from splunklib.results import JSONResultsReader
|
|
8
|
+
|
|
9
|
+
from splunkctl import guard, output
|
|
10
|
+
from splunkctl.client import get_client
|
|
11
|
+
|
|
12
|
+
_GENERATING = (
|
|
13
|
+
"abstract",
|
|
14
|
+
"bucket",
|
|
15
|
+
"datamodel",
|
|
16
|
+
"dbinspect",
|
|
17
|
+
"eventcount",
|
|
18
|
+
"inputcsv",
|
|
19
|
+
"inputlookup",
|
|
20
|
+
"loadjob",
|
|
21
|
+
"makeresults",
|
|
22
|
+
"mcollect",
|
|
23
|
+
"metadata",
|
|
24
|
+
"metasearch",
|
|
25
|
+
"mstats",
|
|
26
|
+
"pivot",
|
|
27
|
+
"rest",
|
|
28
|
+
"savedsearch",
|
|
29
|
+
"tstats",
|
|
30
|
+
"typeahead",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_spl(spl: str) -> str:
|
|
35
|
+
"""Auto-prepend ``search`` when the query needs it."""
|
|
36
|
+
stripped = spl.strip()
|
|
37
|
+
if stripped.startswith("|"):
|
|
38
|
+
return stripped
|
|
39
|
+
first_word = stripped.split()[0].lower() if stripped else ""
|
|
40
|
+
if first_word == "search" or first_word in _GENERATING:
|
|
41
|
+
return stripped
|
|
42
|
+
return f"search {stripped}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _time_kwargs(
|
|
46
|
+
earliest: str | None,
|
|
47
|
+
latest: str | None,
|
|
48
|
+
) -> dict[str, str]:
|
|
49
|
+
"""Build SDK time-range kwargs."""
|
|
50
|
+
kw: dict[str, str] = {}
|
|
51
|
+
if earliest:
|
|
52
|
+
kw["earliest_time"] = earliest
|
|
53
|
+
if latest:
|
|
54
|
+
kw["latest_time"] = latest
|
|
55
|
+
return kw
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_results(stream: Any) -> list[dict[str, Any]]:
|
|
59
|
+
"""Parse a Splunk results stream into a list of dicts."""
|
|
60
|
+
reader: Any = JSONResultsReader(stream)
|
|
61
|
+
rows: list[dict[str, Any]] = []
|
|
62
|
+
for item in reader:
|
|
63
|
+
if isinstance(item, dict):
|
|
64
|
+
rows.append(item)
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.group("search")
|
|
69
|
+
def search_group() -> None:
|
|
70
|
+
"""Search and job management."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@search_group.command("run")
|
|
74
|
+
@click.argument("spl")
|
|
75
|
+
@click.option("--earliest", default=None, help="Earliest time (e.g. -24h, -7d).")
|
|
76
|
+
@click.option("--latest", default=None, help="Latest time (e.g. now).")
|
|
77
|
+
@click.option("--limit", default=100, type=int, help="Max results (default 100).")
|
|
78
|
+
@click.option("--app", default=None, help="Splunk app context.")
|
|
79
|
+
@click.pass_context
|
|
80
|
+
def run_search(
|
|
81
|
+
ctx: click.Context,
|
|
82
|
+
spl: str,
|
|
83
|
+
earliest: str | None,
|
|
84
|
+
latest: str | None,
|
|
85
|
+
limit: int,
|
|
86
|
+
app: str | None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Run a search synchronously and print results."""
|
|
89
|
+
client = get_client(ctx)
|
|
90
|
+
svc = client.service
|
|
91
|
+
if app:
|
|
92
|
+
svc.namespace["app"] = app
|
|
93
|
+
query = _normalize_spl(spl)
|
|
94
|
+
|
|
95
|
+
timeout: int = ctx.obj.get("timeout", 30)
|
|
96
|
+
kwargs: dict[str, Any] = {
|
|
97
|
+
"exec_mode": "normal",
|
|
98
|
+
**_time_kwargs(earliest, latest),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
output.info(f"Running: {query}")
|
|
102
|
+
job: Any = svc.jobs.create(query, **kwargs)
|
|
103
|
+
|
|
104
|
+
deadline = time.monotonic() + timeout
|
|
105
|
+
while not job.is_done():
|
|
106
|
+
if time.monotonic() > deadline:
|
|
107
|
+
job.cancel()
|
|
108
|
+
output.error(f"Search timed out after {timeout}s.")
|
|
109
|
+
ctx.exit(1)
|
|
110
|
+
return
|
|
111
|
+
time.sleep(0.5)
|
|
112
|
+
job.refresh()
|
|
113
|
+
|
|
114
|
+
rows = _read_results(job.results(output_mode="json", count=limit))
|
|
115
|
+
output.render(ctx, rows)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@search_group.command("export")
|
|
119
|
+
@click.argument("spl")
|
|
120
|
+
@click.option("--earliest", default=None, help="Earliest time.")
|
|
121
|
+
@click.option("--latest", default=None, help="Latest time.")
|
|
122
|
+
@click.option("--app", default=None, help="Splunk app context.")
|
|
123
|
+
@click.pass_context
|
|
124
|
+
def export_search(
|
|
125
|
+
ctx: click.Context,
|
|
126
|
+
spl: str,
|
|
127
|
+
earliest: str | None,
|
|
128
|
+
latest: str | None,
|
|
129
|
+
app: str | None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Streaming export for large result sets."""
|
|
132
|
+
client = get_client(ctx)
|
|
133
|
+
svc = client.service
|
|
134
|
+
if app:
|
|
135
|
+
svc.namespace["app"] = app
|
|
136
|
+
query = _normalize_spl(spl)
|
|
137
|
+
|
|
138
|
+
kwargs: dict[str, Any] = {
|
|
139
|
+
"output_mode": "json",
|
|
140
|
+
**_time_kwargs(earliest, latest),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
output.info(f"Exporting: {query}")
|
|
144
|
+
stream: Any = svc.jobs.export(query, **kwargs)
|
|
145
|
+
rows = _read_results(stream)
|
|
146
|
+
output.render(ctx, rows)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@search_group.command("oneshot")
|
|
150
|
+
@click.argument("spl")
|
|
151
|
+
@click.option("--earliest", default=None, help="Earliest time.")
|
|
152
|
+
@click.option("--latest", default=None, help="Latest time.")
|
|
153
|
+
@click.option("--limit", default=100, type=int, help="Max results (default 100).")
|
|
154
|
+
@click.option("--app", default=None, help="Splunk app context.")
|
|
155
|
+
@click.pass_context
|
|
156
|
+
def oneshot_search(
|
|
157
|
+
ctx: click.Context,
|
|
158
|
+
spl: str,
|
|
159
|
+
earliest: str | None,
|
|
160
|
+
latest: str | None,
|
|
161
|
+
limit: int,
|
|
162
|
+
app: str | None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Quick one-off search."""
|
|
165
|
+
client = get_client(ctx)
|
|
166
|
+
svc = client.service
|
|
167
|
+
if app:
|
|
168
|
+
svc.namespace["app"] = app
|
|
169
|
+
query = _normalize_spl(spl)
|
|
170
|
+
|
|
171
|
+
kwargs: dict[str, Any] = {
|
|
172
|
+
"output_mode": "json",
|
|
173
|
+
"count": limit,
|
|
174
|
+
**_time_kwargs(earliest, latest),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
output.info(f"Oneshot: {query}")
|
|
178
|
+
stream: Any = svc.jobs.oneshot(query, **kwargs)
|
|
179
|
+
rows = _read_results(stream)
|
|
180
|
+
output.render(ctx, rows)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@search_group.command("jobs")
|
|
184
|
+
@click.pass_context
|
|
185
|
+
def list_jobs(ctx: click.Context) -> None:
|
|
186
|
+
"""List running and recent search jobs."""
|
|
187
|
+
client = get_client(ctx)
|
|
188
|
+
svc = client.service
|
|
189
|
+
|
|
190
|
+
rows: list[dict[str, Any]] = []
|
|
191
|
+
for job in svc.jobs:
|
|
192
|
+
content: dict[str, Any] = dict(job.content)
|
|
193
|
+
dur = content.get("runDuration", "")
|
|
194
|
+
if isinstance(dur, (float, str)):
|
|
195
|
+
try:
|
|
196
|
+
dur = f"{float(dur):.3f}"
|
|
197
|
+
except (ValueError, TypeError):
|
|
198
|
+
pass
|
|
199
|
+
rows.append(
|
|
200
|
+
{
|
|
201
|
+
"sid": job.sid,
|
|
202
|
+
"status": content.get("dispatchState", ""),
|
|
203
|
+
"earliest": content.get("earliestTime", ""),
|
|
204
|
+
"latest": content.get("latestTime", ""),
|
|
205
|
+
"event_count": content.get("eventCount", 0),
|
|
206
|
+
"run_duration": dur,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
output.render(ctx, rows)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@search_group.command("job")
|
|
213
|
+
@click.argument("sid")
|
|
214
|
+
@click.pass_context
|
|
215
|
+
def get_job(ctx: click.Context, sid: str) -> None:
|
|
216
|
+
"""Get status and results for a specific job."""
|
|
217
|
+
client = get_client(ctx)
|
|
218
|
+
svc = client.service
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
job: Any = svc.jobs[sid]
|
|
222
|
+
except KeyError:
|
|
223
|
+
output.error(f"Job '{sid}' not found.")
|
|
224
|
+
ctx.exit(1)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
job.refresh()
|
|
228
|
+
content: dict[str, Any] = dict(job.content)
|
|
229
|
+
done: bool = job.is_done()
|
|
230
|
+
|
|
231
|
+
status: dict[str, Any] = {
|
|
232
|
+
"sid": job.sid,
|
|
233
|
+
"status": content.get("dispatchState", ""),
|
|
234
|
+
"earliest": content.get("earliestTime", ""),
|
|
235
|
+
"latest": content.get("latestTime", ""),
|
|
236
|
+
"event_count": content.get("eventCount", 0),
|
|
237
|
+
"result_count": content.get("resultCount", 0),
|
|
238
|
+
"run_duration": content.get("runDuration", ""),
|
|
239
|
+
"is_done": done,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if done:
|
|
243
|
+
rows = _read_results(job.results(output_mode="json"))
|
|
244
|
+
if rows:
|
|
245
|
+
output.info(f"Job {sid}: DONE - {len(rows)} result(s)")
|
|
246
|
+
output.render(ctx, rows)
|
|
247
|
+
return
|
|
248
|
+
output.render(ctx, status)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@search_group.command("cancel")
|
|
252
|
+
@click.argument("sid")
|
|
253
|
+
@click.pass_context
|
|
254
|
+
def cancel_job(ctx: click.Context, sid: str) -> None:
|
|
255
|
+
"""Cancel a running search job."""
|
|
256
|
+
if not guard.check(ctx, f"Cancel job '{sid}'"):
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
client = get_client(ctx)
|
|
260
|
+
svc = client.service
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
job: Any = svc.jobs[sid]
|
|
264
|
+
except KeyError:
|
|
265
|
+
output.error(f"Job '{sid}' not found.")
|
|
266
|
+
ctx.exit(1)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
job.cancel()
|
|
270
|
+
output.info(f"Job '{sid}' cancelled.")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Skill commands — print or install the embedded SKILL.md."""
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from splunkctl import output
|
|
9
|
+
|
|
10
|
+
_INSTALL_DIR = Path.home() / ".claude" / "skills" / "splunkctl"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_skill() -> str:
|
|
14
|
+
"""Read SKILL.md from the installed package."""
|
|
15
|
+
try:
|
|
16
|
+
ref = importlib.resources.files("splunkctl.skill").joinpath("SKILL.md")
|
|
17
|
+
return ref.read_text(encoding="utf-8")
|
|
18
|
+
except FileNotFoundError:
|
|
19
|
+
output.error("SKILL.md not found in package. Reinstall splunkctl.")
|
|
20
|
+
raise SystemExit(1) from None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group("skill", invoke_without_command=True)
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def skill_group(ctx: click.Context) -> None:
|
|
26
|
+
"""Print or install the embedded agent skill guide."""
|
|
27
|
+
if ctx.invoked_subcommand is None:
|
|
28
|
+
click.echo(_load_skill(), nl=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@skill_group.command("print")
|
|
32
|
+
def skill_print() -> None:
|
|
33
|
+
"""Print SKILL.md to stdout."""
|
|
34
|
+
click.echo(_load_skill(), nl=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@skill_group.command("install")
|
|
38
|
+
def skill_install() -> None:
|
|
39
|
+
"""Install SKILL.md to ~/.claude/skills/splunkctl/."""
|
|
40
|
+
_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
dest = _INSTALL_DIR / "SKILL.md"
|
|
42
|
+
dest.write_text(_load_skill(), encoding="utf-8")
|
|
43
|
+
output.info(f"Installed to {dest}")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""User and role management commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from splunkctl import guard, output
|
|
8
|
+
from splunkctl.client import get_client
|
|
9
|
+
|
|
10
|
+
_MAX_CAPS = 5
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _user_row(user: Any) -> dict[str, Any]:
|
|
14
|
+
c: dict[str, Any] = user.content
|
|
15
|
+
roles = c.get("roles", [])
|
|
16
|
+
if isinstance(roles, str):
|
|
17
|
+
roles = [roles]
|
|
18
|
+
return {
|
|
19
|
+
"name": user.name,
|
|
20
|
+
"realname": c.get("realname", ""),
|
|
21
|
+
"email": c.get("email", ""),
|
|
22
|
+
"roles": ", ".join(roles),
|
|
23
|
+
"defaultApp": c.get("defaultApp", ""),
|
|
24
|
+
"type": c.get("type", ""),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _role_row(role: Any) -> dict[str, Any]:
|
|
29
|
+
c: dict[str, Any] = role.content
|
|
30
|
+
imported = c.get("imported_roles", [])
|
|
31
|
+
if isinstance(imported, str):
|
|
32
|
+
imported = [imported]
|
|
33
|
+
caps = c.get("capabilities", [])
|
|
34
|
+
if isinstance(caps, str):
|
|
35
|
+
caps = [caps]
|
|
36
|
+
truncated = ", ".join(caps[:_MAX_CAPS])
|
|
37
|
+
if len(caps) > _MAX_CAPS:
|
|
38
|
+
truncated += f" (+{len(caps) - _MAX_CAPS} more)"
|
|
39
|
+
return {
|
|
40
|
+
"name": role.name,
|
|
41
|
+
"imported_roles": ", ".join(imported),
|
|
42
|
+
"capabilities": truncated,
|
|
43
|
+
"defaultApp": c.get("defaultApp", ""),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group("users")
|
|
48
|
+
def users_group() -> None:
|
|
49
|
+
"""Manage users and roles."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@users_group.command("list")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def list_users(ctx: click.Context) -> None:
|
|
55
|
+
"""List all users."""
|
|
56
|
+
client = get_client(ctx)
|
|
57
|
+
users = client.service.users.list()
|
|
58
|
+
if not users:
|
|
59
|
+
output.info("No users found.")
|
|
60
|
+
return
|
|
61
|
+
rows = [_user_row(u) for u in users]
|
|
62
|
+
output.render(ctx, rows)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@users_group.command("get")
|
|
66
|
+
@click.argument("name")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def get_user(ctx: click.Context, name: str) -> None:
|
|
69
|
+
"""Get details for a single user."""
|
|
70
|
+
client = get_client(ctx)
|
|
71
|
+
try:
|
|
72
|
+
user = client.service.users[name]
|
|
73
|
+
except KeyError:
|
|
74
|
+
output.error(f"User '{name}' not found.")
|
|
75
|
+
ctx.exit(1)
|
|
76
|
+
return
|
|
77
|
+
output.render(ctx, _user_row(user))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@users_group.command("roles")
|
|
81
|
+
@click.pass_context
|
|
82
|
+
def list_roles(ctx: click.Context) -> None:
|
|
83
|
+
"""List all roles."""
|
|
84
|
+
client = get_client(ctx)
|
|
85
|
+
roles = client.service.roles.list()
|
|
86
|
+
if not roles:
|
|
87
|
+
output.info("No roles found.")
|
|
88
|
+
return
|
|
89
|
+
rows = [_role_row(r) for r in roles]
|
|
90
|
+
output.render(ctx, rows)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@users_group.command("create")
|
|
94
|
+
@click.option("--name", "username", required=True, help="Username.")
|
|
95
|
+
@click.option("--password", required=True, help="Password.")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--roles",
|
|
98
|
+
required=True,
|
|
99
|
+
help="Comma-separated role names.",
|
|
100
|
+
)
|
|
101
|
+
@click.option("--email", default=None, help="Email address.")
|
|
102
|
+
@click.option("--realname", default=None, help="Display name.")
|
|
103
|
+
@click.pass_context
|
|
104
|
+
def create_user(
|
|
105
|
+
ctx: click.Context,
|
|
106
|
+
username: str,
|
|
107
|
+
password: str,
|
|
108
|
+
roles: str,
|
|
109
|
+
*,
|
|
110
|
+
email: str | None,
|
|
111
|
+
realname: str | None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Create a new user."""
|
|
114
|
+
roles_list = [r.strip() for r in roles.split(",")]
|
|
115
|
+
details = f"Create user '{username}' with roles: {', '.join(roles_list)}"
|
|
116
|
+
if not guard.check(ctx, details):
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
client = get_client(ctx)
|
|
120
|
+
kwargs: dict[str, Any] = {"password": password, "roles": roles_list}
|
|
121
|
+
if email:
|
|
122
|
+
kwargs["email"] = email
|
|
123
|
+
if realname:
|
|
124
|
+
kwargs["realname"] = realname
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
client.service.users.create(username, **kwargs)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
output.error(f"Create failed: {exc}")
|
|
130
|
+
ctx.exit(1)
|
|
131
|
+
return
|
|
132
|
+
output.info(f"Created user '{username}'.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@users_group.command("update")
|
|
136
|
+
@click.argument("name")
|
|
137
|
+
@click.option("--roles", default=None, help="Comma-separated role names.")
|
|
138
|
+
@click.option("--email", default=None, help="Email address.")
|
|
139
|
+
@click.option("--realname", default=None, help="Display name.")
|
|
140
|
+
@click.option("--default-app", default=None, help="Default app.")
|
|
141
|
+
@click.pass_context
|
|
142
|
+
def update_user(
|
|
143
|
+
ctx: click.Context,
|
|
144
|
+
name: str,
|
|
145
|
+
*,
|
|
146
|
+
roles: str | None,
|
|
147
|
+
email: str | None,
|
|
148
|
+
realname: str | None,
|
|
149
|
+
default_app: str | None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Update an existing user."""
|
|
152
|
+
kwargs: dict[str, Any] = {}
|
|
153
|
+
if roles is not None:
|
|
154
|
+
kwargs["roles"] = [r.strip() for r in roles.split(",")]
|
|
155
|
+
if email is not None:
|
|
156
|
+
kwargs["email"] = email
|
|
157
|
+
if realname is not None:
|
|
158
|
+
kwargs["realname"] = realname
|
|
159
|
+
if default_app is not None:
|
|
160
|
+
kwargs["defaultApp"] = default_app
|
|
161
|
+
|
|
162
|
+
if not kwargs:
|
|
163
|
+
output.error("No update fields specified.")
|
|
164
|
+
ctx.exit(1)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
changes = ", ".join(f"{k}={v}" for k, v in kwargs.items())
|
|
168
|
+
if not guard.check(ctx, f"Update user '{name}'", details=changes):
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
client = get_client(ctx)
|
|
172
|
+
try:
|
|
173
|
+
user = client.service.users[name]
|
|
174
|
+
except KeyError:
|
|
175
|
+
output.error(f"User '{name}' not found.")
|
|
176
|
+
ctx.exit(1)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
user.update(**kwargs)
|
|
181
|
+
user.refresh()
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
output.error(f"Update failed: {exc}")
|
|
184
|
+
ctx.exit(1)
|
|
185
|
+
return
|
|
186
|
+
output.info(f"Updated user '{name}'.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@users_group.command("delete")
|
|
190
|
+
@click.argument("name")
|
|
191
|
+
@click.pass_context
|
|
192
|
+
def delete_user(ctx: click.Context, name: str) -> None:
|
|
193
|
+
"""Delete a user."""
|
|
194
|
+
if not guard.check(ctx, f"Delete user '{name}'"):
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
client = get_client(ctx)
|
|
198
|
+
try:
|
|
199
|
+
user = client.service.users[name]
|
|
200
|
+
except KeyError:
|
|
201
|
+
output.error(f"User '{name}' not found.")
|
|
202
|
+
ctx.exit(1)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
user.delete()
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
output.error(f"Delete failed: {exc}")
|
|
209
|
+
ctx.exit(1)
|
|
210
|
+
return
|
|
211
|
+
output.info(f"Deleted user '{name}'.")
|
splunkctl/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Config file management — load, save, redact."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
DEFAULT_DIR: Path = Path.home() / ".splunkctl"
|
|
11
|
+
DEFAULT_PATH: Path = DEFAULT_DIR / "config.yaml"
|
|
12
|
+
|
|
13
|
+
_SECRETS: frozenset[str] = frozenset({"password", "token", "secret"})
|
|
14
|
+
|
|
15
|
+
_ENV_MAP: dict[str, str] = {
|
|
16
|
+
"SPLUNK_HOST": "host",
|
|
17
|
+
"SPLUNK_PORT": "port",
|
|
18
|
+
"SPLUNK_USER": "username",
|
|
19
|
+
"SPLUNK_PASS": "password",
|
|
20
|
+
"SPLUNK_SCHEME": "scheme",
|
|
21
|
+
"SPLUNK_TOKEN": "token",
|
|
22
|
+
"SPLUNK_APP": "app",
|
|
23
|
+
"SPLUNK_OWNER": "owner",
|
|
24
|
+
"SPLUNK_VERIFY": "verify",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def defaults() -> dict[str, Any]:
|
|
29
|
+
"""Return default configuration values."""
|
|
30
|
+
return {
|
|
31
|
+
"host": "localhost",
|
|
32
|
+
"port": 8089,
|
|
33
|
+
"username": "admin",
|
|
34
|
+
"password": "",
|
|
35
|
+
"scheme": "https",
|
|
36
|
+
"verify": False,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(path: Path | None = None) -> dict[str, Any]:
|
|
41
|
+
"""Load config with resolution: defaults -> file -> env vars."""
|
|
42
|
+
cfg = defaults()
|
|
43
|
+
config_path = path or DEFAULT_PATH
|
|
44
|
+
|
|
45
|
+
if config_path.exists():
|
|
46
|
+
with open(config_path) as f:
|
|
47
|
+
file_cfg = yaml.safe_load(f)
|
|
48
|
+
if isinstance(file_cfg, dict):
|
|
49
|
+
cfg.update(file_cfg)
|
|
50
|
+
|
|
51
|
+
for env_key, cfg_key in _ENV_MAP.items():
|
|
52
|
+
val = os.environ.get(env_key)
|
|
53
|
+
if val is None:
|
|
54
|
+
continue
|
|
55
|
+
if cfg_key == "port":
|
|
56
|
+
try:
|
|
57
|
+
cfg[cfg_key] = int(val)
|
|
58
|
+
except ValueError:
|
|
59
|
+
continue
|
|
60
|
+
elif cfg_key == "verify":
|
|
61
|
+
cfg[cfg_key] = val.lower() in ("1", "true", "yes")
|
|
62
|
+
else:
|
|
63
|
+
cfg[cfg_key] = val
|
|
64
|
+
|
|
65
|
+
return cfg
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save(cfg: dict[str, Any], path: Path | None = None) -> Path:
|
|
69
|
+
"""Save config to YAML with 0600 permissions."""
|
|
70
|
+
config_path = path or DEFAULT_PATH
|
|
71
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
with open(config_path, "w") as f:
|
|
74
|
+
yaml.dump(dict(cfg), f, default_flow_style=False, sort_keys=False)
|
|
75
|
+
|
|
76
|
+
config_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
77
|
+
return config_path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def redact(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""Return a copy with secret values replaced by '****'."""
|
|
82
|
+
return {k: "****" if k in _SECRETS and v else v for k, v in cfg.items()}
|
splunkctl/guard.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Mutation guard — dry-run preview, --yes to apply."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check(ctx: click.Context, action: str, *, details: str = "") -> bool:
|
|
9
|
+
"""Return True if the mutation should proceed.
|
|
10
|
+
|
|
11
|
+
Dry-run (default) prints a preview to stderr and returns False.
|
|
12
|
+
Pass ``--yes`` to apply.
|
|
13
|
+
"""
|
|
14
|
+
obj: dict[str, Any] = ctx.obj or {}
|
|
15
|
+
if not obj.get("dry_run", True):
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
click.echo(f"[DRY RUN] {action}", err=True)
|
|
19
|
+
if details:
|
|
20
|
+
click.echo(details, err=True)
|
|
21
|
+
click.echo("Pass --yes to apply.", err=True)
|
|
22
|
+
return False
|