You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1072 lines
47 KiB
1072 lines
47 KiB
# Copyright (C) 2005-2024 Splunk Inc. All Rights Reserved.
|
|
"""
|
|
Low level interface for interacting with the kvstore
|
|
"""
|
|
import json
|
|
from urllib.parse import quote_plus
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
import splunk.rest as rest
|
|
from itsi_py3 import _
|
|
from splunk import ResourceNotFound, RESTException
|
|
|
|
LOGGER = "itoa.storage.statestore"
|
|
|
|
from ITOA.setup_logging import logger
|
|
from ITOA.itoa_common import get_conf_stanza_single_entry, get_object_batch_size
|
|
|
|
SUPPORTED_ITSI_OBJECT_TYPES = [
|
|
"backup_restore",
|
|
"base_service_template",
|
|
"custom_threshold_windows",
|
|
"deep_dive",
|
|
"entity",
|
|
"entity_filter_rule",
|
|
"entity_management_policies",
|
|
"entity_management_rules",
|
|
"entity_relationship",
|
|
"entity_relationship_rule",
|
|
"entity_type",
|
|
"event_management_state",
|
|
"glass_table",
|
|
"home_view",
|
|
"kpi",
|
|
"kpi_at_info",
|
|
"kpi_base_search",
|
|
"kpi_entity_threshold",
|
|
"kpi_template",
|
|
"kpi_threshold_template",
|
|
"saved_page",
|
|
"service",
|
|
"team",
|
|
"temporary_kpi",
|
|
"upgrade_readiness_prechecks",
|
|
"sandbox",
|
|
"sandbox_service",
|
|
"sandbox_sync_log",
|
|
"data_integration"
|
|
]
|
|
|
|
# For special handling of objects that aren't first-class objects in the KVstore
|
|
NONKVSTORE_OBJECT_TYPES = set([
|
|
"kpi",
|
|
])
|
|
|
|
|
|
class StateStoreError(Exception):
|
|
def __init__(self, message, status_code=None):
|
|
self.message = message
|
|
self.status_code = status_code
|
|
|
|
def __str__(self):
|
|
s = repr(self.message)
|
|
if self.status_code is not None:
|
|
s = "status={} ".format(self.status_code) + s
|
|
return s
|
|
|
|
|
|
class StateStore(object):
|
|
appname = "SA-ITOA"
|
|
|
|
# Per http://docs.splunk.com/Documentation/Splunk/6.5.0/RESTREF/RESTkvstore#Limits, 16 MB is max limit for document
|
|
# size in KV store and non-configurable
|
|
_max_document_size_limit_bytes = 16777216 # 16 * 1024 * 1024 = 16 MB
|
|
|
|
# Per http://docs.splunk.com/Documentation/Splunk/6.5.0/RESTREF/RESTkvstore#Limits, the max size per batch save in
|
|
# MB is set in the kvstore stanza in the limits.conf file with the name max_size_per_batch_save_mb
|
|
_max_size_per_batch_save = None
|
|
# Per http://docs.splunk.com/Documentation/Splunk/6.5.0/RESTREF/RESTkvstore#Limits, the max count of documents per
|
|
# batch save
|
|
_max_documents_per_batch_save = None
|
|
|
|
# Configurable field to set the timeout for each rest call this value is configurable in itsi_settings.conf
|
|
# under the Rest section. The default value is 300
|
|
|
|
_rest_timeout = None
|
|
|
|
def __init__(self, **kwargs):
|
|
# Defaults here
|
|
self.owner = "nobody"
|
|
self.app = kwargs.get("namespace") or self.appname
|
|
self.collectionname = kwargs.get("collection", "itsi_services")
|
|
|
|
def _set_batch_save_size_limit(self, session_key, host_base_uri=''):
|
|
"""
|
|
Fetches the max size per batch save if not already fetched
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param host_base_uri: The base uri <scheme>://<host>:<port> of the target host. '' targets local host.
|
|
@type host_base_uri: basestring
|
|
"""
|
|
# Sets static variable from limits conf file if not already set
|
|
if StateStore._max_size_per_batch_save is None:
|
|
resp = get_conf_stanza_single_entry(session_key, 'limits', 'kvstore', 'max_size_per_batch_save_mb',
|
|
host_base_uri=host_base_uri)
|
|
max_size_per_batch_save_mb = int(resp.get('content', 50))
|
|
# Used 0.99 of max_size_per_batch_save_mb in calculation _max_size_per_batch_save,
|
|
# As during the payload size calculation, extra comma and space will be added.
|
|
StateStore._max_size_per_batch_save = (0.99 * max_size_per_batch_save_mb) * 1024 * 1024
|
|
if StateStore._max_documents_per_batch_save is None:
|
|
resp = get_conf_stanza_single_entry(session_key, 'limits', 'kvstore', 'max_documents_per_batch_save',
|
|
host_base_uri=host_base_uri)
|
|
StateStore._max_documents_per_batch_save = int(resp.get('content', 1000))
|
|
|
|
def _set_rest_configurations(self, session_key, host_base_uri=''):
|
|
"""
|
|
Fetches the rest configuration if not already fetched
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param host_base_uri: The base uri <scheme>://<host>:<port> of the target host. '' targets local host.
|
|
@type host_base_uri: basestring
|
|
"""
|
|
# Sets static variable from itsi_settings conf file if not already set
|
|
if StateStore._rest_timeout is None:
|
|
try:
|
|
resp = get_conf_stanza_single_entry(session_key, 'itsi_settings', 'rest', 'rest_timeout',
|
|
host_base_uri=host_base_uri)
|
|
StateStore._rest_timeout = int(resp.get('content', 300))
|
|
except Exception:
|
|
StateStore._rest_timeout = 300
|
|
|
|
def check_payload_size(self, data_list, session_key, throw_on_violation=True, host_base_uri=''):
|
|
"""
|
|
Method to verify KV store payload size isnt larger than 16MB limit of per document size
|
|
|
|
@type: list
|
|
@param data_list: JSON list payload to verify
|
|
|
|
@type: boolean
|
|
@param throw_on_violation: True if violation should trigger exception, else returns bool indicating
|
|
if violation detected
|
|
|
|
@type host_base_uri: basestring
|
|
@param host_base_uri: The base uri <scheme>://<host>:<port> of the target host. '' targets local host.
|
|
|
|
@rtype: tuple (boolean, [int] or int)
|
|
@return: (True, array of starting and ending index of each batch) if no violation detected, (False, size causing
|
|
violation in bytes) if violation detected
|
|
"""
|
|
if not isinstance(data_list, list):
|
|
raise StateStoreError(_('JSON payload is invalid.'))
|
|
|
|
self._set_batch_save_size_limit(session_key, host_base_uri=host_base_uri)
|
|
|
|
save_ranges = []
|
|
cur_size = 0
|
|
first_index = 0
|
|
for idx, data in enumerate(data_list):
|
|
size_of_payload = sys.getsizeof(str(data))
|
|
if size_of_payload > self._max_document_size_limit_bytes:
|
|
if throw_on_violation:
|
|
raise StateStoreError(
|
|
_('Object you are trying to save is too large (%s bytes). KV store only supports '
|
|
'documents within 16MB sizes.') % size_of_payload
|
|
)
|
|
else:
|
|
# Return False indicating violation even if one object violates limits
|
|
return False, size_of_payload
|
|
cur_size += size_of_payload
|
|
# Check that you have not reached the document limit
|
|
if cur_size >= StateStore._max_size_per_batch_save or (
|
|
idx - first_index) == StateStore._max_documents_per_batch_save:
|
|
save_ranges.append((first_index, idx))
|
|
first_index = idx
|
|
cur_size = size_of_payload
|
|
save_ranges.append((first_index, len(data_list)))
|
|
|
|
return True, save_ranges
|
|
|
|
def lazy_init(self, session_key, host_base_uri=''):
|
|
'''
|
|
Method to initialize all the ITSI collections if it is done so.
|
|
Query the kvstore config uri with the collection name, initialize the collection if 404 exception is returned,
|
|
otherwise pass.
|
|
|
|
@param session_key: The splunkd session key
|
|
@type session_key: string
|
|
|
|
@param host_base_uri: The base uri <scheme>://<host>:<port> of the target host. '' targets local host.
|
|
@type host_base_uri: basestring
|
|
'''
|
|
LOG_PREFIX = "[lazy_init] "
|
|
entries = []
|
|
uri = host_base_uri + "/servicesNS/" + self.owner + "/" + self.app + "/storage/collections/config/" + \
|
|
self.collectionname
|
|
try:
|
|
response, content = rest.simpleRequest(
|
|
uri,
|
|
getargs={"output_mode": "json"},
|
|
sessionKey=session_key,
|
|
raiseAllErrors=False
|
|
)
|
|
|
|
parsed_content = json.loads(content)
|
|
entries = parsed_content.get('entry', [])
|
|
except ResourceNotFound:
|
|
logger.debug('%s does not exist, it could be a new collection, will try to create it.', self.collectionname)
|
|
except Exception as e:
|
|
logger.exception(str(e))
|
|
|
|
if len(entries) == 0:
|
|
# If it doesnt have the collection, we need to create it
|
|
postargs = {"name": self.collectionname}
|
|
postargs["output_mode"] = "json"
|
|
response, content = rest.simpleRequest(
|
|
uri,
|
|
method="POST",
|
|
postargs=postargs,
|
|
sessionKey=session_key,
|
|
raiseAllErrors=False
|
|
)
|
|
if response.status != 200 and response.status != 201:
|
|
logger.error("%s Unable to create collection=`%s`. URL=`%s`. Response=`%s`. Content=`%s`",
|
|
LOG_PREFIX, self.collectionname, uri, response, content)
|
|
else:
|
|
logger.debug("%s Created collection successfully.", LOG_PREFIX)
|
|
|
|
def is_available(self, session_key, host_base_uri=''):
|
|
"""
|
|
Tries to check the kvstore readiness via server info REST endpoint
|
|
Parse kvStoreStatus status from the Json response.
|
|
|
|
@param session_key: The splunkd session key
|
|
@type session_key: string
|
|
|
|
@param host_base_uri: The base uri <scheme>://<host>:<port> of the target host. '' targets local host.
|
|
@type host_base_uri: basestring
|
|
|
|
@returns true if response contains ready
|
|
@rtype bool
|
|
"""
|
|
kvStoreStatus = ''
|
|
uri = host_base_uri + '/services/server/info'
|
|
getargs = {'output_mode': 'json'}
|
|
response, content = rest.simpleRequest(uri, getargs=getargs, sessionKey=session_key, rawResult=True)
|
|
try:
|
|
parsed_content = json.loads(content)
|
|
if isinstance(parsed_content, dict):
|
|
entry = parsed_content.get('entry', [])
|
|
if entry[0]:
|
|
content = entry[0].get('content', {})
|
|
kvStoreStatus = content.get('kvStoreStatus', '')
|
|
except Exception:
|
|
pass
|
|
return 'ready' in kvStoreStatus
|
|
|
|
def create(self, session_key, owner, objecttype, data, host_base_uri=''):
|
|
"""
|
|
Create accepts different entity specifiers here, but can be reporposed for
|
|
other collection tasks
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param objecttype: The type of object we are attempting to create
|
|
@type objecttype: string
|
|
|
|
@param data: The dict data (suitable for json-ification)
|
|
@type data: dict
|
|
|
|
@param host_base_uri: The host to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
"""
|
|
LOG_PREFIX = "[create_statestore_" + objecttype + "]"
|
|
# Build the request
|
|
uri = host_base_uri + "/servicesNS/" + owner + "/" + self.app + "/storage/collections/data/" + \
|
|
self.collectionname
|
|
data['object_type'] = objecttype
|
|
response, content = rest.simpleRequest(
|
|
uri,
|
|
method="POST",
|
|
jsonargs=json.dumps(data),
|
|
sessionKey=session_key,
|
|
raiseAllErrors=False
|
|
)
|
|
if response.status != 200 and response.status != 201:
|
|
# Something failed in our request, raise an error
|
|
message = _("Unable to save {0}, request failed. ").format(objecttype)
|
|
logger.error('%s %s. response=`%s` content=`%s`', LOG_PREFIX, message, response, content)
|
|
raise StateStoreError(content, response.status)
|
|
try:
|
|
# What we'll get back here is of the form {"_key":"somelongnumber"} (note the quotes)
|
|
parsed_content = json.loads(content)
|
|
return parsed_content
|
|
except TypeError:
|
|
message = _("Unable to parse response from statestore for {0} {1}.").format(objecttype, data)
|
|
logger.exception(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
|
|
def get(self, session_key, owner, objecttype, identifier, sort_key=None, sort_dir=None, filter_data={}, fields=None,
|
|
skip=None, limit=None, host_base_uri=''):
|
|
"""
|
|
Retrieves the object specified by the identifier, which can be either internal or external
|
|
@param session_key: The splunkd session key
|
|
@type session_key: str
|
|
|
|
@param objecttype: The type of object (currently service or entity)
|
|
@type objecttype: str
|
|
|
|
@param identifier: The object's primary identifier, if None retrieves all objects of the selected type
|
|
@type identifier: str or None
|
|
|
|
@param sort_key: the field on which to ask the server to sort the results
|
|
@type sort_key: str
|
|
|
|
@param fields: An array of fields to be returned. This is an array that is used to limit the field set returned
|
|
@type fields: list
|
|
|
|
@param host_base_uri: The host to to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
"""
|
|
LOG_PREFIX = "[get_statestore_" + objecttype + "]"
|
|
self._set_rest_configurations(session_key, host_base_uri=host_base_uri)
|
|
uri = host_base_uri + "/servicesNS/" + owner + "/" + self.app + "/storage/collections/data/" + \
|
|
self.collectionname
|
|
get_args = None
|
|
|
|
if identifier is None:
|
|
filter_data = {} if filter_data is None else filter_data
|
|
endpoint = uri # Here we plan on getting all elements
|
|
# Pass in the filter_data and use the sort_key,sort_dir if defined
|
|
if sort_key is not None and sort_dir is None:
|
|
logger.error(LOG_PREFIX + "sort_key defined as {0} with no sort direction".format(sort_key))
|
|
elif sort_key is None and sort_dir is not None:
|
|
logger.error(LOG_PREFIX + "sort_dir defined as {0} with no sort key".format(sort_dir))
|
|
elif sort_key is not None and sort_dir is not None:
|
|
if sort_dir == "desc":
|
|
sort_dir = -1
|
|
else:
|
|
sort_dir = 1 # Default to ascending
|
|
get_args = {"sort": sort_key + ":" + str(sort_dir)}
|
|
|
|
filter_data["object_type"] = objecttype
|
|
# ITOA-2913
|
|
if "" in filter_data:
|
|
message = _("Empty field received - Rejecting filter.")
|
|
logger.error(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
|
|
if "filter_string" in filter_data:
|
|
logger.debug(LOG_PREFIX + "filter_string=%s", filter_data["filter_string"])
|
|
filter_data.update(filter_data["filter_string"])
|
|
del filter_data["filter_string"]
|
|
|
|
if "shared" in filter_data:
|
|
get_args = {} if get_args is None else get_args
|
|
get_args["shared"] = filter_data["shared"]
|
|
del filter_data["shared"]
|
|
|
|
if fields is not None:
|
|
get_args = {} if get_args is None else get_args
|
|
if "object_type" not in fields:
|
|
fields.append("object_type")
|
|
exclude = [field for field in fields if ':0' in field]
|
|
# Mongodb does not allow field inclusion and exclusion in a single query.
|
|
# The assumption is that if there is more than one field exclusion,
|
|
# the system will ignore the field inclusion.
|
|
if len(exclude) > 0:
|
|
final_fields = exclude
|
|
else:
|
|
final_fields = fields
|
|
get_args['fields'] = ','.join(final_fields)
|
|
|
|
if skip is not None:
|
|
get_args = {} if get_args is None else get_args
|
|
get_args['skip'] = skip
|
|
if limit is not None:
|
|
get_args = {} if get_args is None else get_args
|
|
get_args['limit'] = limit
|
|
|
|
# At this point, 'filter_data' should have only the data for the 'query' param
|
|
# Other params should be stored in 'get_args' and deleted from 'filter_data'
|
|
if len(filter_data) > 0:
|
|
get_args = {} if get_args is None else get_args
|
|
try:
|
|
get_args['query'] = json.dumps(filter_data)
|
|
logger.debug(LOG_PREFIX + "json.dumps successful, get_args=%s", get_args)
|
|
except ValueError:
|
|
logger.error(LOG_PREFIX + "error parsing json of query - query=%s", filter_data)
|
|
else:
|
|
endpoint = uri + "/" + quote_plus(identifier)
|
|
|
|
if objecttype in NONKVSTORE_OBJECT_TYPES:
|
|
# Only KPIs are supported for now
|
|
if identifier is None:
|
|
# pwu: Impossible to hit? Needs further investigation.
|
|
logger.info("KPI fetched with None identifier")
|
|
get_args["object_type"] = "service"
|
|
else:
|
|
endpoint = uri
|
|
get_args = {
|
|
"query": json.dumps({
|
|
"object_type": "service",
|
|
"kpis._key": identifier,
|
|
}),
|
|
}
|
|
|
|
content = "[]"
|
|
for retry in range(3):
|
|
try:
|
|
response, content = rest.simpleRequest(
|
|
endpoint,
|
|
method="GET",
|
|
sessionKey=session_key,
|
|
raiseAllErrors=True,
|
|
getargs=get_args,
|
|
timeout=self._rest_timeout
|
|
)
|
|
if 300 > response.status > 199:
|
|
break
|
|
else:
|
|
raise RESTException(response.status)
|
|
except ResourceNotFound:
|
|
logger.error("%s 404 Not Found on GET to %s", LOG_PREFIX, endpoint)
|
|
# Return None when something is not found
|
|
return None
|
|
except RESTException as e:
|
|
if e.statusCode == 503 and retry != 2:
|
|
logger.warn(
|
|
"%s status 503 on endpoint %s, assuming KV Store starting up and retrying request, retries=%s",
|
|
LOG_PREFIX, endpoint, retry)
|
|
time.sleep(2)
|
|
else:
|
|
logger.error("%s status %s on endpoint %s, raising exception", LOG_PREFIX, e.statusCode, endpoint)
|
|
raise
|
|
|
|
try:
|
|
parsed_content = json.loads(content)
|
|
if len(parsed_content) == 0:
|
|
parsed_content = {}
|
|
return parsed_content
|
|
except TypeError:
|
|
message = _("Unable to parse response from statestore for {0} {1}.").format(objecttype, identifier)
|
|
logger.exception(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
except ValueError:
|
|
message = _("Unable to decode response from statestore for {0} {1}.").format(objecttype, identifier)
|
|
logger.exception(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
|
|
def get_via_post_bulk(self, session_key, owner, objecttype, sort_key=None, sort_dir=None,
|
|
filter_data={}, fields=None, skip=None, limit=None, host_base_uri=""):
|
|
"""
|
|
Retrieves all objects that match the set of filters (the query)
|
|
|
|
@param session_key: The splunkd session key
|
|
@type session_key: str
|
|
|
|
@param owner: owner making the request
|
|
@type owner: str
|
|
|
|
@param objecttype: The type of object (currently service or entity)
|
|
@type objecttype: str
|
|
|
|
@param sort_key: the field on which to ask the server to sort the results
|
|
@type sort_key: str
|
|
|
|
@param sort_dir: Sorting direction (Ascending or Descending)
|
|
@type sort_dir: str
|
|
|
|
@param filter_data: Filters based on which objects get retrieved
|
|
@type filter_data: dict
|
|
|
|
@param fields: An array of fields to be returned. This is an array that is used to limit the field set returned
|
|
@type fields: list
|
|
|
|
@param skip: number of items to skip from the start
|
|
@type skip: str
|
|
|
|
@param limit: Limit (value) for how many objects should be retrieved.
|
|
@type limit: str
|
|
|
|
@param host_base_uri: The host to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
|
|
@return: List of records that are specified by the filters
|
|
@rtype: list
|
|
"""
|
|
LOG_PREFIX = "[get_statestore_" + objecttype + "]"
|
|
self._set_rest_configurations(session_key, host_base_uri=host_base_uri)
|
|
endpoint = host_base_uri + "/servicesNS/" + owner + "/" + self.app + \
|
|
"/storage/collections/data/" + self.collectionname + "/batch_find?responseFormat=map"
|
|
post_args = {}
|
|
filter_data = {} if filter_data is None else filter_data
|
|
# Here we plan on getting all elements
|
|
# Pass in the filter_data and use the sort_key,sort_dir if defined
|
|
if sort_key is not None and sort_dir is None:
|
|
logger.error(LOG_PREFIX + "sort_key defined as {0} with no sort direction".format(sort_key))
|
|
elif sort_key is None and sort_dir is not None:
|
|
logger.error(LOG_PREFIX + "sort_dir defined as {0} with no sort key".format(sort_dir))
|
|
elif sort_key is not None and sort_dir is not None:
|
|
if sort_dir == "desc":
|
|
sort_dir = -1
|
|
else:
|
|
sort_dir = 1 # Default to ascending
|
|
post_args = {"sort": [{sort_key: sort_dir}]}
|
|
|
|
filter_data["object_type"] = objecttype
|
|
|
|
if "" in filter_data:
|
|
message = _("Empty field received - Rejecting filter.")
|
|
logger.error(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
|
|
if "filter_string" in filter_data:
|
|
logger.debug(LOG_PREFIX + "filter_string=%s", filter_data["filter_string"])
|
|
filter_data.update(filter_data["filter_string"])
|
|
del filter_data["filter_string"]
|
|
|
|
if "shared" in filter_data:
|
|
if filter_data["shared"]:
|
|
post_args["shared"] = True
|
|
else:
|
|
post_args["shared"] = False
|
|
del filter_data["shared"]
|
|
|
|
if fields is not None:
|
|
if "object_type" not in fields:
|
|
fields.append("object_type")
|
|
exclude = [field for field in fields if ':0' in field]
|
|
# Mongodb does not allow field inclusion and exclusion in a single query.
|
|
# The assumption is that if there is more than one field exclusion,
|
|
# the system will ignore the field inclusion.
|
|
if len(exclude) > 0:
|
|
final_fields = exclude
|
|
# Fields which need to be excluded are provided as ['exclude1:0', 'exclude2:0],
|
|
# So, striping the last two characters (':0') from the field string
|
|
post_args["fields"] = {field[:len(field) - 2]: 0 for field in final_fields}
|
|
else:
|
|
final_fields = fields
|
|
post_args["fields"] = {field: 1 for field in final_fields}
|
|
|
|
# At this point, 'filter_data' should have only the data for the 'query' param
|
|
# Other params should be stored in 'post_args' and deleted from 'filter_data'
|
|
if len(filter_data) > 0:
|
|
try:
|
|
post_args['query'] = filter_data
|
|
logger.debug(LOG_PREFIX + "json.dumps successful, post_args=%s", post_args)
|
|
except ValueError:
|
|
logger.error(LOG_PREFIX + "error parsing json of query - query=%s", filter_data)
|
|
|
|
if skip is not None:
|
|
skip_index = int(skip)
|
|
else:
|
|
skip_index = 0
|
|
post_args['skip'] = skip_index
|
|
if limit is not None:
|
|
limit_val = int(limit)
|
|
else:
|
|
limit_val = 0
|
|
post_args['limit'] = limit_val
|
|
results_remaining = True
|
|
rv = []
|
|
while results_remaining:
|
|
for retry in range(3):
|
|
try:
|
|
response, content = rest.simpleRequest(
|
|
endpoint,
|
|
method="POST",
|
|
sessionKey=session_key,
|
|
raiseAllErrors=True,
|
|
# converting post_args variable to list as it is necessary for "batch_find" keyword
|
|
jsonargs=json.dumps([post_args]),
|
|
timeout=self._rest_timeout
|
|
)
|
|
|
|
if 300 > response.status > 199:
|
|
break
|
|
else:
|
|
raise RESTException(response.status)
|
|
except ResourceNotFound:
|
|
logger.error("%s 404 Not Found on GET to %s", LOG_PREFIX, endpoint)
|
|
# Return None when something is not found
|
|
return None
|
|
except RESTException as e:
|
|
if e.statusCode == 503 and retry != 2:
|
|
logger.warn(
|
|
"%s status 503 on endpoint %s, assuming KV Store starting up and retrying request,"
|
|
"retries=%s", LOG_PREFIX, endpoint, retry,
|
|
)
|
|
time.sleep(2)
|
|
else:
|
|
logger.error("%s status %s on endpoint %s, raising exception",
|
|
LOG_PREFIX, e.statusCode, endpoint)
|
|
raise
|
|
|
|
try:
|
|
parsed_content = json.loads(content)
|
|
if len(parsed_content) == 0:
|
|
results_remaining = False
|
|
else:
|
|
parsed_content = parsed_content[0]
|
|
# Old-style /batch_find endpoint returns a list
|
|
if type(parsed_content) is list:
|
|
rv = parsed_content
|
|
# On older versions of Splunk, we have no visibility
|
|
results_remaining = False
|
|
# New-style /batch_find endpoint returns a dict
|
|
else:
|
|
rv.extend(parsed_content['records'])
|
|
# To prevent an infinite loop if a single object can't be fetched
|
|
records_fetched = len(parsed_content['records'])
|
|
records_truncated = int(parsed_content.get('recordsTruncated', '0'))
|
|
if records_truncated and records_fetched == 0:
|
|
message = _(
|
|
'Unable to fetch full results from statestore for query {0}, fetched {1}/{2}'
|
|
).format(post_args, len(rv), len(rv) + records_truncated)
|
|
logger.error(LOG_PREFIX + message)
|
|
# 507: Insufficient Storage
|
|
raise RESTException(507)
|
|
elif records_truncated:
|
|
post_args['skip'] += records_fetched
|
|
else:
|
|
results_remaining = False
|
|
except TypeError:
|
|
message = _("Unable to parse response from statestore for {0}.").format(
|
|
objecttype)
|
|
logger.exception(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
except ValueError:
|
|
message = _("Unable to decode response from statestore for {0}.").format(
|
|
objecttype)
|
|
logger.exception(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
return rv
|
|
|
|
def edit(self, session_key, owner, objecttype, identifier, data, host_base_uri=''):
|
|
"""
|
|
Per the contract that we're defining, edit will only cover a single thing, and that thing will be found by its
|
|
id which in this case is the statestore id.
|
|
|
|
If no records are found, we will throw an error
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param objecttype: The type of object we are attempting to create - can currently be entity or service
|
|
@type objecttype: string
|
|
|
|
@param identifier: The id of the object
|
|
@type identifier: string
|
|
|
|
@param data: The dict data (suitable for json-ification)
|
|
@type data: dict
|
|
|
|
@param host_base_uri: The host to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
"""
|
|
LOG_PREFIX = "[edit_statestore_" + objecttype + "]"
|
|
curr_owner = owner
|
|
uri = host_base_uri + "/servicesNS/" + curr_owner + "/" + self.app + "/storage/collections/data/" + \
|
|
self.collectionname + "/" + quote_plus(identifier)
|
|
# These are the two fields that we require
|
|
if '_key' not in data:
|
|
data['_key'] = identifier
|
|
data['object_type'] = objecttype
|
|
|
|
response0 = self.get(session_key, curr_owner, objecttype, identifier, host_base_uri=host_base_uri)
|
|
if response0 is None or len(response0) == 0: # object doesn't exist under input namespace
|
|
if curr_owner != "nobody":
|
|
message = _("Cannot change permissions from 'app' to 'user' without cloning.")
|
|
logger.error(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
# find object (if possible) in other namespace
|
|
if curr_owner == "nobody":
|
|
curr_owner = data.get('acl').get('owner') if data.get('acl') else "nobody"
|
|
else:
|
|
curr_owner = "nobody"
|
|
response1 = self.get(session_key, curr_owner, objecttype, identifier, host_base_uri=host_base_uri)
|
|
if response1 is None or len(response1) == 0: # object doesn't exist in either namespace, error
|
|
message = _("Object with ID: %s does not exist in statestore.") % (identifier)
|
|
logger.error(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
else:
|
|
# DELETE object from original namespace
|
|
self.delete(session_key, curr_owner, objecttype, identifier, host_base_uri=host_base_uri)
|
|
# CREATE object in input name space
|
|
return self.create(session_key, owner, objecttype, data, host_base_uri=host_base_uri)
|
|
else: # found object under current namespace
|
|
response, content = rest.simpleRequest(
|
|
uri,
|
|
method="POST",
|
|
jsonargs=json.dumps(data),
|
|
sessionKey=session_key,
|
|
raiseAllErrors=True
|
|
)
|
|
# Here. we're being generous about what we choose to accept
|
|
if response.status not in (200, 201, 202):
|
|
message = _("Unable to edit {0} {1}.").format(objecttype, identifier)
|
|
logger.error('%s %s. Response=`%s` Content=`%s`', LOG_PREFIX, message, response, content)
|
|
raise StateStoreError(message + ' ' + str(content), response.status)
|
|
|
|
return {"_key": identifier}
|
|
|
|
def delete(self, session_key, owner, objecttype, identifier, host_base_uri=''):
|
|
"""
|
|
Deletes only the record at the specified endpoint, so it will look for that specific record
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param objecttype: The type of object we are attempting to create - can currently be entity or service
|
|
@type objecttype: string
|
|
|
|
@param identifier: The id of the object
|
|
@type identifier: string
|
|
|
|
@param host_base_uri: The host to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
"""
|
|
LOG_PREFIX = "[delete_statestore_" + objecttype + "]"
|
|
# First, we need to get the identifiers of the objects we plan on deleting
|
|
uri = host_base_uri + "/servicesNS/" + owner + "/" + self.app + "/storage/collections/data/" + \
|
|
self.collectionname + "/" + quote_plus(identifier)
|
|
try:
|
|
response, content = rest.simpleRequest(
|
|
uri,
|
|
method="DELETE",
|
|
sessionKey=session_key,
|
|
raiseAllErrors=False
|
|
)
|
|
except ResourceNotFound:
|
|
# We tried to delete something that doesnt exist, just continue
|
|
return
|
|
# Here we're being generous about what we choose to accept
|
|
if response.status not in (200, 201, 202):
|
|
message = _("Unable to delete {0} {1}.").format(objecttype, identifier)
|
|
logger.error('%s %s. Response=`%s`. Content=`%s`', LOG_PREFIX, message, response, content)
|
|
raise StateStoreError(message + ' ' + str(content), response.status)
|
|
|
|
def delete_all(self, session_key, owner, objecttype, filter_data, host_base_uri=''):
|
|
"""
|
|
Implements a bulk delete based on the object type and the other filter data that comes in.
|
|
While you can put in things without filterdata, in this case I am requiring it to not be empty.
|
|
Because that's how you delete your entire environment, and that isn't good
|
|
"""
|
|
LOG_PREFIX = "[delete_all_statestore_" + objecttype + "]"
|
|
if filter_data is None or len(filter_data) == 0:
|
|
logger.error(LOG_PREFIX + "filter data required for batch delete")
|
|
return
|
|
|
|
# ITOA-2913
|
|
if "" in filter_data:
|
|
message = _("Empty field received - Rejecting filter.")
|
|
logger.error(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
|
|
uri = host_base_uri + "/servicesNS/" + owner + "/" + self.app + "/storage/collections/data/" + \
|
|
self.collectionname
|
|
filter_data['object_type'] = objecttype
|
|
get_args = {}
|
|
try:
|
|
get_args['query'] = json.dumps(filter_data)
|
|
except ValueError:
|
|
logger.exception("error parsing json of query - aborting request - query=%s", filter_data)
|
|
return
|
|
try:
|
|
# get_args are used here because those are appended to the URI - so they work in this case for deletes
|
|
response, content = rest.simpleRequest(uri, method="DELETE", sessionKey=session_key,
|
|
getargs=get_args, raiseAllErrors=False)
|
|
del content
|
|
except ResourceNotFound:
|
|
# We tried to delete something that doesnt exist, just continue
|
|
logger.exception("error parsing json of query - aborting request - query=%s", filter_data)
|
|
return
|
|
# Here we're being generous about what we choose to accept
|
|
if response.status != 200:
|
|
logger.error(
|
|
LOG_PREFIX + "could not batch delete status={0} query={1}".format(response.status, filter_data))
|
|
return
|
|
|
|
def make_get_call_with_appropriate_method(self, session_key, owner, objecttype, sort_key=None, sort_dir=None, filter_data={}, fields=None,
|
|
skip=None, limit=None, host_base_uri=''):
|
|
"""
|
|
Makes Get call to fetch all of data associated with the object type with appropriate method
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param owner: owner making the request
|
|
@type owner: str
|
|
|
|
@param objecttype: The type of object we are attempting to create - can currently be entity or service
|
|
@type objecttype: string
|
|
|
|
@param sort_key: the field on which to ask the server to sort the results
|
|
@type sort_key: str
|
|
|
|
@param sort_dir: Sorting direction (Ascending or Descending)
|
|
@type sort_dir: str
|
|
|
|
@param filter_data: Currently unused filter data
|
|
@type filter_data: A dict of kv pairs that can be passed along to the backend for filtering.
|
|
|
|
@param fields: An array of fields to be returned. This is an array that is used to limit the field set returned
|
|
@type fields: list
|
|
|
|
@param skip: number of items to skip from the start
|
|
@type skip: str
|
|
|
|
@param limit: Limit (value) for how many objects should be retrieved.
|
|
@type limit: str
|
|
|
|
@param host_base_uri: The host to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
|
|
@return: List of records that are specified by the filters
|
|
@rtype: list
|
|
"""
|
|
if objecttype in SUPPORTED_ITSI_OBJECT_TYPES:
|
|
response = self.get_via_post_bulk(
|
|
session_key,
|
|
owner,
|
|
objecttype,
|
|
sort_key=sort_key,
|
|
sort_dir=sort_dir,
|
|
filter_data=filter_data,
|
|
fields=fields,
|
|
skip=skip,
|
|
limit=limit,
|
|
host_base_uri=host_base_uri
|
|
)
|
|
else:
|
|
response = self.get(
|
|
session_key,
|
|
owner,
|
|
objecttype,
|
|
None,
|
|
sort_key=sort_key,
|
|
sort_dir=sort_dir,
|
|
filter_data=filter_data,
|
|
fields=fields,
|
|
skip=skip,
|
|
limit=limit,
|
|
host_base_uri=host_base_uri
|
|
)
|
|
if response is None or len(response) == 0:
|
|
return []
|
|
return response
|
|
|
|
def get_all(self, session_key, owner, objecttype, sort_key=None, sort_dir=None, filter_data={}, fields=None,
|
|
skip=None, limit=None, host_base_uri=''):
|
|
"""
|
|
Gets all of the data associated with the object type; like get all entities or services
|
|
This function will subsequently call paged_get_all to fetch data in paginated format to remove limit=0 calls to collections
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param objecttype: The type of object we are attempting to create - can currently be entity or service
|
|
@type objecttype: string
|
|
|
|
@param filter_data: Currently unused filter data
|
|
@type filter_data: A dict of kv pairs that can be passed along to the backend for filtering.
|
|
"""
|
|
if objecttype in NONKVSTORE_OBJECT_TYPES:
|
|
# Only KPIs are supported for now
|
|
objecttype = "service"
|
|
|
|
if fields is not None:
|
|
unique_fields = set(fields)
|
|
fields = list(unique_fields)
|
|
|
|
# Calling paged get call to fetch all objects via pagination
|
|
results = self.paged_get_all(
|
|
session_key, owner, objecttype, sort_key=sort_key, sort_dir=sort_dir, filter_data=filter_data,
|
|
fields=fields, limit=limit, skip=skip, host_base_uri=host_base_uri,
|
|
)
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def _extract_content_error(content):
|
|
"""
|
|
Method takes in a content string from KV store API returned when API reports error
|
|
and returns extracted first error message from it
|
|
|
|
@type content: basestring
|
|
@param content: XML containing content with error message in <messages><msg>...</msg>...<messages>
|
|
Will return the first error message from the first element of type <msg>
|
|
|
|
@rtype: basestring
|
|
@return: extracted error message or a default if unavailable
|
|
"""
|
|
default_error = 'Unknown, check logs for details.'
|
|
match = re.search(r'<response>.*<messages>.*<msg.*>.*</msg>', content, flags=re.S)
|
|
if match is not None and match.group(0) is not None:
|
|
error_message = match.group(0)
|
|
return error_message[error_message.find('>', error_message.find('<msg')) + 1: error_message.find('</msg>')]
|
|
|
|
return default_error
|
|
|
|
def batch_save(self, session_key, owner, data, host_base_uri=''):
|
|
"""
|
|
WARNING: object_type must be set in everything or the data will be irretrievable with standard library
|
|
Create accepts different entity specifiers here, but can be repurposed for
|
|
other collection tasks
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param data: The list of dicts (suitable for json-ification)
|
|
@type data: list
|
|
|
|
@param host_base_uri: The host to run action. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
"""
|
|
condition, ranges = self.check_payload_size(data, session_key, host_base_uri=host_base_uri)
|
|
|
|
LOG_PREFIX = "[batch_save]"
|
|
if not isinstance(data, list):
|
|
message = _("Batch save requires input be a list, actual data input={0}").format(data)
|
|
logger.error(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
if len(data) == 0:
|
|
logger.warning("%s empty array passed to batch save, no op required", LOG_PREFIX)
|
|
return []
|
|
|
|
parsed_content = []
|
|
for range_var in ranges:
|
|
parsed_content.extend(self._execute_save_request(session_key, owner, data[range_var[0]:range_var[1]],
|
|
host_base_uri=host_base_uri))
|
|
|
|
return parsed_content
|
|
|
|
def _execute_save_request(self, session_key, owner, data, host_base_uri=''):
|
|
"""
|
|
Executes a save request for the specified data
|
|
|
|
@param session_key: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param owner: splunkd session key
|
|
@type session_key: string
|
|
|
|
@param data: The dict data (suitable for json-ification)
|
|
@type data: dict
|
|
|
|
@rtype: dict
|
|
@return: return value from the save request
|
|
|
|
@param host_base_uri: The host to run action. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
"""
|
|
LOG_PREFIX = "[batch_save]"
|
|
uri = host_base_uri + "/servicesNS/" + owner + "/" + self.app + "/storage/collections/data/" + \
|
|
self.collectionname + "/batch_save"
|
|
response, content = rest.simpleRequest(
|
|
uri,
|
|
method="POST",
|
|
jsonargs=json.dumps(data),
|
|
sessionKey=session_key,
|
|
raiseAllErrors=False
|
|
)
|
|
if response.status not in (200, 201):
|
|
details = StateStore._extract_content_error(str(content))
|
|
message = _("Batch save to KV store failed with code {0}. Error details: {1}").format(response.status,
|
|
details)
|
|
logger.error('%s %s. Response=`%s`. Content=`%s`', LOG_PREFIX, message, response, content)
|
|
raise StateStoreError(message)
|
|
try:
|
|
# What we'll get back here is of the form {"_key":"somelongnumber"} (note the quotes)
|
|
parsed_content = json.loads(content)
|
|
return parsed_content
|
|
except TypeError:
|
|
message = _("Unable to parse batch response from statestore for batch_edit")
|
|
logger.exception(LOG_PREFIX + message)
|
|
raise StateStoreError(message)
|
|
|
|
def paged_get_all(self, session_key, owner, object_type, sort_key=None, sort_dir=None, filter_data={}, fields=None,
|
|
skip=None, limit=None, host_base_uri=''):
|
|
"""
|
|
Paged implementation of get_all method to avoid limit=0 dependency
|
|
|
|
@param session_key: The splunkd session key
|
|
@type session_key: str
|
|
|
|
@param owner: owner making the request
|
|
@type owner: str
|
|
|
|
@param object_type: The type of object (currently service or entity)
|
|
@type object_type: str
|
|
|
|
@param sort_key: the field on which to ask the server to sort the results
|
|
@type sort_key: str
|
|
|
|
@param sort_dir: Sorting direction (Ascending or Descending)
|
|
@type sort_dir: str
|
|
|
|
@param filter_data: Filters based on which objects get retrieved
|
|
@type filter_data: dict
|
|
|
|
@param fields: An array of fields to be returned. This is an array that is used to limit the field set returned
|
|
@type fields: list
|
|
|
|
@param skip: number of items to skip from the start
|
|
@type skip: str
|
|
|
|
@param limit: Limit (value) for how many objects should be retrieved.
|
|
@type limit: str
|
|
|
|
@param host_base_uri: The host to run the REST request. '' defaults to localhost
|
|
@type host_base_uri: string
|
|
|
|
@return: List of records that are specified by the filters
|
|
@rtype: list
|
|
"""
|
|
LOG_PREFIX = "[paged_get_all_statestore_" + object_type + "]"
|
|
batch_size = get_object_batch_size(session_key, object_type=object_type)
|
|
requested_page = []
|
|
|
|
try:
|
|
skip = 0 if skip is None else int(skip)
|
|
except ValueError:
|
|
logger.error(LOG_PREFIX + "skip value should be valid integer instead passed {0}".format(skip))
|
|
|
|
try:
|
|
limit = 0 if limit is None else int(limit)
|
|
except ValueError:
|
|
logger.error(LOG_PREFIX + "limit value should be valid integer instead passed {0}".format(limit))
|
|
|
|
if 0 < limit <= batch_size:
|
|
requested_page = self.make_get_call_with_appropriate_method(session_key, owner, objecttype=object_type,
|
|
sort_key=sort_key, sort_dir=sort_dir,
|
|
filter_data=filter_data, fields=fields,
|
|
limit=limit,
|
|
skip=skip,
|
|
host_base_uri=host_base_uri)
|
|
|
|
elif limit == 0:
|
|
while True:
|
|
results = self.make_get_call_with_appropriate_method(session_key, owner, objecttype=object_type,
|
|
sort_key=sort_key, sort_dir=sort_dir,
|
|
filter_data=filter_data, fields=fields,
|
|
limit=batch_size,
|
|
skip=skip,
|
|
host_base_uri=host_base_uri)
|
|
if not results:
|
|
break
|
|
skip += batch_size
|
|
requested_page.extend(results)
|
|
|
|
elif limit > batch_size:
|
|
while limit > 0:
|
|
results = self.make_get_call_with_appropriate_method(session_key, owner, objecttype=object_type,
|
|
sort_key=sort_key, sort_dir=sort_dir,
|
|
filter_data=filter_data, fields=fields,
|
|
limit=min(limit, batch_size),
|
|
skip=skip,
|
|
host_base_uri=host_base_uri)
|
|
if not results:
|
|
break
|
|
limit -= batch_size
|
|
skip += batch_size
|
|
requested_page.extend(results)
|
|
|
|
return requested_page
|