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.
@@ -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