Skip to content

Commit d764e75

Browse files
authored
Merge pull request #468 from splunk/DVPL-10898-develop
SDK Support for splunkd search API changes
2 parents 27f8fbf + 2b2dee7 commit d764e75

File tree

8 files changed

+119
-24
lines changed

8 files changed

+119
-24
lines changed

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ password=changed!
99
# Access scheme (default: https)
1010
scheme=https
1111
# Your version of Splunk (default: 6.2)
12-
version=8.0
12+
version=9.0
1313
# Bearer token for authentication
1414
#bearerToken="<Bearer-token>"
1515
# Session key for authentication

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
- ubuntu-latest
1414
python: [ 2.7, 3.7 ]
1515
splunk-version:
16-
- "8.0"
16+
- "8.2"
1717
- "latest"
1818
fail-fast: false
1919

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ If you're seeing some unexpected behavior with this project, please create an [i
1111
1. Version of this project you're using (ex: 1.5.0)
1212
2. Platform version (ex: Windows Server 2012 R2)
1313
3. Framework version (ex: Python 3.7)
14-
4. Splunk Enterprise version (ex: 8.0)
14+
4. Splunk Enterprise version (ex: 9.0)
1515
5. Other relevant information (ex: local/remote environment, Splunk network configuration, standalone or distributed deployment, are load balancers used)
1616

