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.

747 lines
32 KiB

# Copyright (C) 2005-2024 Splunk Inc. All Rights Reserved.
import sys
import re
import http.client
import json
import urllib.parse
from itsi_py3 import _
from splunk.clilib import bundle_paths
from splunk.clilib.bundle_paths import make_splunkhome_path
sys.path.append(make_splunkhome_path(['etc', 'apps', 'SA-ITOA', 'lib']))
from itsi.objects.object_manifest import object_manifest as itoa_objects
from ITOA.event_management.event_management_object_manifest import object_manifest as event_management_objects
from ITOA.controller_utils import ITOAError
from splunk import ResourceNotFound
import splunk.rest as rest
import os
import shutil
itoa_objects.update(event_management_objects)
class MissingCPConfigFile(Exception):
# When an expected config file is not found
pass
class InvalidCPVersion(Exception):
# When the content pack version is not valid
pass
class InvalidSplunkObjectFormat(Exception):
# When an invalid format splunk object is encountered
# expecting:
# {'name': 'xxxx', 'app':'yyyy'}
pass
class InvalidTitle(Exception):
# When the overview is too large to fit into kv store
pass
class InvalidDescription(Exception):
# When the description is too large to fit into kv store
pass
class InvalidOverview(Exception):
# When the overview is too large to fit into kv store
pass
class AuthorshipUtils(object):
def __init__(self, logger, session_key, authored_content_key='', content_pack_id='', cp_root_path=''):
self.logger = logger
self.session_key = session_key
self.authored_content_key = authored_content_key
self.content_pack_id = content_pack_id
self.cp_root_path = cp_root_path
def validate_itsi_objects_intact(self, itsi_objects={}, user_selected_objects={}):
"""
Validate if ITSI objects within a content authorship object is intact
Authorship has a list of references to different types of ITSI objects
Validates if the referencing ITSI objects still exist in the KVStore by retrieving object titles
@type: dict
@param itsi_objects: ITSI object references
eg:
{
'service': [
'9a7b10a1-52d8-4216-af15-711fd917a46e',
'dfsfa1-52d8-4216-af1dsfakljio-2394lk'
],
'kpi_base_search': ['DA-ITSI-APPSERVER_Performance_Runtime'],
'kpi_threshold_template': ['kpi_threshold_template_7'],
'base_service_template': [],
'glass_table': [],
'deep_dive': [],
'entity_type': ['k8s_node'],
'home_view': [],
'notable_aggregation_policy': [],
'correlation_search': ['correlation_search1'],
'event_management_state': [],
'team': []
}
@param user_selected_objects: Objects that the user clicked on specifically
e.g
user_selected_objects :
{
'itsi_objects':
{
'base_service_template': [],
'correlation_search': [],
'deep_dive': [],
'entity_type': [],
'event_management_state': [],
'glass_table': [],
'home_view': [],
'kpi_base_search': [],
'kpi_threshold_template': [],
'notable_aggregation_policy': [],
'service': [],
'team': []
},
'splunk_objects':
{
'dashboards': [],
'lookups': [],
'macros': [],
'props': [],
'savedsearches': [],
'transforms': []
}
}
@rtype: object {bool, dict, dict/None, dict}
@return: {is_intact, itsi_objects_with_title, all_missing_objects, final_user_selected_objects_itsi}
"""
itsi_objects_with_title = {
'service': [],
'kpi_base_search': [],
'kpi_threshold_template': [],
'base_service_template': [],
'glass_table': [],
'deep_dive': [],
'entity_type': [],
'home_view': [],
'notable_aggregation_policy': [],
'correlation_search': [],
'event_management_state': [],
'team': []
}
all_missing_objects = {}
for itsi_object_type, key_list in itsi_objects.items():
'''
Ignore kpi_threshold_template with id as "custom".
This is due to bad data which is reported by some customers over slack.
Refer files "kpi_thresholds_template_update_handler.py" and "itsi_migration.py" which mentions about relation between "custom" and KPI Threshold Templates
'''
if itsi_object_type == 'kpi_threshold_template':
key_list = [key for key in key_list if key != "custom"]
# if no values for this object type, do nothing
if not key_list:
continue
try:
if itsi_object_type == 'notable_aggregation_policy':
itoa_object_class = itoa_objects.get('notable_event_aggregation_policy')
obj = itoa_object_class(self.session_key, 'nobody')
results = obj.get_bulk(
key_list,
fields=['_key', 'title']
)
elif itsi_object_type == 'correlation_search':
itoa_object_class = itoa_objects.get(itsi_object_type)
obj = itoa_object_class(self.session_key, 'nobody')
results = obj.get_bulk(
key_list,
)
else:
itoa_object_class = itoa_objects.get(itsi_object_type)
obj = itoa_object_class(self.session_key, 'nobody')
modified_key_list = [
{'_key': key} for key in key_list
]
filter_data = {'$or': modified_key_list}
results = obj.get_bulk(
'nobody',
fields=['_key', 'title'],
filter_data=filter_data
)
if len(results) != len(key_list):
missing_objects = self._get_missing_objects(itsi_object_type, key_list, results)
all_missing_objects.update({itsi_object_type: missing_objects})
for item in results:
if itsi_object_type == 'correlation_search':
itsi_objects_with_title[itsi_object_type].append({
item.get('name'): item.get('name')
})
else:
itsi_objects_with_title[itsi_object_type].append({
item.get('_key'): item.get('title')
})
except Exception as e:
self.logger.exception(f'{self.authored_content_key} Failed to retrieve objects for object '
f'type {itsi_object_type}: exception {e}')
raise ITOAError(status=http.client.BAD_REQUEST,
message=_(f"Failed to retrieve content pack object from KVStore. "
f"Check the logs for details. type {itsi_object_type}: exception {e}"))
final_user_selected_objects = user_selected_objects
if all_missing_objects and len(all_missing_objects) > 0:
try:
if user_selected_objects:
temp_missing_object = all_missing_objects
for key in temp_missing_object.keys():
for item in temp_missing_object[key]:
if item not in itsi_objects_with_title[key] and item in final_user_selected_objects['itsi_objects'][key]:
final_user_selected_objects['itsi_objects'][key].remove(item)
return {'is_intact': False,
'itsi_objects_with_title' : itsi_objects_with_title,
'all_missing_objects': all_missing_objects,
'final_user_selected_objects_itsi': final_user_selected_objects
}
except Exception as e:
self.logger.exception(f'{self.authored_content_key} Failed to process missing objects '
f'for user_selected_objects. exception {e}')
raise ITOAError(status=http.client.BAD_REQUEST,
message=_(f"Failure during user_selected_objects missing object processing "
f"Check the logs for details. exception {e}"))
else:
return {'is_intact': True,
'itsi_objects_with_title' : itsi_objects_with_title,
'all_missing_objects': {},
'final_user_selected_objects_itsi': final_user_selected_objects
}
@staticmethod
def _get_missing_objects(itsi_object_type, expected_keys, actual_objects):
"""
Cross checking the list of expected objects listed in the content pack
authorship meta data against the list of objects actually existing on the
system and return the missing object list
:param itsi_object_type: type of itsi objects (i.e. key in object_manifest,
base_service_template, service, etc)
:param expected_keys: list of keys/ids/names listed in content pack authorship meta
for the given object_type
:param actual_objects: list of objects actually returned from querying the
expected_keys
:return: missing_keys
:rtype: list of _key or name (for correlation search)
"""
if itsi_object_type == 'correlation_search':
actual_keys = [item.get('name') for item in actual_objects]
else:
actual_keys = [item.get('_key') for item in actual_objects]
missing_keys = [key for key in expected_keys if key not in actual_keys]
return missing_keys
@staticmethod
def is_valid_cp_version(cp_version):
"""
Verify if the given content pack version string is valid
:param str cp_version: a content pack version string to be validated
:return: whether the version string is valid or not
:rtype: boolean
"""
pattern = re.compile(r'^([1-9]\d*|0)(\.\d+){2}$')
if pattern.fullmatch(cp_version):
return True
else:
return False
def _make_request(self, method, relative_url, args={}):
"""
Perform a request to a Splunk endpoint
pool.
@type: string
@param method: GET or POST
@type: string
@param relative_url:
@param: dict
@param args: args of the request
@returns: content of the request
@rtype: dict
@raises: exception
"""
try:
if method == 'GET':
path = rest.makeSplunkdUri() + relative_url
args['output_mode'] = 'json'
response, content = rest.simpleRequest(
path,
method='GET',
getargs=args,
sessionKey=self.session_key,
raiseAllErrors=False
)
self.logger.info('%s GET for %s response %s, content %s', self.authored_content_key, relative_url,
response, content)
content = json.loads(content)
return content
elif method == 'POST':
path = rest.makeSplunkdUri() + relative_url
args['output_mode'] = 'json'
response, content = rest.simpleRequest(
path,
method='POST',
postargs=args,
sessionKey=self.session_key,
raiseAllErrors=False
)
self.logger.info('%s POST for %s response %s, content %s, args %s', self.authored_content_key,
relative_url, response, content, args)
content = json.loads(content)
return content
except Exception as e:
self.logger.exception(
f'{self.authored_content_key} Encountered exception for method:{method} path:{path} exception:{e}')
raise e
def get_missing_splunk_objects(self, object_type, object_list_to_check):
"""find missing splunk objects in the content authorship metadata references
Args:
object_type (str): type of object (i.e: dashboards, lookups, props, etc)
object_list_to_check (_type_): the list of dashboards, lookups etc to check
Returns:
dict: dict of object_type to list of missing objects
"""
missing_object_list = []
if object_type == 'dashboards':
get_object_partial_path = 'servicesNS/nobody/{}/data/ui/views/{}'
# if the object_type is 'savedsearch', get only disabled property for that stanza
# since we are checking only if the object exists or not; not interested in all the
# data content
elif object_type == 'savedsearches':
get_object_partial_path = 'servicesNS/nobody/{}/properties/{}/{}/disabled'
else:
get_object_partial_path = 'servicesNS/nobody/{}/configs/conf-{}/{}'
for splunk_object in object_list_to_check:
object_name = splunk_object.get('name')
object_app = splunk_object.get('app')
if object_type == 'dashboards':
conf_object_full_path = get_object_partial_path.format(object_app,
urllib.parse.quote(object_name))
else:
conf_object_full_path = get_object_partial_path.format(object_app,
object_type,
urllib.parse.quote(object_name))
try:
self._make_request('GET', conf_object_full_path)
except ResourceNotFound:
missing_object_list.append({
'name': object_name,
'app': object_app
})
except Exception as e:
self.logger.exception(
f'{self.authored_content_key} Retrieving splunk object for object type:{object_type} '
f'in app:{object_app} with name:{object_name} '
f'encountered exception {e}'
)
return missing_object_list
def validate_splunk_objects_intact(self, splunk_objects={}, user_selected_objects={}):
"""
Validate if the splunk objects within a content authorship
object are present on the instance
@type: dict
@param splunk_objects: splunk object references
eg:
"splunk_objects": {
"dashboards": [
{
"name": "test_dashboard",
"app": "itsi"
}
],
"lookups": [
{
"name": "Entity.csv",
"app": "DA-ITSI-CP-vmware-dashboards"
},
{
"name": "well_known_ports.csv",
"app": "DA-ITSI-CP-aws-dashboards"
}
]
}
@param user_selected_objects: Objects that the user clicked on specifically
e.g
user_selected_objects :
{
'itsi_objects':
{
'base_service_template': [],
'correlation_search': [],
'deep_dive': [],
'entity_type': [],
'event_management_state': [],
'glass_table': [],
'home_view': [],
'kpi_base_search': [],
'kpi_threshold_template': [],
'notable_aggregation_policy': [],
'service': [],
'team': []
},
'splunk_objects':
{
'dashboards': [],
'lookups': [],
'macros': [],
'props': [],
'savedsearches': [],
'transforms': []
}
}
@rtype: object of bool, dict/None
@return: {is_intact, all_missing_objects}
"""
all_missing_objects = {}
final_user_selected_objects = user_selected_objects
temp_splunk_objects = splunk_objects
try:
for object_type, object_info_list in splunk_objects.items():
if not object_info_list:
continue
missing_objects = self.get_missing_splunk_objects(object_type, object_info_list)
if missing_objects and len(missing_objects) > 0:
all_missing_objects[object_type] = missing_objects
if all_missing_objects and len(all_missing_objects) > 0:
for keys in all_missing_objects.keys():
for item in all_missing_objects[keys]:
if item in splunk_objects[keys]:
temp_splunk_objects[keys].remove(item)
if final_user_selected_objects:
final_user_selected_objects['splunk_objects'][keys].remove(item)
return {'is_intact': False,
'all_missing_objects': all_missing_objects,
'splunk_objects': temp_splunk_objects,
'final_user_selected_objects_splunk': final_user_selected_objects,
}
else:
return {'is_intact': True,
'all_missing_objects': {},
'splunk_objects': temp_splunk_objects,
'final_user_selected_objects_splunk': final_user_selected_objects,
}
except Exception as e:
self.logger.exception(f'{self.authored_content_key}: Failed to retrieve splunk object. {e}')
raise ITOAError(status=http.client.BAD_REQUEST,
message=_(f'Failed to retrieve content pack splunk object. '
f'Check the logs for details. exception={e}'))
def is_custom_image_in_kvstore(self, source):
"""
Determine if the icon/image/choropleth is a custom icon/image/choropleth uploaded on kvstore
If the UUID of the source begins with "icon-" then it's default. If it's plain UUID, then it's custom.
@type source: str
@param source: source points to the URL or kvstore UUID of the visualization
@rtype: bool
@return: whether the source points to a custom image uploaded on kvstore
"""
if not source:
return False
if 'kvstore://' in source:
res = source.split("kvstore://", 1)
file_id = res[1]
if not file_id:
return False
# default icon ID will have UUID preceding a descriptive name like icon-active-directory__<uuid> or
# icon-datatacenter__<uuid>, whereas the ones uploaded by the customer will just be a uuid
if file_id.startswith('icon') or '__' in file_id:
self.logger.info("Content pack: %s. Following is not a custom image: %s",
self.authored_content_key, source)
return False
else:
self.logger.info("Content pack: %s. Following is a custom image: %s",
self.authored_content_key, source)
return True
return False
def is_image_on_disk(self, source):
"""
Determine if the icon/image/choropleth is stored locally on the disk
If the URL of the source starts with "/static" then we know that it's stored locally on the disk.
@type source: str
@param source: source points to the URL of the visualization
@rtype: bool
@returns: whether the source points to an icon/image/choropleth's static location locally on the disk
"""
if not source:
return False
# TODO: check if this works for Windows
if source.startswith('/static') or source.startswith('static') or source.startswith(r'\\static'):
return True
return False
def get_image_path(self, image_url):
"""
Returns the actual image file path based on the image URL provided
If the image_url is "/static/app/itsi/dashboard_images/background_image.jpeg",
the image path will be "$SPLUNK_HOME/etc/apps/itsi/appserver/static/dashboard_images/background_image.jpeg"
@type image_url: string
@param image_url: URL of the image location.
Example of image_url: "/static/app/itsi/dashboard_images/background_image.jpeg"
@rtype: string
@returns: the actual file path relative to $SPLUNK_HOME for the given image_url
"""
if not image_url:
return ""
pattern_match = ''
sub_image_url = ''
first_slash_idx = ''
if os.path.sep == '/':
# Linux-type system
pattern_match = re.search("static/app/", image_url)
sub_image_url = image_url[pattern_match.end():]
first_slash_idx = sub_image_url.find(os.path.sep)
else:
# Windows system
# TODO: check if this is working for Windows
pattern_match = re.search(r'static\\app\\', image_url)
sub_image_url = image_url[pattern_match.end():]
first_slash_idx = sub_image_url.find(os.path.sep)
app_name = sub_image_url[:first_slash_idx]
# Fetch the file path after the app name.
# For example, for "/static/app/itsi/dashboard_images/background_image.jpeg" the file path will be
# "dashboard_images/background_image.jpeg"
# In Windows, "\\" will be regarded as one character. Hence, below code ([first_slash_idx + 1:])
# should provide the desired index on both Linux-type and Windows environment.
file_path = sub_image_url[first_slash_idx + 1:]
full_image_path = bundle_paths.make_splunkhome_path(['etc', 'apps', app_name, 'appserver', 'static', file_path])
self.logger.info('Content pack: %s. Fetched full path for the given image url. '
'Image URL: %s. full image path: %s', self.authored_content_key, image_url, full_image_path)
return full_image_path
@staticmethod
def _extract_file_name_from_path(filepath):
"""
Takes a file path and returns the file name from that path. Assumption is that the value after
the last slash is the file name.
@type filepath: str
@param filepath: file path. Example: "/opt/etc/apps/itsi/appserver/static/images/image.jpeg"
@rtype: str
@returns: file name in the given file path. For the above example, it will return 'image.jpeg'
"""
if not filepath:
return ""
# remove the trailing slashes and then split the filepath
tokens = filepath.strip(os.path.sep).split(os.path.sep)
if len(tokens) > 1:
return tokens[len(tokens) - 1]
return ""
def extract_image(self, source, type):
"""
It determines what data should be embedded in the dashboard for the visualization's source.
If the source is custom KV Store UUID, then the source to be embedded in the dashboard would be base64 encoding
stored in the KV Store for the given UUID.
If the source is static file location, then the source to be embedded in the dashboard would be the static
file location inside the Content Pack's app. File location inside the content pack app would be:
"/static/app/{content_pack_id}/dashboard_images/{file_name}".
For all other remaining sources, they would remain as is.
@type source: str
@param source: Source of the icon/image/choropleth.
The source could point to one of the following: kvstore UUID, URL of local static location, base64 encoding
or an HTTP URL.
@rtype: str
@returns: source to be embedded in the dashboard.
The source to be embedded could be one of the following: default kvstore UUID, URL of local static location,
base64 encoding or an HTTP URL.
"""
if not source:
return ''
if self.is_custom_image_in_kvstore(source):
self.logger.info("Content pack: %s. This is a custom image is in kvstore. Image source: %s",
self.authored_content_key, source)
# get the image, get base64
res = source.split('kvstore://', 1)
file_id = res[1]
file_url = ""
if type and 'icon' in type:
# icons are stored in splunk-dashboard-icons collection
file_url = '/servicesNS/nobody/splunk-dashboard-studio/storage/collections/data/' \
'splunk-dashboard-icons/' + file_id
else:
# background images and images viz are stored in splunk-dashboard-images collection
file_url = '/servicesNS/nobody/splunk-dashboard-studio/storage/collections/data/' \
'splunk-dashboard-images/' + file_id
self.logger.info('Content pack: %s. Getting custom imgage from kv store. KV Store image url: %s',
self.authored_content_key, file_url)
response = self._make_request('GET', file_url)
self.logger.debug("Content pack: %s. Base64 encoded image from kvstore: %s", self.authored_content_key,
response)
base64_img = response['dataURI']
return base64_img
elif self.is_image_on_disk(source):
self.logger.info("Content pack: %s. Image is on disk. Image source: %s", self.authored_content_key, source)
# Copy the image to the Content Pack App's appserver/static/dashboard_images directory. Return the static
# location of the image relative to the Content Pack App's static directory.
if not source:
self.logger.warn('Content pack: %s. No URL provided for the image on disk', self.authored_content_key)
return ''
full_image_path = self.get_image_path(source)
cp_static_dashboard_images_path = os.path.join(self.cp_root_path, 'appserver', 'static', 'dashboard_images')
os.makedirs(cp_static_dashboard_images_path, exist_ok=True)
file_name = self._extract_file_name_from_path(full_image_path)
cp_static_file_path = os.path.join(cp_static_dashboard_images_path, file_name)
shutil.copy(full_image_path, cp_static_file_path)
self.logger.info('Content pack: %s. Local image copied over from %s to %s ', self.authored_content_key,
full_image_path, cp_static_file_path)
# path that will be embedded in the dashboard is "/static/app/{content_pack_id}/dashboard_image/{file_name}"
# static_path_under_cp = '/static/app/{}/dashboard_images/{}'.format(self.content_pack_id, file_name)
static_path_under_cp = os.path.join(os.path.sep, 'static', 'app', self.content_pack_id,
'dashboard_images', file_name)
self.logger.info('Content pack: %s. Local image static path to be embedded in dashboard: %s.',
self.authored_content_key, static_path_under_cp)
return static_path_under_cp
return source
def get_image_source(self, visualization):
"""
It replaces visualization's source with source that would work out of the box when the Content Pack App is
installed on a different instance.
@type visualization: dict
@param visualization: a visualization from the list of visualizations used in the dashboard
Visualization could be one of the following types: viz.img(or splunk.image), icon, choropleth, or layout with
backgroundImage
@rtype: dict
@returns: visualization with modified source for image/icon/choropleth.
"""
if not visualization:
return {}
source = ''
if 'type' in visualization:
type = visualization['type']
# icon svg (type is splunk.singlevalueicon)
if 'icon' in type:
source = None
if 'options' in visualization and 'icon' in visualization['options']:
source = visualization['options']['icon']
self.logger.info('Content pack: %s. Parsing dashboard. icon source: %s', self.authored_content_key,
source)
if source:
visualization['options']['icon'] = self.extract_image(source, type)
# image (type is viz.img or splunk.image)
elif ('viz.img' in type) or ('splunk.image' in type):
source = None
if 'options' in visualization and 'src' in visualization['options']:
source = visualization['options']['src']
self.logger.info('Content pack: %s. Parsing dashboard. viz.img (or splunk.image) source: %s',
self.authored_content_key, source)
if source:
visualization['options']['src'] = self.extract_image(source, type)
# choropleth svg (type is splunk.choropleth.svg)
elif 'choropleth' in type:
source = None
if 'options' in visualization and 'svg' in visualization['options']:
source = visualization['options']['svg']
self.logger.info('Content pack: %s. Parsing dashboard. choropleth source: %s',
self.authored_content_key, source)
if source:
visualization['options']['svg'] = self.extract_image(source, type)
# background image
elif 'options' in visualization and 'backgroundImage' in visualization['options']:
source = None
if ('options' in visualization) and ('backgroundImage' in visualization['options']) \
and ('src' in visualization['options']['backgroundImage']):
source = visualization['options']['backgroundImage']['src']
self.logger.info('Content pack: %s. Parsing dashboard. backgroundImg source: %s',
self.authored_content_key, source)
if source:
visualization['options']['backgroundImage']['src'] = self.extract_image(source, 'viz.img')
self.logger.info("Returning visualization: %s", visualization)
return visualization
@staticmethod
def _is_savedsearch_in_list(savedsearches_list, savedsearch):
'''
Check if the savedsearch being used in the Dashboard is present in the savedsearches_list provided
@type savedsearches_list: list of dict's
@param savedsearches_list: list of savedsearches which are specified to be part of the Content Pack
Example of the list:
[{'name': '10 Most Popular Executables Last Hour (UNIX - CPU)', 'app': 'DA-ITSI-CP-unix-dashboards'},
{'name': 'foo_warn', 'app': 'itsi'},
{'name': 'bar_error', 'app': 'itsi'}]
@type savedsearch: dict
@param savedsearch: savedsearch retrieved from dashboard.
In the dashboard the savedsearch name is called 'ref', but before sending it for checking here, 'ref' will be
changed to 'name'.
{'name': 'baz_search', 'app': 'itsi'}
@returns: whether the savedsearch is present in savedsearches_list
@rtype: bool
'''
if not savedsearches_list or len(savedsearches_list) == 0:
return False
name = savedsearch.get('name', None)
# if no app is specified then search is the default app
app = savedsearch.get('app', 'search')
for search in savedsearches_list:
s_name = search.get('name', None)
s_app = search.get('app', 'search')
if name == s_name and app == s_app:
return True
return False