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,217 @@
1
+ """Lookup table commands — raw REST (SDK gap)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import click
9
+
10
+ from splunkctl import guard, output
11
+ from splunkctl.client import get_client
12
+
13
+ _READ_BASE = "/servicesNS/-/{app}/data/lookup-table-files"
14
+ _WRITE_BASE = "/servicesNS/nobody/{app}/data/lookup-table-files"
15
+
16
+
17
+ def _read_path(app: str, name: str | None = None) -> str:
18
+ base = _READ_BASE.format(app=quote(app, safe=""))
19
+ return f"{base}/{quote(name, safe='')}" if name else base
20
+
21
+
22
+ def _write_path(app: str, name: str | None = None) -> str:
23
+ base = _WRITE_BASE.format(app=quote(app, safe=""))
24
+ return f"{base}/{quote(name, safe='')}" if name else base
25
+
26
+
27
+ def _parse_entries(body: bytes) -> list[dict[str, Any]]:
28
+ """Extract flat rows from Splunk REST JSON response."""
29
+ data: dict[str, Any] = json.loads(body)
30
+ return [
31
+ {
32
+ "name": entry.get("name", ""),
33
+ "app": entry.get("acl", {}).get("app", ""),
34
+ "owner": entry.get("acl", {}).get("owner", ""),
35
+ "disabled": entry.get("content", {}).get("disabled", ""),
36
+ "eai:type": entry.get("content", {}).get("eai:type", ""),
37
+ "updated": entry.get("updated", ""),
38
+ }
39
+ for entry in data.get("entry", [])
40
+ ]
41
+
42
+
43
+ def _parse_entry(body: bytes) -> dict[str, Any]:
44
+ """Extract a single entry from Splunk REST JSON response."""
45
+ data: dict[str, Any] = json.loads(body)
46
+ entries: list[dict[str, Any]] = data.get("entry", [])
47
+ if not entries:
48
+ return {}
49
+ entry = entries[0]
50
+ content: dict[str, Any] = entry.get("content", {})
51
+ return {
52
+ "name": entry.get("name", ""),
53
+ "app": entry.get("acl", {}).get("app", ""),
54
+ "owner": entry.get("acl", {}).get("owner", ""),
55
+ "disabled": content.get("disabled", ""),
56
+ "eai:type": content.get("eai:type", ""),
57
+ "eai:data": content.get("eai:data", ""),
58
+ "updated": entry.get("updated", ""),
59
+ }
60
+
61
+
62
+ @click.group("lookups")
63
+ def lookups_group() -> None:
64
+ """Manage lookup table files."""
65
+
66
+
67
+ @lookups_group.command("list")
68
+ @click.option("--app", default="-", help="Splunk app context (default: all).")
69
+ @click.pass_context
70
+ def list_lookups(ctx: click.Context, *, app: str) -> None:
71
+ """List lookup table files."""
72
+ client = get_client(ctx)
73
+ svc = client.service
74
+ resp = svc.get(_read_path(app), output_mode="json")
75
+ body: bytes = resp.body.read()
76
+ rows = _parse_entries(body)
77
+ if not rows:
78
+ output.info("No lookup tables found.")
79
+ return
80
+ output.render(ctx, rows)
81
+
82
+
83
+ @lookups_group.command("get")
84
+ @click.argument("name")
85
+ @click.option("--app", default="-", help="Splunk app context (default: all).")
86
+ @click.pass_context
87
+ def get_lookup(ctx: click.Context, name: str, *, app: str) -> None:
88
+ """Get metadata for a lookup file."""
89
+ client = get_client(ctx)
90
+ svc = client.service
91
+ try:
92
+ resp = svc.get(_read_path(app, name), output_mode="json")
93
+ except Exception as exc:
94
+ output.error(f"Lookup '{name}' not found: {exc}")
95
+ ctx.exit(1)
96
+ return
97
+ body: bytes = resp.body.read()
98
+ row = _parse_entry(body)
99
+ if not row:
100
+ output.error(f"Lookup '{name}' not found.")
101
+ ctx.exit(1)
102
+ return
103
+ output.render(ctx, row)
104
+
105
+
106
+ @lookups_group.command("upload")
107
+ @click.argument("name")
108
+ @click.option(
109
+ "--file",
110
+ "file_path",
111
+ required=True,
112
+ type=click.Path(exists=True),
113
+ help="CSV file to upload.",
114
+ )
115
+ @click.option("--app", default="search", help="Target app (default: search).")
116
+ @click.pass_context
117
+ def upload_lookup(ctx: click.Context, name: str, file_path: str, *, app: str) -> None:
118
+ """Upload a CSV file as a new lookup table."""
119
+ path = Path(file_path)
120
+ details = f"Upload '{path.name}' as lookup '{name}' in app '{app}'"
121
+ if not guard.check(ctx, details):
122
+ return
123
+
124
+ client = get_client(ctx)
125
+ svc = client.service
126
+ csv_data = path.read_text(encoding="utf-8")
127
+ try:
128
+ svc.post(
129
+ _write_path(app),
130
+ name=name,
131
+ **{"eai:data": csv_data},
132
+ )
133
+ except Exception as exc:
134
+ output.error(f"Upload failed: {exc}")
135
+ ctx.exit(1)
136
+ return
137
+ output.info(f"Uploaded lookup '{name}' to app '{app}'.")
138
+
139
+
140
+ @lookups_group.command("download")
141
+ @click.argument("name")
142
+ @click.option("--app", default="search", help="Splunk app context.")
143
+ @click.option("--out", type=click.Path(), default=None, help="Write CSV to file.")
144
+ @click.pass_context
145
+ def download_lookup(
146
+ ctx: click.Context, name: str, *, app: str, out: str | None
147
+ ) -> None:
148
+ """Download a lookup table as CSV."""
149
+ client = get_client(ctx)
150
+ svc = client.service
151
+ try:
152
+ stream = svc.jobs.oneshot(f"| inputlookup {name}", output_mode="csv", app=app)
153
+ csv_content = stream.read().decode("utf-8")
154
+ except Exception as exc:
155
+ output.error(f"Download failed: {exc}")
156
+ ctx.exit(1)
157
+ return
158
+
159
+ if out:
160
+ Path(out).write_text(csv_content, encoding="utf-8")
161
+ output.info(f"Written to {out}")
162
+ else:
163
+ click.echo(csv_content, nl=False)
164
+
165
+
166
+ @lookups_group.command("update")
167
+ @click.argument("name")
168
+ @click.option(
169
+ "--file",
170
+ "file_path",
171
+ required=True,
172
+ type=click.Path(exists=True),
173
+ help="CSV file to upload.",
174
+ )
175
+ @click.option("--app", default="search", help="Target app (default: search).")
176
+ @click.pass_context
177
+ def update_lookup(ctx: click.Context, name: str, file_path: str, *, app: str) -> None:
178
+ """Overwrite an existing lookup table with new CSV data."""
179
+ path = Path(file_path)
180
+ details = f"Overwrite lookup '{name}' in app '{app}' with '{path.name}'"
181
+ if not guard.check(ctx, details):
182
+ return
183
+
184
+ client = get_client(ctx)
185
+ svc = client.service
186
+ csv_data = path.read_text(encoding="utf-8")
187
+ try:
188
+ svc.post(
189
+ _write_path(app, name),
190
+ **{"eai:data": csv_data},
191
+ )
192
+ except Exception as exc:
193
+ output.error(f"Update failed: {exc}")
194
+ ctx.exit(1)
195
+ return
196
+ output.info(f"Updated lookup '{name}' in app '{app}'.")
197
+
198
+
199
+ @lookups_group.command("delete")
200
+ @click.argument("name")
201
+ @click.option("--app", default="search", help="Target app (default: search).")
202
+ @click.pass_context
203
+ def delete_lookup(ctx: click.Context, name: str, *, app: str) -> None:
204
+ """Delete a lookup table file."""
205
+ details = f"Delete lookup '{name}' from app '{app}'"
206
+ if not guard.check(ctx, details):
207
+ return
208
+
209
+ client = get_client(ctx)
210
+ svc = client.service
211
+ try:
212
+ svc.delete(_write_path(app, name))
213
+ except Exception as exc:
214
+ output.error(f"Delete failed: {exc}")
215
+ ctx.exit(1)
216
+ return
217
+ output.info(f"Deleted lookup '{name}' from app '{app}'.")
@@ -0,0 +1,177 @@
1
+ """Parser / sourcetype commands — props.conf and transforms.conf via confs API."""
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
+
11
+ @click.group("parsers")
12
+ def parsers_group() -> None:
13
+ """Manage source types and field extractions."""
14
+
15
+
16
+ @parsers_group.command("sourcetypes")
17
+ @click.pass_context
18
+ def sourcetypes(ctx: click.Context) -> None:
19
+ """List source types from props.conf."""
20
+ client = get_client(ctx)
21
+ conf = client.service.confs["props"]
22
+ rows: list[dict[str, Any]] = [
23
+ {
24
+ "name": s.name,
25
+ "category": s.content.get("category", ""),
26
+ "description": s.content.get("description", ""),
27
+ "TRANSFORMS": s.content.get("TRANSFORMS", ""),
28
+ }
29
+ for s in conf.list()
30
+ ]
31
+ output.render(ctx, rows)
32
+
33
+
34
+ @parsers_group.command("get")
35
+ @click.argument("sourcetype")
36
+ @click.pass_context
37
+ def get_sourcetype(ctx: click.Context, sourcetype: str) -> None:
38
+ """Show full props.conf settings for a sourcetype."""
39
+ client = get_client(ctx)
40
+ conf = client.service.confs["props"]
41
+ try:
42
+ stanza = conf[sourcetype]
43
+ except KeyError:
44
+ output.error(f"Sourcetype '{sourcetype}' not found.")
45
+ ctx.exit(1)
46
+ return
47
+ row: dict[str, Any] = {"name": stanza.name, **dict(stanza.content)}
48
+ output.render(ctx, row)
49
+
50
+
51
+ @parsers_group.command("extractions")
52
+ @click.option("--sourcetype", default=None, help="Filter by name substring.")
53
+ @click.pass_context
54
+ def extractions(ctx: click.Context, sourcetype: str | None) -> None:
55
+ """List field extractions from transforms.conf."""
56
+ client = get_client(ctx)
57
+ conf = client.service.confs["transforms"]
58
+ rows: list[dict[str, Any]] = []
59
+ for stanza in conf.list():
60
+ if sourcetype and sourcetype not in stanza.name:
61
+ continue
62
+ rows.append(
63
+ {
64
+ "name": stanza.name,
65
+ "REGEX": stanza.content.get("REGEX", ""),
66
+ "FORMAT": stanza.content.get("FORMAT", ""),
67
+ "DEST_KEY": stanza.content.get("DEST_KEY", ""),
68
+ }
69
+ )
70
+ output.render(ctx, rows)
71
+
72
+
73
+ @parsers_group.command("create")
74
+ @click.option("--sourcetype", required=True, help="Sourcetype name.")
75
+ @click.option("--category", default=None, help="Category value.")
76
+ @click.option(
77
+ "--transforms",
78
+ "transforms_val",
79
+ default=None,
80
+ help="TRANSFORMS value.",
81
+ )
82
+ @click.pass_context
83
+ def create_sourcetype(
84
+ ctx: click.Context,
85
+ sourcetype: str,
86
+ category: str | None,
87
+ transforms_val: str | None,
88
+ ) -> None:
89
+ """Create a props.conf stanza."""
90
+ kwargs: dict[str, str] = {}
91
+ if category:
92
+ kwargs["category"] = category
93
+ if transforms_val:
94
+ kwargs["TRANSFORMS"] = transforms_val
95
+
96
+ details = f" sourcetype: {sourcetype}"
97
+ for k, v in kwargs.items():
98
+ details += f"\n {k}: {v}"
99
+
100
+ if not guard.check(ctx, f"Create sourcetype '{sourcetype}'", details=details):
101
+ return
102
+
103
+ client = get_client(ctx)
104
+ conf = client.service.confs["props"]
105
+ conf.create(sourcetype, **kwargs)
106
+ output.info(f"Created sourcetype '{sourcetype}'.")
107
+
108
+
109
+ @parsers_group.command("update")
110
+ @click.argument("sourcetype")
111
+ @click.option("--category", default=None, help="Category value.")
112
+ @click.option(
113
+ "--transforms",
114
+ "transforms_val",
115
+ default=None,
116
+ help="TRANSFORMS value.",
117
+ )
118
+ @click.pass_context
119
+ def update_sourcetype(
120
+ ctx: click.Context,
121
+ sourcetype: str,
122
+ category: str | None,
123
+ transforms_val: str | None,
124
+ ) -> None:
125
+ """Update a props.conf stanza."""
126
+ kwargs: dict[str, str] = {}
127
+ if category:
128
+ kwargs["category"] = category
129
+ if transforms_val:
130
+ kwargs["TRANSFORMS"] = transforms_val
131
+
132
+ if not kwargs:
133
+ output.error("Nothing to update — pass at least one option.")
134
+ ctx.exit(1)
135
+ return
136
+
137
+ details = f" sourcetype: {sourcetype}"
138
+ for k, v in kwargs.items():
139
+ details += f"\n {k}: {v}"
140
+
141
+ if not guard.check(ctx, f"Update sourcetype '{sourcetype}'", details=details):
142
+ return
143
+
144
+ client = get_client(ctx)
145
+ conf = client.service.confs["props"]
146
+ try:
147
+ stanza = conf[sourcetype]
148
+ except KeyError:
149
+ output.error(f"Sourcetype '{sourcetype}' not found.")
150
+ ctx.exit(1)
151
+ return
152
+ stanza.update(**kwargs).refresh()
153
+ output.info(f"Updated sourcetype '{sourcetype}'.")
154
+
155
+
156
+ @parsers_group.command("delete")
157
+ @click.argument("sourcetype")
158
+ @click.pass_context
159
+ def delete_sourcetype(ctx: click.Context, sourcetype: str) -> None:
160
+ """Delete a props.conf stanza."""
161
+ if not guard.check(
162
+ ctx,
163
+ f"Delete sourcetype '{sourcetype}'",
164
+ details=f" sourcetype: {sourcetype}",
165
+ ):
166
+ return
167
+
168
+ client = get_client(ctx)
169
+ conf = client.service.confs["props"]
170
+ try:
171
+ stanza = conf[sourcetype]
172
+ except KeyError:
173
+ output.error(f"Sourcetype '{sourcetype}' not found.")
174
+ ctx.exit(1)
175
+ return
176
+ stanza.delete()
177
+ output.info(f"Deleted sourcetype '{sourcetype}'.")
@@ -0,0 +1,266 @@
1
+ """Saved searches / detection rules."""
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
+
11
+ def _summarize(ss: Any) -> dict[str, Any]:
12
+ c: dict[str, Any] = ss.content
13
+ acl: dict[str, Any] = ss.access
14
+ return {
15
+ "name": ss.name,
16
+ "app": acl.get("app", ""),
17
+ "is_scheduled": c.get("is_scheduled", "0"),
18
+ "cron": c.get("cron_schedule", ""),
19
+ "next_scheduled": c.get("next_scheduled_time", ""),
20
+ "disabled": c.get("disabled", "0"),
21
+ "actions": c.get("actions", ""),
22
+ }
23
+
24
+
25
+ _DETAIL_FIELDS = (
26
+ "search",
27
+ "description",
28
+ "cron_schedule",
29
+ "is_scheduled",
30
+ "next_scheduled_time",
31
+ "disabled",
32
+ "actions",
33
+ "alert_type",
34
+ "alert.severity",
35
+ "alert.suppress",
36
+ "dispatch.earliest_time",
37
+ "dispatch.latest_time",
38
+ "max_concurrent",
39
+ "realtime_schedule",
40
+ "request.ui_dispatch_app",
41
+ )
42
+
43
+
44
+ def _detail(ss: Any) -> dict[str, Any]:
45
+ c: dict[str, Any] = ss.content
46
+ row: dict[str, Any] = {"name": ss.name}
47
+ row.update({f: c.get(f, "") for f in _DETAIL_FIELDS})
48
+ return row
49
+
50
+
51
+ @click.group("rules")
52
+ def rules_group() -> None:
53
+ """Manage detection rules (saved searches)."""
54
+
55
+
56
+ @rules_group.command("list")
57
+ @click.pass_context
58
+ def list_rules(ctx: click.Context) -> None:
59
+ """List all saved searches."""
60
+ client = get_client(ctx)
61
+ items = client.service.saved_searches.list()
62
+ rows = [_summarize(ss) for ss in items]
63
+ output.render(ctx, rows)
64
+
65
+
66
+ @rules_group.command()
67
+ @click.argument("name")
68
+ @click.pass_context
69
+ def get(ctx: click.Context, name: str) -> None:
70
+ """Get a saved search by name."""
71
+ client = get_client(ctx)
72
+ try:
73
+ ss = client.service.saved_searches[name]
74
+ except KeyError:
75
+ output.error(f"Saved search not found: {name}")
76
+ ctx.exit(1)
77
+ return
78
+ output.render(ctx, _detail(ss))
79
+
80
+
81
+ @rules_group.command()
82
+ @click.option("--name", required=True, help="Saved search name.")
83
+ @click.option("--search", "spl", required=True, help="SPL query.")
84
+ @click.option("--cron", default=None, help="Cron schedule.")
85
+ @click.option("--app", default=None, help="Splunk app context.")
86
+ @click.option("--description", default=None, help="Description.")
87
+ @click.option("--actions", default=None, help="Alert actions (comma-separated).")
88
+ @click.option("--disabled", is_flag=True, default=False, help="Create disabled.")
89
+ @click.pass_context
90
+ def create(
91
+ ctx: click.Context,
92
+ *,
93
+ name: str,
94
+ spl: str,
95
+ cron: str | None,
96
+ app: str | None,
97
+ description: str | None,
98
+ actions: str | None,
99
+ disabled: bool,
100
+ ) -> None:
101
+ """Create a saved search."""
102
+ kwargs: dict[str, Any] = {}
103
+ if cron is not None:
104
+ kwargs["cron_schedule"] = cron
105
+ kwargs["is_scheduled"] = "1"
106
+ if description is not None:
107
+ kwargs["description"] = description
108
+ if actions is not None:
109
+ kwargs["actions"] = actions
110
+ if disabled:
111
+ kwargs["disabled"] = "1"
112
+ if app is not None:
113
+ kwargs["app"] = app
114
+
115
+ detail = f" name: {name}\n search: {spl}"
116
+ if kwargs:
117
+ detail += "\n " + "\n ".join(f"{k}: {v}" for k, v in kwargs.items())
118
+
119
+ if not guard.check(ctx, f"Create saved search '{name}'", details=detail):
120
+ return
121
+
122
+ client = get_client(ctx)
123
+ client.service.saved_searches.create(name, search=spl, **kwargs)
124
+ output.info(f"Created saved search '{name}'.")
125
+
126
+
127
+ @rules_group.command()
128
+ @click.argument("name")
129
+ @click.option("--search", "spl", default=None, help="SPL query.")
130
+ @click.option("--cron", default=None, help="Cron schedule.")
131
+ @click.option("--description", default=None, help="Description.")
132
+ @click.option("--actions", default=None, help="Alert actions (comma-separated).")
133
+ @click.option(
134
+ "--enabled/--disabled",
135
+ default=None,
136
+ help="Enable or disable scheduling.",
137
+ )
138
+ @click.pass_context
139
+ def update(
140
+ ctx: click.Context,
141
+ name: str,
142
+ *,
143
+ spl: str | None,
144
+ cron: str | None,
145
+ description: str | None,
146
+ actions: str | None,
147
+ enabled: bool | None,
148
+ ) -> None:
149
+ """Update a saved search."""
150
+ kwargs: dict[str, Any] = {}
151
+ if spl is not None:
152
+ kwargs["search"] = spl
153
+ if cron is not None:
154
+ kwargs["cron_schedule"] = cron
155
+ if description is not None:
156
+ kwargs["description"] = description
157
+ if actions is not None:
158
+ kwargs["actions"] = actions
159
+ if enabled is not None:
160
+ kwargs["disabled"] = "0" if enabled else "1"
161
+ if enabled:
162
+ kwargs["is_scheduled"] = "1"
163
+
164
+ if not kwargs:
165
+ output.error("No changes specified.")
166
+ ctx.exit(1)
167
+ return
168
+
169
+ detail = "\n".join(f" {k}: {v}" for k, v in kwargs.items())
170
+ if not guard.check(ctx, f"Update saved search '{name}'", details=detail):
171
+ return
172
+
173
+ client = get_client(ctx)
174
+ try:
175
+ ss = client.service.saved_searches[name]
176
+ except KeyError:
177
+ output.error(f"Saved search not found: {name}")
178
+ ctx.exit(1)
179
+ return
180
+ ss.update(**kwargs).refresh()
181
+ output.info(f"Updated saved search '{name}'.")
182
+
183
+
184
+ @rules_group.command()
185
+ @click.argument("name")
186
+ @click.pass_context
187
+ def delete(ctx: click.Context, name: str) -> None:
188
+ """Delete a saved search."""
189
+ if not guard.check(ctx, f"Delete saved search '{name}'"):
190
+ return
191
+
192
+ client = get_client(ctx)
193
+ try:
194
+ ss = client.service.saved_searches[name]
195
+ except KeyError:
196
+ output.error(f"Saved search not found: {name}")
197
+ ctx.exit(1)
198
+ return
199
+ ss.delete()
200
+ output.info(f"Deleted saved search '{name}'.")
201
+
202
+
203
+ @rules_group.command()
204
+ @click.argument("name")
205
+ @click.pass_context
206
+ def enable(ctx: click.Context, name: str) -> None:
207
+ """Enable scheduling for a saved search."""
208
+ if not guard.check(ctx, f"Enable saved search '{name}'"):
209
+ return
210
+
211
+ client = get_client(ctx)
212
+ try:
213
+ ss = client.service.saved_searches[name]
214
+ except KeyError:
215
+ output.error(f"Saved search not found: {name}")
216
+ ctx.exit(1)
217
+ return
218
+ ss.update(disabled="0", is_scheduled="1").refresh()
219
+ output.info(f"Enabled saved search '{name}'.")
220
+
221
+
222
+ @rules_group.command()
223
+ @click.argument("name")
224
+ @click.pass_context
225
+ def disable(ctx: click.Context, name: str) -> None:
226
+ """Disable scheduling for a saved search."""
227
+ if not guard.check(ctx, f"Disable saved search '{name}'"):
228
+ return
229
+
230
+ client = get_client(ctx)
231
+ try:
232
+ ss = client.service.saved_searches[name]
233
+ except KeyError:
234
+ output.error(f"Saved search not found: {name}")
235
+ ctx.exit(1)
236
+ return
237
+ ss.update(disabled="1").refresh()
238
+ output.info(f"Disabled saved search '{name}'.")
239
+
240
+
241
+ @rules_group.command()
242
+ @click.argument("name")
243
+ @click.pass_context
244
+ def history(ctx: click.Context, name: str) -> None:
245
+ """Show run history for a saved search."""
246
+ client = get_client(ctx)
247
+ try:
248
+ ss = client.service.saved_searches[name]
249
+ except KeyError:
250
+ output.error(f"Saved search not found: {name}")
251
+ ctx.exit(1)
252
+ return
253
+ jobs = ss.history()
254
+ rows: list[dict[str, Any]] = []
255
+ for job in jobs:
256
+ c: dict[str, Any] = job.content
257
+ rows.append(
258
+ {
259
+ "sid": job.sid,
260
+ "dispatch_state": c.get("dispatchState", ""),
261
+ "run_duration": c.get("runDuration", ""),
262
+ "event_count": c.get("eventCount", ""),
263
+ "result_count": c.get("resultCount", ""),
264
+ }
265
+ )
266
+ output.render(ctx, rows)