1717
Alternatively, if you have a Splunk question please ask on [Splunk Answers](https://community.splunk.com/t5/Splunk-Development/ct-p/developer-tools).

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Install the sources you cloned from GitHub:
5858
You'll need `docker` and `docker-compose` to get up and running using this method.
5959

6060
```
61-
make up SPLUNK_VERSION=8.0
61+
make up SPLUNK_VERSION=9.0
6262
make wait_up
6363
make test
6464
make down
@@ -107,7 +107,7 @@ here is an example of .env file:
107107
# Access scheme (default: https)
108108
scheme=https
109109
# Your version of Splunk Enterprise
110-
version=8.0
110+
version=9.0
111111
# Bearer token for authentication
112112
#bearerToken=<Bearer-token>
113113
# Session key for authentication

splunklib/client.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
import datetime
6363
import json
6464
import logging
65+
import re
6566
import socket
6667
from datetime import datetime, timedelta
6768
from time import sleep
@@ -99,6 +100,7 @@
99100
PATH_INDEXES = "data/indexes/"
100101
PATH_INPUTS = "data/inputs/"
101102
PATH_JOBS = "search/jobs/"
103+
PATH_JOBS_V2 = "search/v2/jobs/"
102104
PATH_LOGGER = "/services/server/logger/"
103105
PATH_MESSAGES = "messages/"
104106
PATH_MODULAR_INPUTS = "data/modular-inputs"
@@ -570,6 +572,8 @@ def parse(self, query, **kwargs):
570572
:type kwargs: ``dict``
571573
:return: A semantic map of the parsed search query.
572574
"""
575+
if self.splunk_version >= (9,):
576+
return self.post("search/v2/parser", q=query, **kwargs)
573577
return self.get("search/parser", q=query, **kwargs)
574578

575579
def restart(self, timeout=None):
@@ -741,6 +745,25 @@ def __init__(self, service, path):
741745
self.service = service
742746
self.path = path
743747

748+
def get_api_version(self, path):
749+
"""Return the API version of the service used in the provided path.
750+
751+
Args:
752+
path (str): A fully-qualified endpoint path (for example, "/services/search/jobs").
753+
754+
Returns:
755+
int: Version of the API (for example, 1)
756+
"""
757+
# Default to v1 if undefined in the path
758+
# For example, "/services/search/jobs" is using API v1
759+
api_version = 1
760+
761+
versionSearch = re.search('(?:servicesNS\/[^/]+\/[^/]+|services)\/[^/]+\/v(\d+)\/', path)
762+
if versionSearch:
763+
api_version = int(versionSearch.group(1))
764+
765+
return api_version
766+
744767
def get(self, path_segment="", owner=None, app=None, sharing=None, **query):
745768
"""Performs a GET operation on the path segment relative to this endpoint.
746769
@@ -803,6 +826,22 @@ def get(self, path_segment="", owner=None, app=None, sharing=None, **query):
803826
app=app, sharing=sharing)
804827
# ^-- This was "%s%s" % (self.path, path_segment).
805828
# That doesn't work, because self.path may be UrlEncoded.
829+
830+
# Get the API version from the path
831+
api_version = self.get_api_version(path)
832+
833+
# Search API v2+ fallback to v1:
834+
# - In v2+, /results_preview, /events and /results do not support search params.
835+
# - Fallback from v2+ to v1 if Splunk Version is < 9.
836+
# if api_version >= 2 and ('search' in query and path.endswith(tuple(["results_preview", "events", "results"])) or self.service.splunk_version < (9,)):
837+
# path = path.replace(PATH_JOBS_V2, PATH_JOBS)
838+
839+
if api_version == 1:
840+
if isinstance(path, UrlEncoded):
841+
path = UrlEncoded(path.replace(PATH_JOBS_V2, PATH_JOBS), skip_encode=True)
842+
else:
843+
path = path.replace(PATH_JOBS_V2, PATH_JOBS)
844+
806845
return self.service.get(path,
807846
owner=owner, app=app, sharing=sharing,
808847
**query)
@@ -855,13 +894,29 @@ def post(self, path_segment="", owner=None, app=None, sharing=None, **query):
855894
apps.get('nonexistant/path') # raises HTTPError
856895
s.logout()
857896
apps.get() # raises AuthenticationError
858-
"""
897+
"""
859898
if path_segment.startswith('/'):
860899
path = path_segment
861900
else:
862901
if not self.path.endswith('/') and path_segment != "":
863902
self.path = self.path + '/'
864903
path = self.service._abspath(self.path + path_segment, owner=owner, app=app, sharing=sharing)
904+
905+
# Get the API version from the path
906+
api_version = self.get_api_version(path)
907+
908+
# Search API v2+ fallback to v1:
909+
# - In v2+, /results_preview, /events and /results do not support search params.
910+
# - Fallback from v2+ to v1 if Splunk Version is < 9.
911+
# if api_version >= 2 and ('search' in query and path.endswith(tuple(["results_preview", "events", "results"])) or self.service.splunk_version < (9,)):
912+
# path = path.replace(PATH_JOBS_V2, PATH_JOBS)
913+
914+
if api_version == 1:
915+
if isinstance(path, UrlEncoded):
916+
path = UrlEncoded(path.replace(PATH_JOBS_V2, PATH_JOBS), skip_encode=True)
917+
else:
918+
path = path.replace(PATH_JOBS_V2, PATH_JOBS)
919+
865920
return self.service.post(path, owner=owner, app=app, sharing=sharing, **query)
866921

867922

@@ -2664,7 +2719,14 @@ def oneshot(self, path, **kwargs):
26642719
class Job(Entity):
26652720
"""This class represents a search job."""
26662721
def __init__(self, service, sid, **kwargs):
2667-
path = PATH_JOBS + sid
2722+
# Default to v2 in Splunk Version 9+
2723+
path = "{path}{sid}"
2724+
# Formatting path based on the Splunk Version
2725+
if service.splunk_version < (9,):
2726+
path = path.format(path=PATH_JOBS, sid=sid)
2727+
else:
2728+
path = path.format(path=PATH_JOBS_V2, sid=sid)
2729+
26682730
Entity.__init__(self, service, path, skip_refresh=True, **kwargs)
26692731
self.sid = sid
26702732

@@ -2718,7 +2780,11 @@ def events(self, **kwargs):
27182780
:return: The ``InputStream`` IO handle to this job's events.
27192781
"""
27202782
kwargs['segmentation'] = kwargs.get('segmentation', 'none')
2721-
return self.get("events", **kwargs).body
2783+
2784+
# Search API v1(GET) and v2(POST)
2785+
if self.service.splunk_version < (9,):
2786+
return self.get("events", **kwargs).body
2787+
return self.post("events", **kwargs).body
27222788

27232789
def finalize(self):
27242790
"""Stops the job and provides intermediate results for retrieval.
@@ -2806,7 +2872,11 @@ def results(self, **query_params):
28062872
:return: The ``InputStream`` IO handle to this job's results.
28072873
"""
28082874
query_params['segmentation'] = query_params.get('segmentation', 'none')
2809-
return self.get("results", **query_params).body
2875+
2876+
# Search API v1(GET) and v2(POST)
2877+
if self.service.splunk_version < (9,):
2878+
return self.get("results", **query_params).body
2879+
return self.post("results", **query_params).body
28102880

28112881
def preview(self, **query_params):
28122882
"""Returns a streaming handle to this job's preview search results.
@@ -2847,7 +2917,11 @@ def preview(self, **query_params):
28472917
:return: The ``InputStream`` IO handle to this job's preview results.
28482918
"""
28492919
query_params['segmentation'] = query_params.get('segmentation', 'none')
2850-
return self.get("results_preview", **query_params).body
2920+
2921+
# Search API v1(GET) and v2(POST)
2922+
if self.service.splunk_version < (9,):
2923+
return self.get("results_preview", **query_params).body
2924+
return self.post("results_preview", **query_params).body
28512925

28522926
def searchlog(self, **kwargs):
28532927
"""Returns a streaming handle to this job's search log.
@@ -2936,7 +3010,12 @@ class Jobs(Collection):
29363010
"""This class represents a collection of search jobs. Retrieve this
29373011
collection using :meth:`Service.jobs`."""
29383012
def __init__(self, service):
2939-
Collection.__init__(self, service, PATH_JOBS, item=Job)
3013+
# Splunk 9 introduces the v2 endpoint
3014+
if service.splunk_version >= (9,):
3015+
path = PATH_JOBS_V2
3016+
else:
3017+
path = PATH_JOBS
3018+
Collection.__init__(self, service, path, item=Job)
29403019
# The count value to say list all the contents of this
29413020
# Collection is 0, not -1 as it is on most.
29423021
self.null_count = 0
@@ -3774,4 +3853,4 @@ def batch_save(self, *documents):
37743853

37753854
data = json.dumps(documents)
37763855

3777-
return json.loads(self._post('batch_save', headers=KVStoreCollectionData.JSON_HEADER, body=data).body.read().decode('utf-8'))
3856+
return json.loads(self._post('batch_save', headers=KVStoreCollectionData.JSON_HEADER, body=data).body.read().decode('utf-8'))

splunkrc.spec

Lines changed: 0 additions & 12 deletions
This file was deleted.

tests/test_job.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,29 @@ def test_search_invalid_query_as_json(self):
382382
except Exception as e:
383383
self.fail("Got some unexpected error. %s" % e.message)
384384

385+
def test_v1_job_fallback(self):
386+
self.assertEventuallyTrue(self.job.is_done)
387+
self.assertLessEqual(int(self.job['eventCount']), 3)
388+
389+
preview_stream = self.job.preview(output_mode='json', search='| head 1')
390+
preview_r = results.JSONResultsReader(preview_stream)
391+
self.assertFalse(preview_r.is_preview)
392+
393+
events_stream = self.job.events(output_mode='json', search='| head 1')
394+
events_r = results.JSONResultsReader(events_stream)
395+
396+
results_stream = self.job.results(output_mode='json', search='| head 1')
397+
results_r = results.JSONResultsReader(results_stream)
398+
399+
n_events = len([x for x in events_r if isinstance(x, dict)])
400+
n_preview = len([x for x in preview_r if isinstance(x, dict)])
401+
n_results = len([x for x in results_r if isinstance(x, dict)])
402+
403+
# Fallback test for Splunk Version 9+
404+
if self.service.splunk_version[0] >= 9:
405+
self.assertGreaterEqual(9, self.service.splunk_version[0])
406+
self.assertEqual(n_events, n_preview, n_results)
407+
385408

386409
class TestResultsReader(unittest.TestCase):
387410
def test_results_reader(self):

tests/test_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ def test_parse(self):
102102
# objectified form of the results, but for now there's
103103
# nothing to test but a good response code.
104104
response = self.service.parse('search * abc="def" | dedup abc')
105+
106+
# Splunk Version 9+ using API v2: search/v2/parser
107+
if self.service.splunk_version[0] >= 9:
108+
self.assertGreaterEqual(9, self.service.splunk_version[0])
109+
105110
self.assertEqual(response.status, 200)
106111

107112
def test_parse_fail(self):

0 commit comments

Comments
 (0)