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.

1839 lines
92 KiB

# Copyright (C) 2005-2024 Splunk Inc. All Rights Reserved.
import json
import time
from croniter import croniter
from datetime import datetime
import custom_threshold_windows.constants as CustomThresholdWindowConstants
from itsi_py3 import _
from ITOA.itoa_common import calculate_next_cron_time, get_current_utc_epoch, is_valid_dict, is_valid_list, \
is_valid_num, is_valid_str, normalize_num_field
from ITOA.itoa_object import ItoaObject, CRUDMethodTypes
from custom_threshold_windows.utils import calculate_custom_thresholds, get_human_readable_time_str
from ITOA.setup_logging import logger
from ITOA.itoa_exceptions import ItoaAccessDeniedError
from ITOA.controller_utils import ITOAError
from itsi.itsi_utils import ITOAInterfaceUtils, SplunkMessageHandler
from itsi.objects.itsi_service import ItsiService
def skip_validation_method(f):
"""
Decorator for skipping validation in a single ItsiCustomThresholdWindows method.
See validate_ctw_schema_skippable() for what this actually skips.
:param f: Function to decorate
:type f: function
:return: (function)
"""
def wrapper(self, *args, **kwargs):
existing_skip_validation = self.skip_validation
self.skip_validation = True
try:
return f(self, *args, **kwargs)
finally:
self.skip_validation = existing_skip_validation
return wrapper
class ItsiCustomThresholdWindows(ItoaObject):
"""
Implements ITSI Custom threshold window object type, which supersedes
existing KPI threshold configurations during specified, special time
periods. Custom threshold window exist in a one-to-many relationship where
a single CTW can be used by multiple KPIs.
The methods are organized into sections as follows:
* Class setup and validation methods
* Non-CRUD REST Endpoint methods (associate, disconnect, stop)
* CRUD operation methods
* Miscellaneous helper methods
* Delete handler methods
"""
collection_name = 'itsi_custom_threshold_windows'
log_prefix = '[ITSI Custom threshold windows]'
object_type = 'custom_threshold_windows'
delete_object_fields = ['_key', 'acl', 'sec_grp', '_immutable', 'status']
"""
Class setup and validation methods
"""
def __init__(self, session_key, current_user_name, skip_validation=False):
"""
:param session_key: Splunk access token.
:type session_key: basestring
:param current_user_name: Splunk user calling this class
:type current_user_name: basestring
:param skip_validation: Whether to skip schema-validation for performance improvements (internal-use only)
:type skip_validation: boolean
"""
super(ItsiCustomThresholdWindows, self).__init__(
session_key,
current_user_name,
object_type=self.object_type,
collection_name=self.collection_name,
title_validation_required=True
)
# Dependencies
self.message_handler = SplunkMessageHandler(session_key)
self.service_object = ItsiService(session_key, self.current_user_name)
# Permissioning
self.skip_validation = skip_validation
# For linking and unlinking, a less-restrictive set of permissions is required. This controls whether to check
# a potential update against the `can_edit` or the `can_link` permission
self.check_against_can_link_instead = False
# For transitioning states, a less-restrictive set of permissions is required. This controls whether to check
# a potential update against the `can_edit` or the `can_transition` permission
self.check_against_can_transition_instead = False
def do_object_validation(self, owner, objects, validate_name=True, dupname_tag=None, transaction_id=None,
skip_local_failure=False):
"""
Object validation routine (overwrites ItoaObject.do_object_validation())
:param objects: Objects to validate
:type objects: list
:param dupname_tag: A special tag to the duplicated titles. This is sometimes used to identify the calling
process (a bit of a workaround).
:type dupname_tag: basestring
"""
super(ItsiCustomThresholdWindows, self).do_object_validation(owner, objects, validate_name, dupname_tag,
transaction_id, skip_local_failure)
# Skip validation for trusted internal process (backup/restore)
existing_skip_validation = self.skip_validation
if dupname_tag == CustomThresholdWindowConstants.DUPNAME_TAG_MIGRATION:
# This flag is only used in do_additional_setup().
# do_object_validation() is always followed by do_additional_setup() in the code.
self.skip_validation = True
try:
self.validate_ctw_schema(objects)
finally:
self.skip_validation = existing_skip_validation
def do_additional_setup(self, owner, objects, req_source='unknown', method=CRUDMethodTypes.METHOD_UPSERT,
transaction_id=None, skip_local_failure=False):
"""
Additional setup performed during edit/create operations (overwrites ItoaObject.do_additional_setup()).
This is called immediately after ItoaObject.do_object_validation().
This method is called for CREATE, UPDATE, and UPSERT.
:param owner: Splunk user to own object
:type owner: basestring
:param objects: Objects to setup
:type objects: list
:param method: CRUD method that we preform the setup for
:type method: CRUDMethodTypes enum (basestring)
:param transaction_id: Transaction ID for logging
:type transaction_id: basestring
"""
self.attach_security_groups(owner, objects, transaction_id)
# Check that the CTW to edit can be edited
# Note: can_delete permissions handled by overwritten delete()
for json_data in objects:
if method == CRUDMethodTypes.METHOD_UPDATE or method == CRUDMethodTypes.METHOD_UPSERT:
try:
ctw_to_update = self.get(owner, json_data.get('_key', ''))
# Updating merges the object data before saving, so we need a separate mechanism of figuring out
# if the change was merely KPI linkages or a change to the object itself
if self.check_against_can_link_instead:
self.check_ctw_permissions(ctw_to_update, 'can_link')
elif self.check_against_can_transition_instead:
self.check_ctw_permissions(ctw_to_update, 'can_transition')
else:
self.check_ctw_permissions(ctw_to_update, 'can_edit')
except ITOAError as e:
# UPSERT allows creation of new objects
if method == CRUDMethodTypes.METHOD_UPSERT and e.status == 404:
continue
raise e
# Check if the duration field provided is a non negative integer
duration = json_data.get('duration')
if duration and int(duration) < 0:
error_message = "Error:'Must be a positive integer ({})." \
.format('duration')
self.raise_error(logger, _(error_message), 403)
def attach_security_groups(self, owner, custom_threshold_windows, transaction_id):
"""
Attach inferred security groups to CTW (from services).
:param owner: Splunk user to own object
:type owner: basestring
:param custom_threshold_windows: CTWs to update
:type custom_threshold_windows: list
:param transaction_id: Transaction ID for logging
:type transaction_id: basestring
"""
# This interface uses `owner` instead of the actual user since the actual user may not have access to all
# services attached to a shared CTW. This shouldn't be a security risk as this function is only called
# internally, before a CREATE, UPDATE, or UPSERT in itoa_object.py.
service_interface = ItsiService(self.session_key, owner)
for threshold_window in custom_threshold_windows:
associated_sec_grps = []
# Validate linked objects
linked_services = threshold_window.get('linked_services', [])
for linked_service in linked_services:
try:
service = service_interface.get(owner,
linked_service.get("service_id"),
req_source="CustomThresholdWindowsGet",
transaction_id=transaction_id)
if service is None:
raise ITOAError(status="404", message=_("Linked service not found."))
if not service.get("permissions", {}).get("write", False):
raise ITOAError(status="403", message=_("Service write permission denied."))
if service.get('sec_grp') and not service["sec_grp"] in associated_sec_grps:
associated_sec_grps.append(service["sec_grp"])
except ItoaAccessDeniedError:
raise ITOAError(status="403", message=_("Service permission denied."))
if len(associated_sec_grps) == 0:
threshold_window['sec_grp_list'] = [self._get_security_enforcer().get_default_itsi_security_group_key()]
else:
threshold_window['sec_grp_list'] = associated_sec_grps
def validate_ctw_schema(self, custom_threshold_windows):
"""
Method for validating custom_threshold_window objects and potentially fixing any required fields in the expected
schema of the CTW object.
@type custom_threshold_windows: object list
@param custom_threshold_windows: list of custom threshold objects being created/updated
@return None - exceptions to be raised upon failed validations
"""
def validate_ctw_schema_skippable(custom_threshold_window):
"""
Helper function that runs the skippable validations. This function sets default values and runs the check
for creating CTWs in the past (user shouldn't be able to skip this check, but backup-restores should).
:param custom_threshold_window: CTW object
:type custom_threshold_window: dict
"""
if not is_valid_dict(custom_threshold_window):
self.raise_error_bad_validation(
logger,
_('Invalid custom threshold window specified. Custom Threshold Windows must be in dict format.')
)
for bool_field in ['recurrence']:
if (
(bool_field not in custom_threshold_window)
or (not isinstance(custom_threshold_window[bool_field], bool))
):
custom_threshold_window[bool_field] = False
for str_field in ['description', 'pause_description', 'cron_schedule', 'window_type', 'status']:
if (
(str_field not in custom_threshold_window)
or (not is_valid_str(custom_threshold_window[str_field]))
):
if str_field == 'window_type':
custom_threshold_window[str_field] = CustomThresholdWindowConstants.TYPE_PERCENTAGE
elif str_field == 'status':
custom_threshold_window[str_field] = CustomThresholdWindowConstants.STATUS_SCHEDULED
else:
custom_threshold_window[str_field] = ''
for num_field in ['duration', 'start_time', 'window_config_percentage']:
if (
(num_field not in custom_threshold_window)
or (not is_valid_num(custom_threshold_window[num_field]))
):
if num_field == 'duration':
custom_threshold_window[num_field] = 24
elif num_field == 'window_config_percentage':
custom_threshold_window[num_field] = 10
elif num_field == 'start_time':
# Default start to be 24 hours from now
default_start = 86400
custom_threshold_window[num_field] = int(datetime.now().timestamp() + default_start)
if 'window_config_static' not in custom_threshold_window:
custom_threshold_window['window_config_static'] = {}
# Percentages should be between -200 and 200
if abs(int(custom_threshold_window.get('window_config_percentage', 0))) > 200:
custom_threshold_window['window_config_percentage'] = 200 \
if custom_threshold_window['window_config_percentage'] > 0 else -200
# Validate start time for non-recurring window
if not custom_threshold_window.get('recurrence') and \
threshold_window['start_time'] < get_current_utc_epoch():
self.raise_error_bad_validation(
logger,
_('Start time cannot be in the past for a custom threshold window (%s).' %
custom_threshold_window.get('title')),
)
for threshold_window in custom_threshold_windows:
# this will be set to false only by associate KPI calls so no need to do validation
if not self.skip_validation:
validate_ctw_schema_skippable(threshold_window)
# If recurring window and somehow status was set to completed, change back to scheduled
if threshold_window['status'] == CustomThresholdWindowConstants.STATUS_COMPLETED and \
threshold_window['recurrence']:
threshold_window['status'] = CustomThresholdWindowConstants.STATUS_SCHEDULED
# Fill in missing times in scheduled CTWs
if threshold_window.get('status') == CustomThresholdWindowConstants.STATUS_SCHEDULED:
next_scheduled_time = None
if not threshold_window.get('start_time', 0):
next_scheduled_time = self.calculate_next_scheduled_time(threshold_window)
threshold_window['start_time'] = next_scheduled_time
if not threshold_window.get('next_scheduled_time', 0):
next_scheduled_time = next_scheduled_time or self.calculate_next_scheduled_time(threshold_window)
threshold_window['next_scheduled_time'] = next_scheduled_time
if not threshold_window.get('end_time', 0):
threshold_window['end_time'] = self.calculate_end_time(threshold_window)
# Time field normalization
normalize_num_field(threshold_window, 'start_time')
normalize_num_field(threshold_window, 'next_scheduled_time')
normalize_num_field(threshold_window, 'end_time')
# Security check to avoid basing permissions on passed-in fields (e.g. with updates)
if 'permissions' in threshold_window:
threshold_window.pop('permissions', None)
def check_ctw_permissions(self, ctw_to_validate, requested_permission, service_kpis_dicts=[]):
"""
Method to check that a user can perform a given operation on a CTW.
@type ctw_to_validate: Custom Threshold Windows object
@param ctw_to_validate: object to validate
@type requested_permission: basestring
@param requested_permission: requested permissions
@type service_kpis_dicts: list
@param service_kpis_dicts: list of service-kpi dicts to be unlinked from CTW
@return None - Exception raised if operation cannot be done
"""
ctw_title = ctw_to_validate.get('title')
status = ctw_to_validate['status']
if requested_permission not in CustomThresholdWindowConstants.CAN_PERMISSIONS:
error_message = 'Unknown custom threshold window permission requested (%s)' % requested_permission
self.raise_error(logger, _(error_message), 500)
if self.current_user_name != 'nobody':
if not ctw_to_validate['permissions']['can_view']:
error_message = "{ \"error_code\": \"403\", \"message\": \"User does not have permission to view custom threshold window.\"}"
self.raise_error(logger, _(error_message), 403)
elif requested_permission == 'can_link' and status not in CustomThresholdWindowConstants.LINKABLE_STATES:
error_message = "The KPI(s) can't be linked / unlinked because the custom threshold window ({}) is " \
"in the {} state.".format(ctw_title, status)
self.raise_error(logger, _(error_message), 422)
elif requested_permission == 'can_edit' and status not in CustomThresholdWindowConstants.EDITABLE_STATES:
error_message = "The custom threshold window ({}) can't be edited because it is in the {} state." \
.format(ctw_title, status)
self.raise_error(logger, _(error_message), 422)
elif requested_permission == 'can_transition' and status not in \
CustomThresholdWindowConstants.TRANSITIONABLE_STATES:
error_message = "The custom threshold window ({}) can't be transitioned because it is in the {} " \
"state.".format(ctw_title, status)
self.raise_error(logger, _(error_message), 422)
elif requested_permission == 'can_delete' and status not in \
CustomThresholdWindowConstants.DELETEABLE_STATES:
error_message = "The custom threshold window ({}) can't be deleted because it is in the {} state."\
.format(ctw_title, status)
self.raise_error(logger, _(error_message), 422)
elif (requested_permission == 'can_link' and not ctw_to_validate['permissions']['can_link']):
error_message = "Error: User does not have permission to link to custom threshold window " \
"({}).".format(ctw_title)
self.raise_error(logger, _(error_message), 403)
elif requested_permission == 'can_unlink' and 'can_unlink' in ctw_to_validate['permissions']:
if not service_kpis_dicts:
error_message = "Error: Not enough data passed in to unlink KPIs from custom " \
"threshold window ({}).".format(ctw_title)
self.raise_error(logger, _(error_message), 500)
for service_and_kpis in service_kpis_dicts:
ids_of_kpis_to_disconnect = service_and_kpis.get('linked_kpi_ids')
for kpi_key in ids_of_kpis_to_disconnect:
# Bad data can be passed in. Validate this
if ctw_to_validate['permissions']['can_unlink'].get(kpi_key) is None:
error_message = "Error: Custom Threshold Window ({}) does not have reference to KPI with ID ({}) or user does not have permission to unlink to custom threshold window.".format(ctw_title, kpi_key)
self.raise_error(logger, _(error_message), 403)
elif requested_permission == 'can_edit' and not ctw_to_validate['permissions']['can_edit']:
error_message = "Error: User does not have permission to edit custom threshold window ({})." \
.format(ctw_title)
self.raise_error(logger, _(error_message), 403)
elif requested_permission == 'can_transition' and not ctw_to_validate['permissions']['can_transition']:
error_message = 'Error: User does not have permission to transition custom threshold window ({}).' \
.format(ctw_title)
self.raise_error(logger, _(error_message), 403)
elif requested_permission == 'can_delete' and not ctw_to_validate['permissions']['can_delete']:
error_message = "Error: User does not have permission to delete custom threshold window ({})." \
.format(ctw_title)
self.raise_error(logger, _(error_message), 403)
def validate_ctw_inputs(self, data, is_create_call=False, is_partial_data=False):
"""
Helper function implemented to check for inputs passed by user for rest calls.
We check inputs like linked_services and KPis must not duplicates.
"""
if is_partial_data:
if data.get('end_time', None) is not None:
if data.get('start_time') is None and data.get('cron_schedule') is None:
raise ITOAError(status='400', message=_('Custom threshold window update operation cannot update '
'end_time.'))
linked_services = data.get('linked_services', [])
if is_create_call and not linked_services:
raise ITOAError(status='400', message=_('Custom threshold window (%s) should contain at least one '
'linked service.') % data.get('title', 'N/A'))
service_id_list = set()
for linked_service in linked_services:
service_id = linked_service.get('service_id', 'undefined')
if (service_id in service_id_list):
raise ITOAError(status='400', message=_('Custom threshold window (%s) should not contain duplicates in '
'linked services.') % data.get('title', 'N/A'))
else:
service_id_list.add(service_id)
linked_kpi_ids = linked_service.get('linked_kpi_ids', [])
if ITOAInterfaceUtils.check_if_duplicates(linked_kpi_ids):
raise ITOAError(status='400', message=_('Custom threshold window (%s) should not contain duplicates in '
'Kpis.') % data.get('title', 'N/A'))
"""
Non-CRUD REST Endpoint methods
"""
@skip_validation_method
def associate_ctw_kpis(self, owner, object_id, transaction_id=None, **kwargs):
"""
This function is used to associate the service and its Kpi's to
CTW object.
@type: string
@param owner: Splunk user to own object
@type object_id: string
@param object_id: id of CTW object to retrieve
@type transaction_id: string
@param transaction_id: unique identifier of a user transaction
@type: dict
@param **kwargs: key word arguments extracted from request
@rtype: dictionary
@return: object with updated association, throws exceptions on errors
"""
ctw_to_update = self.get(owner, object_id, req_source='get_ctw_object', transaction_id=transaction_id)
self.check_ctw_permissions(ctw_to_update, 'can_link')
# In the case of a shared CTW, the user linking may not have access to all linked services. However, we still
# need to attach those as we can do a partial update on a object but not on an object's field.
ctw_to_update_as_nobody = ItoaObject.get(self, 'nobody', object_id)
ctw_to_update['linked_services'] = ctw_to_update_as_nobody['linked_services']
service_interface = ItsiService(self.session_key, self.current_user_name)
payload = kwargs.get('data') if 'data' in kwargs else json.loads(kwargs.get('payload'))
if 'custom_threshold_window' in payload:
custom_threshold_window_from_payload = payload['custom_threshold_window']
ctw_to_update = self.update_ctw_object_with_values_received(ctw_to_update,
custom_threshold_window_from_payload)
services_in_payload = payload.get('services', [])
ctw_services_objs_to_update = ctw_to_update.get('linked_services', [])
list_of_service_ids_in_ctw_obj = []
if len(ctw_services_objs_to_update) != 0:
list_of_service_ids_in_ctw_obj = self.populate_services_associated_to_ctw(ctw_services_objs_to_update)
for service in services_in_payload:
ctw_services_obj = {}
service_obj = service_interface.get(owner, service.get('_key'),
req_source='get_bulk',
transaction_id=transaction_id)
ctw_services_obj['service_id'] = service.get('_key')
if service_obj is not None:
kpis_in_object = service_obj.get('kpis', [])
kpi_ids_in_object = [kpi_object.get('_key') for kpi_object in kpis_in_object]
kpis_in_payload = service.get('kpi_ids')
if set(kpis_in_payload).issubset(set(kpi_ids_in_object)):
ctw_services_obj['linked_kpi_ids'] = kpis_in_payload
else:
missing_kpis = list(set(kpis_in_payload).difference(set(kpi_ids_in_object)))
self.raise_error_bad_validation(
logger,
_("The specified KPIs are not part of the service object {}".format(missing_kpis))
)
if service.get('_key') in list_of_service_ids_in_ctw_obj:
for tmp_ctw_obj in ctw_services_objs_to_update:
if tmp_ctw_obj.get('service_id') == service.get('_key'):
kpi_ids = tmp_ctw_obj.get('linked_kpi_ids')
for str in ctw_services_obj.get('linked_kpi_ids'):
if str not in kpi_ids:
kpi_ids.append(str)
tmp_ctw_obj['linked_kpi_ids'] = kpi_ids
ctw_services_objs_to_update[ctw_services_objs_to_update.index(tmp_ctw_obj)] = tmp_ctw_obj
elif ctw_services_obj not in ctw_services_objs_to_update:
ctw_services_objs_to_update.append(ctw_services_obj)
else:
self.raise_error_bad_validation(
logger,
_("Not able to retrive the Service object %s", service.get('_key'))
)
ctw_to_update['linked_services'] = ctw_services_objs_to_update
# Prevent KPI association from deleting fields in the CTW
ctw_to_update.pop('_marked_for_delete', None)
try:
self.check_against_can_link_instead = True
ctw_object = self.update(owner, object_id, ctw_to_update, is_partial_data=True)
finally:
self.check_against_can_link_instead = False
return ctw_object
@skip_validation_method
def disconnect_kpis_from_custom_threshold_window(self, owner, ctw_id=None, **kwargs):
"""
This function is used to disconnect KPIs from a CTW object
@type: string
@param owner: Splunk user to own object
@type ctw_id: string
@param ctw_id: Id of CTW object to retrieve
@type: dict
@param kwargs: Key word arguments extracted from request
@rtype: dictionary
@return: Updated custom threshold object, post-disconnect. Throws exceptions on errors
Payload will look like:
{
"data": {
"service_kpis_dict": [
{
"service_id": "fake service id",
"linked_kpi_ids": [
"fake kpi id"
]
}
]
}
}
"""
ctw_to_update = self.get(owner, ctw_id)
list_of_service_kpis_dicts = kwargs.get('data', {}).get('service_kpis_dict', [])
self.check_ctw_permissions(ctw_to_update, 'can_unlink', list_of_service_kpis_dicts)
# In the case of a shared CTW, the user linking may not have access to all linked services. However, we still
# need to attach those as we can do a partial update on a object but not on an object's field.
ctw_to_update_as_nobody = ItoaObject.get(self, 'nobody', ctw_id)
ctw_to_update['linked_services'] = ctw_to_update_as_nobody['linked_services']
ctw_linked_services = ctw_to_update.get('linked_services', [])
ctw_linked_services_keys = [linked_service.get('service_id') for linked_service in ctw_linked_services]
# This should not be triggered since ctws need at least 1 service/kpi linked at creation but we'll keep it just in case
if not ctw_linked_services_keys:
error_message = 'Error: Custom threshold windows object has no linked_services entries.'
self.raise_error_bad_validation(
logger,
_(error_message)
)
service_interface = ItsiService(self.session_key, self.current_user_name)
for service_kpis_dict in list_of_service_kpis_dicts:
# Check that CTW has reference to service in linked_services
dict_service_id = service_kpis_dict.get('service_id')
if dict_service_id not in ctw_linked_services_keys:
error_message = 'Error: Custom threshold window does not have reference to service ' \
'with ID {} in linked_services field.'.format(dict_service_id)
self.raise_error_bad_validation(
logger,
_(error_message)
)
service_obj = service_interface.get(owner, dict_service_id)
ids_of_kpis_to_disconnect = service_kpis_dict.get('linked_kpi_ids')
kpis_in_service_obj = service_obj.get('kpis')
kpi_keys_in_service_dict = {}
for kpi in kpis_in_service_obj:
kpi_keys_in_service_dict[kpi.get('_key')] = kpi
# Grab linked_kpi_ids from ctw
linked_kpi_ids_in_ctw_for_service = []
for linked_service in ctw_linked_services:
if dict_service_id == linked_service.get('service_id'):
linked_kpi_ids_in_ctw_for_service = linked_service.get('linked_kpi_ids', [])
break
for id_of_kpi_to_disconnect in ids_of_kpis_to_disconnect:
kpi = kpi_keys_in_service_dict.get(id_of_kpi_to_disconnect, None)
if kpi is None:
error_message = 'Service {} does not have a kpi with key {}.'.format(service_obj.get('_key'), id_of_kpi_to_disconnect)
self.raise_error_bad_validation(
logger,
_(error_message)
)
# Verify that each kpi has reference to CTW
if ctw_id not in kpi.get('linked_custom_threshold_windows'):
error_message = 'Error: KPI {} does not have reference to CTW {} in linked_custom_threshold_windows field.'.format(id_of_kpi_to_disconnect, ctw_id)
self.raise_error_bad_validation(
logger,
_(error_message)
)
# Now, verify that ctw has reference to KPI
if id_of_kpi_to_disconnect not in linked_kpi_ids_in_ctw_for_service:
error_message = 'Error: Custom threshold window does not contain a reference to KPI with ID {} ' \
'for service with ID {}.'.format(id_of_kpi_to_disconnect, dict_service_id)
self.raise_error_bad_validation(
logger,
_(error_message)
)
# Now, modify CTW object
for linked_service in ctw_linked_services:
if linked_service.get('service_id') == dict_service_id:
linked_kpi_ids = linked_service.get('linked_kpi_ids')
linked_kpi_ids.remove(id_of_kpi_to_disconnect)
if not linked_kpi_ids:
ctw_linked_services.remove(linked_service)
else:
linked_service['linked_kpi_ids'] = linked_kpi_ids
ctw_to_update['linked_services'] = ctw_linked_services
# Prevent KPI disconnection from deleting fields in the CTW
ctw_to_update.pop('_marked_for_delete', None)
try:
self.check_against_can_link_instead = True
ctw_object = self.update(owner, ctw_id, ctw_to_update, is_partial_data=True)
except Exception as e:
error_msg = 'Error while attempting to update custom threshold window (%s): %s' % (ctw_id, e)
self.raise_error(logger, error_msg, 500)
finally:
self.check_against_can_link_instead = False
return ctw_object
def populate_services_associated_to_ctw(self, list_of_service_associated_to_ctw):
list_of_service_ids = []
for service_obj in list_of_service_associated_to_ctw:
if len(service_obj) != 0:
list_of_service_ids.append(service_obj.get('service_id', ''))
return list_of_service_ids
def update_received_values_for_ctw_object_update_with_end_time(self, ctw_object, data):
"""
Method to fill the end_time at run time for the CTW object updation flow
We pull in the current CTW object and update the new values pass in
to get the end_time for CTW.
@type ctw_object: object
@param ctw_object: ctw object to update from datastore
@type data: json
@param data: data from ui/rest to update
@return: ypdated json data with end_time populated
"""
json_data = self.extract_json_data(data)
calculate_end_time = False
if json_data.get('start_time'):
ctw_object['start_time'] = json_data.get('start_time')
calculate_end_time = True
if json_data.get('duration'):
ctw_object['duration'] = json_data.get('duration')
calculate_end_time = True
if json_data.get('user_timezone'):
ctw_object['user_timezone'] = json_data.get('user_timezone')
if json_data.get('cron_schedule'):
ctw_object['cron_schedule'] = json_data.get('cron_schedule')
ctw_object['start_time'] = 0
ctw_object['recurrence'] = True
ctw_object['next_scheduled_time'] = self.calculate_next_scheduled_time(ctw_object)
ctw_object['start_time'] = ctw_object['next_scheduled_time']
calculate_end_time = True
else:
ctw_object['recurrence'] = False
if calculate_end_time:
data['end_time'] = self.calculate_end_time(ctw_object)
data['next_scheduled_time'] = self.calculate_next_scheduled_time(ctw_object)
return data
def update_ctw_object_with_values_received(self, db_ctw_object, user_passed_ctw_obj):
"""
Method for copying the Values of object received for updation into the DB Object for processing.
@type db_ctw_object: custom_threshold_window
@param db_ctw_object: object of custom threshold objects being updated
@type user_passed_ctw_obj: object
@param user_passed_ctw_obj: object containing values to update CTW with
@return: custom_threshold_window
"""
for key in user_passed_ctw_obj.keys():
if key in db_ctw_object.keys():
db_ctw_object[key] = user_passed_ctw_obj.get(key, db_ctw_object[key])
return db_ctw_object
def get_stop_reason_message(self, stop_reason):
"""
gets a stop reason message with current user's username name in it
@type: string
@param stop_reason: custom reason message provided by the current user
@rtype: string
@return stop reason message
"""
message = _("Last stopped by {} user.").format(self.current_user_name)
if stop_reason:
message = _("Last stopped by {} user with message: {}").format(self.current_user_name, stop_reason)
return message
@skip_validation_method
def stop_active_ctw(self, owner, ctw_id, stop_reason):
"""
Stop an active custom threshold window
@type: string
@param owner: Splunk user to own object
@type: string
@param ctw_id: key of the CTW object
@type: string
@param stop_reason: reason for stopping CTW
@rtype: json
@return: Stopped CTW object
"""
ctw_object = self.get(owner, ctw_id)
self.check_ctw_permissions(ctw_object, 'can_transition')
inactive_services = []
inactive_kpis = {}
for service in ctw_object.get('linked_services', []):
inactive_services.append(service['service_id'])
for kpi_id in service.get('linked_kpi_ids'):
inactive_kpis[kpi_id] = ctw_object['_key']
self.update_service_object([], {}, inactive_services, inactive_kpis)
try:
self.check_against_can_transition_instead = True
stop_reason_message = self.get_stop_reason_message(stop_reason)
self.update_ctw_object_status(
to_be_active_windows=[],
to_be_ended_windows=[ctw_object],
is_stopping=True,
stop_reason_message=stop_reason_message,
)
finally:
self.check_against_can_transition_instead = False
return self.get('nobody', ctw_id)
def bulk_stop_active_ctws(self, owner, ctw_ids, stop_reason):
"""
Stop multiple active custom threshold windows
@type: string
@param owner: Splunk user to own object
:type: list of string
:param ctw_ids: List of CTW IDs to stop
@type: string
@param stop_reason: reason for stopping CTW
:type: list of json
:return: List of stopped CTW objects
"""
rv = []
try:
for i in range(len(ctw_ids)):
rv.append(self.stop_active_ctw(owner, ctw_ids[i], stop_reason))
except Exception as e:
error_msg = 'Custom threshold windows stopped successfully (%s), custom threshold windows that ' \
'encountered errors in the stopping process (%s), %s' % (ctw_ids[0:i], ctw_ids[i:], e)
self.raise_error(logger, error_msg, 500)
return rv
"""
CRUD operation methods
"""
def create(self, owner, data, dupname_tag=None, transaction_id=None):
"""
Overriding the itoa_object create function.
We create the CTW object and then attach the KPIs to it to make use of existing code. We throw errors here
instead of in do_additional_setup() because that's called only before the base interface creation code.
On associate_ctw_kpis() error, attempt a deletion of the CTW object. This is less addressable by the user, but
this ensures stricter compliance with the rule that CTWs must have KPIs.
"""
service_interface = ItsiService(self.session_key, self.current_user_name)
linked_services = data.get('linked_services', [])
self.validate_ctw_inputs(data , is_create_call=True)
for linked_service in linked_services:
service_id = linked_service.get('service_id', 'undefined')
service = service_interface.get(owner, service_id)
linked_kpi_ids = linked_service.get('linked_kpi_ids', [])
if not service:
raise ITOAError(status='400', message=_('Custom threshold window (%s) should have a valid service ID, '
'service ID %s is not a valid service ID') % (data.get('title', 'N/A'), service_id))
elif not linked_kpi_ids:
raise ITOAError(status='400', message=_('Custom threshold window (%s) should have linked KPI Ids') %
data.get('title', 'N/A'))
kpi_keys = {kpi['_key'] for kpi in service['kpis']}
for kpi_id in linked_kpi_ids:
if kpi_id not in kpi_keys:
raise ITOAError(status='400', message=_('Custom threshold window (%s) should have a valid KPI '
'reference, KPI with ID %s is not part of service with ID %s') %
(data.get('title', 'N/A'), kpi_id, service_id))
# The main point of the method
response = super(ItsiCustomThresholdWindows, self).create(
owner, data, dupname_tag=dupname_tag, transaction_id=transaction_id,
)
return response
def save_batch(
self,
owner,
data_list,
validate_names,
dupname_tag=None,
req_source='unknown',
ignore_refresh_impacted_objects=False,
method=CRUDMethodTypes.METHOD_UPSERT,
is_partial_data=False,
transaction_id=None,
skip_local_failure=False
):
"""
Overriding the itoa_object save_batch function.
Batch saving is less performant (we don't have a batch-associate), but it's okay for now since this is only used
in backup-restore and not in other cases.
On associate_ctw_kpis() error, attempt a deletion of the CTW object. This is less addressable by the user, but
this ensures stricter compliance with the rule that CTWs must have KPIs.
"""
for json_data in data_list:
linked_services = json_data.get('linked_services', [])
if not linked_services and not is_partial_data:
raise ITOAError(status='400', message=_('Custom threshold window (%s) should contain at least one '
'linked service.') % json_data.get('title', 'N/A'))
response = super(ItsiCustomThresholdWindows, self).save_batch(
owner, data_list, validate_names, dupname_tag=dupname_tag, req_source=req_source,
ignore_refresh_impacted_objects=ignore_refresh_impacted_objects, method=method,
is_partial_data=is_partial_data, transaction_id=transaction_id, skip_local_failure=skip_local_failure,
)
return response
def get(self, owner, object_id, req_source='unknown', transaction_id=None):
"""
Overriding the itoa_object get function.
The CTW object type is not intended to be 'securable' but we can still enforce RBAC filtering.
@type owner: string
@param owner: Splunk user to own object
@type object_id: string
@param object_id: id of CTW object to retrieve
@type req_source: string
@param req_source: identified source initiating the operation
@type transaction_id: string
@param transaction_id: unique identifier of a user transaction
@return: object matching id on success, throws exceptions on errors (including if object is not found)
"""
ctw_object = ItoaObject.get(self, owner, object_id, req_source, transaction_id)
if not ctw_object:
raise ITOAError(status="404", message=_("Custom threshold window (%s) not found." % object_id))
services_by_ids, services_sec_grp_ids = self.fetch_service_data(owner, transaction_id)
self.attach_ctw_permissions(owner, ctw_object, transaction_id, services_by_ids,
services_sec_grp_ids)
self.check_ctw_permissions(ctw_object, 'can_view')
return ctw_object
def get_bulk(self,
owner,
sort_key=None,
sort_dir=None,
filter_data=None,
fields=None,
skip=None,
limit=None,
req_source='get_bulk',
transaction_id=None):
"""
Overriding the itoa_object get_bulk function.
The CTW object type is not intended to be 'securable' but we can still enforce RBAC filtering.
Note: The `fields` parameter isn't entirely accurate as certain fields are forced into the response
@type: string
@param owner: Splunk user to own object
@type sort_key: string
@param sort_key: string defining keys to sort by
@type sort_dir: string
@param sort_dir: string defining direction for sorting - asc or desc
@type filter_data: dictionary
@param filter_data: json filter constructed to filter data. Follows mongodb syntax
@type fields: list
@param fields: list of fields to retrieve, fetches all fields if not specified
This isn't fully accurate as certain fields (like permissions, `object_type`, `linked_services`) are forced into
the response.
@type skip: number
@param skip: number of items to skip from the start
@type limit: number
@param limit: maximum number of items to return
@type req_source: string
@param req_source: identified source initiating the operation
@type transaction_id: string
@param transaction_id: unique identifier of a user transaction
@rtype: list of dictionary
@return: objects retrieved on success, throws exceptions on errors,
list of of CTW objects filtering based on rbac
"""
# Special hack to show only the ctws for which user has
# access in count section of CTW lister page
if fields is not None:
if 'linked_services' not in fields:
fields.append('linked_services')
if 'sec_grp_list' not in fields:
fields.append('sec_grp_list')
if 'user_timezone' not in fields:
fields.append('user_timezone')
if 'duration' not in fields:
fields.append('duration')
return self.do_rbac_filtering(owner,
sort_key,
sort_dir,
filter_data,
fields,
skip,
limit,
req_source,
transaction_id)
def update(self, owner, object_id, data, is_partial_data=False, dupname_tag=None, transaction_id=None):
"""
Overridden method for itoa_object.update().
Security check is additionally done here instead of do_additional_setup() due to is_partial_data support. We
don't want to use user-supplied fields, so we scrub out permissions fields before attaching our own. However,
is_partial_data support patches the objects *before* doing the save operation as KVStore (MongoDB) doesn't
support partial updates natively. This means that the proper permissions are added, the permissions get removed
in do_additional_setup(), and then the save happens and checks for permissions.
"""
self.validate_ctw_inputs(data , is_partial_data=is_partial_data)
ctw_object = self.get(owner, object_id, transaction_id=transaction_id)
if self.check_against_can_link_instead:
self.check_ctw_permissions(ctw_object, 'can_link')
else:
self.check_ctw_permissions(ctw_object, 'can_edit')
self.update_received_values_for_ctw_object_update_with_end_time(ctw_object, data)
return super(ItsiCustomThresholdWindows, self).update(owner, object_id, data, is_partial_data=is_partial_data,
dupname_tag=dupname_tag, transaction_id=transaction_id)
def can_be_deleted(self, owner, objects, raise_error=False, transaction_id=None):
"""
Overrides itoa_object.can_be_deleted().
@type owner: string
@param {string} owner: user which is performing this operation
@type objects: list
@param objects: the objects to validate for dependency
@type raise_error: boolean
@param raise_error: if true, an error will be raised if no objects could be deleted
@rtype: list
@return: list - list of deletable objects
"""
return [obj for obj in objects if obj['permissions']['can_delete']]
def delete(self, owner, ctw_id, req_source='unknown', transaction_id=None):
"""
Overridden method for itoa_object.delete(). Cleans up references that linked services/kpis have to ctw
before ctw delete happens
@type owner: string
@param owner: Splunk user to own object
@type ctw_id: string
@param ctw_id: Identifier of the object that was deleted
@type req_source: string
@param req_source: String identifying source of this request
@type transaction_id: string
@param transaction_id: String identifying source of this request
@return: results from the delete
"""
ctw_object = self.get(owner, ctw_id)
self.check_ctw_permissions(ctw_object, 'can_delete')
# Use parent delete function to go through normal operations (including identifying dependencies)
return super(ItsiCustomThresholdWindows, self).delete(owner,
ctw_id,
req_source=req_source,
transaction_id=transaction_id)
def delete_bulk(self, owner, filter_data=None, req_source='unknown', transaction_id=None):
"""
Overridden method for itoa_object.delete_bulk(). Cleans up references that linked services/kpis have to ctws
before ctw delete happens
@type owner: string
@param owner: Splunk user to own object
@type filter_data: string
@param filter_data: sql Filter condition with Ctw ids
@type req_source: string
@param req_source: String identifying source of this request
@type transaction_id: string
@param transaction_id: String identifying source of this request
@return: results from the delete
"""
# Use parent delete function to go through normal operations (including identifying dependencies)
results = []
delete_objects = self.get_bulk(owner, filter_data=filter_data, fields=self.delete_object_fields)
for to_be_deleted_object in delete_objects:
result = self.delete(owner, to_be_deleted_object.get('_key'),
req_source=req_source, transaction_id=transaction_id)
results.append(result)
return results
def do_rbac_filtering(self,
owner,
sort_key=None,
sort_dir=None,
filter_data=None,
fields=None,
skip=None,
limit=None,
req_source='do_rbac_filtering',
transaction_id=None):
"""
Filtering out the CTW objects which contains:
- services that current user doesn't have access to
How is this done?
1. Fetch all the CTW objects without limit and offset
2. Fetch the services visible to current user
3. Compare the security_groups_ids of objects in the CTW objects
against the services security groups user can see.
@type: string
@param owner: Splunk user to own object
@type sort_key: string
@param sort_key: string defining keys to sort by
@type sort_dir: string
@param sort_dir: string defining direction for sorting - asc or desc
@type filter_data: dictionary
@param filter_data: json filter constructed to filter data. Follows mongodb syntax
@type fields: list
@param fields: list of fields to retrieve, fetches all fields if not specified
@type skip: number
@param skip: number of items to skip from the start
@type limit: number
@param limit: maximum number of items to return
@type req_source: string
@param req_source: identified source initiating the operation
@type transaction_id: string
@param transaction_id: unique identifier of a user transaction
@rtype: list of dictionary
@return: objects retrieved on success, throws exceptions on errors,
list of of CTW objects filtering based on rbac
"""
# get all the CTW objects by enforcing skip = None and limit = None to get them all
ctw_objects_full_list = ItoaObject.get_bulk(self,
owner,
sort_key,
sort_dir,
filter_data,
fields,
None,
None,
req_source,
transaction_id)
if len(ctw_objects_full_list) > 0:
services_by_ids, services_sec_grp_ids = self.fetch_service_data(owner, transaction_id)
ctw_objects_filtered_list = []
for ctw_object in ctw_objects_full_list:
# Set permissions on all objects returned
self.attach_ctw_permissions(owner, ctw_object, transaction_id, services_by_ids, services_sec_grp_ids)
if ctw_object['permissions']['can_view']:
ctw_objects_filtered_list.append(ctw_object)
skip = int(skip) if skip is not None else 0
limit = int(limit) if limit is not None else 0
if limit == 0 and skip == 0:
return ctw_objects_filtered_list
else:
return ctw_objects_filtered_list[skip:limit + skip]
return ctw_objects_full_list
def identify_dependencies(self, owner, objects, method, req_source='unknown',
transaction_id=None, skip_local_failure=False):
"""
Assess whether any refresh queue jobs needs to be created based on changes that are occurring
@param {string} owner: user performing this operation
@param {list} objects: list of objects being updated
@param {string} method: method name
@param {string} req_source: request source
@return: a tuple
{boolean} set to true/false if dependency update is required
{list} list - list of refresh job, each element has the following
change_type: <identifier of the change used to pick change handler>,
changed_object_key: <Array of changed objects' keys>,
changed_object_type: <string of the type of object>,
change_detail: <Dictionary containing additional information>:
service_kpi_mapping: <dictionary of services/KPIs affected and needing updates>,
method: <String representing the type of change to be performed>
METHODS USED (please update if modified):
- DELETE (CTW is being deleted)
- ADD (KPIs added and need to be linked to CTW)
- DISCONNECT (KPIs removed from CTW and need to be updated)
"""
refresh_jobs = []
is_refresh_required = False
if not is_valid_list(objects):
logger.error("%s resource did not passed valid object list:%s", req_source, objects)
return is_refresh_required, refresh_jobs
ctws_being_updated = []
services_to_update = {} # service_key: list of kpi ids
fields_filter = ['_key', 'linked_services']
persisted_custom_threshold_windows = self.get_persisted_objects_by_id(
owner,
object_ids=[ctw.get('_key') for ctw in objects],
req_source=req_source,
fields=fields_filter
)
if method == CustomThresholdWindowConstants.METHOD_DELETE:
for threshold_window in persisted_custom_threshold_windows:
ctws_being_updated.append(threshold_window.get('_key'))
linked_services = threshold_window.get('linked_services', [])
for service in linked_services:
service_key = service.get('service_id')
linked_kpi_ids_from_service = service.get('linked_kpi_ids', [])
if service_key not in services_to_update:
services_to_update[service_key] = set(linked_kpi_ids_from_service)
else:
services_to_update[service_key].update(linked_kpi_ids_from_service)
# convert back to a list when creating the refresh queue job
for service_key in services_to_update:
services_to_update[service_key] = list(services_to_update[service_key])
if len(ctws_being_updated) > 0 and services_to_update:
# For a deletion, we can create a giant CTW and remove all the CTWs from a KPI
refresh_jobs.append(
self.get_refresh_job_meta_data(
change_type='custom_threshold_window_update',
changed_object_key=list(ctws_being_updated), # Keys of custom threshold windows being updated
changed_object_type=self.object_type,
change_detail={
'method': CustomThresholdWindowConstants.METHOD_DELETE,
'service_kpi_mapping': services_to_update
},
transaction_id=transaction_id
))
else:
persisted_ctw_dict = dict((ctw['_key'], ctw) for ctw in persisted_custom_threshold_windows)
for ctw in objects:
# Newly created CTWs will not have a key yet at this point in the create call
if ctw.get('_key') is None:
ctw['_key'] = ITOAInterfaceUtils.generate_backend_key()
# For update operations, we create a job per CTW being updated so that each service/KPI is updated
# correctly/without wrong CTWs being added
persisted_ctw = persisted_ctw_dict.get(ctw.get('_key'))
if persisted_ctw is None or method == CRUDMethodTypes.METHOD_CREATE:
# This is a new CTW being created. Need to do associations with all the services/kpis
newly_linked_kpis = {service.get('service_id'): service.get('linked_kpi_ids')
for service in ctw.get('linked_services', [])}
former_linked_kpis = {}
else:
# Basic update to the CTW, which can include adding more services/kpis or potentially
# disconnecting KPIs
newly_linked_kpis, former_linked_kpis = self.determine_modified_kpis(ctw, persisted_ctw)
if len(newly_linked_kpis) > 0:
refresh_jobs.append(
self.get_refresh_job_meta_data(
change_type='custom_threshold_window_update',
changed_object_key=[ctw.get('_key', '')],
changed_object_type=self.object_type,
change_detail={
'method': CustomThresholdWindowConstants.METHOD_ADD,
'service_kpi_mapping': newly_linked_kpis
},
transaction_id=transaction_id
))
if len(former_linked_kpis) > 0:
refresh_jobs.append(
self.get_refresh_job_meta_data(
change_type='custom_threshold_window_update',
changed_object_key=[ctw.get('_key', '')],
changed_object_type=self.object_type,
change_detail={
'method': CustomThresholdWindowConstants.METHOD_DISCONNECT,
'service_kpi_mapping': former_linked_kpis
},
transaction_id=transaction_id
))
is_refresh_required = len(refresh_jobs) > 0
return is_refresh_required, refresh_jobs
def fetch_service_data(self, owner, transaction_id):
"""
Fetch data about services the owner has access to for CTW permissioning
:param owner: Splunk user to own object
:type owner: basestring
:param transaction_id: Transaction ID for logging
:type transaction_id: basestring
:return: Tuple of dict, list
"""
fields = ['_key', 'sec_grp', 'permissions', 'kpis._key']
# get the services which current_user_name has access to.
service_interface = ItsiService(self.session_key, self.current_user_name)
services = service_interface.get_bulk(owner,
fields=fields,
req_source='get_bulk',
transaction_id=transaction_id)
services_by_ids = {}
services_sec_grp_ids = []
for service in services:
sec_grp = service['sec_grp']
if sec_grp not in services_sec_grp_ids:
services_sec_grp_ids.append(sec_grp)
if service['_key'] not in services_by_ids:
services_by_ids[service['_key']] = service
return services_by_ids, services_sec_grp_ids
def attach_ctw_permissions(self, owner, custom_threshold_window, transaction_id, services_by_ids={},
services_sec_grp_ids=['default_itsi_security_group']):
"""
Attach explicit permissions as fields to the CTW object.
:param owner: Splunk user to own object
:type owner: basestring
:param custom_threshold_window: CTW object
:type custom_threshold_window: dict
:param transaction_id: Transaction ID for logging
:type transaction_id: basestring
:param services_by_ids: Dictionary of services keyed by ID
:type services_by_ids: dict
:param services_sec_grp_ids: List of security groups that the user has access to
:type services_sec_grp_ids: list
"""
new_attached_permissions = {
'can_view': False,
'can_link': False,
'can_delete': False,
'can_edit': False,
'can_unlink': {},
'can_write': False
}
def set_all_can_permissions(boolean, permissions_dict):
for can_permission in CustomThresholdWindowConstants.CAN_PERMISSIONS:
if can_permission == 'can_unlink':
if can_permission in permissions_dict:
for kpi in permissions_dict[can_permission]:
permissions_dict[can_permission][kpi] = boolean
else:
permissions_dict[can_permission] = {}
else:
permissions_dict[can_permission] = boolean
def reset_existing_permissions():
for can_permission in CustomThresholdWindowConstants.CAN_PERMISSIONS:
custom_threshold_window.pop(can_permission, None)
if 'permissions' in custom_threshold_window:
for can_permission in CustomThresholdWindowConstants.CAN_PERMISSIONS:
if can_permission == 'can_unlink':
if can_permission in custom_threshold_window['permissions']:
for kpi in custom_threshold_window['permissions'][can_permission]:
custom_threshold_window['permissions'][can_permission][kpi] = False
else:
custom_threshold_window['permissions'][can_permission] = {}
else:
if can_permission in custom_threshold_window['permissions']:
custom_threshold_window['permissions'][can_permission] = False
else:
custom_threshold_window['permissions'] = new_attached_permissions.copy()
reset_existing_permissions()
# When internal processes like Modular Input are trying to save the CTW object (with nobody user), have
# the can_edit and can_delete flags set to True
if self.current_user_name == 'nobody':
set_all_can_permissions(True, custom_threshold_window['permissions'])
return
linked_services = custom_threshold_window.get('linked_services', [])
total_kpi_count = sum([len(linked_service['linked_kpi_ids']) for linked_service in linked_services])
# In the case when no association is between CTW and KPI, everyone can edit and see the CTW.
if len(linked_services) == 0:
set_all_can_permissions(True, new_attached_permissions)
ctw_object_sec_grp_list = custom_threshold_window.get('sec_grp_list', [])
# One service for which user have access to is enough to continue onto the next step
for ctw_object_sec_grp in ctw_object_sec_grp_list:
if ctw_object_sec_grp in services_sec_grp_ids:
new_attached_permissions['can_view'] = True
new_attached_permissions['can_link'] = True
new_attached_permissions['can_delete'] = True
break
fields = ['_key', 'sec_grp', 'permissions', 'kpis._key']
if new_attached_permissions['can_view'] or len(ctw_object_sec_grp_list) == 0:
service_interface = ItsiService(self.session_key, self.current_user_name)
is_edit_blocked = False
visible_linked_services = []
for linked_service in linked_services:
if len(linked_service) != 0:
# Determine 'can_edit'
service_id = linked_service.get('service_id')
try:
service = None
if services_by_ids:
if service_id in services_by_ids:
# Get the service from dict obj as we have fetched it earlier
# so no need to get it again.
service = services_by_ids[service_id]
else:
# TODO: It's possible that the 'simpler' single CTW case should be set up similarly
# to the multiple CTW case since we iterate over all services.
# User a get_bulk so that we can limit the fields fetched from the single service
service = service_interface.get_bulk(owner,
filter_data={'_key': service_id},
fields=fields,
req_source='CustomThresholdWindowGet',
transaction_id=transaction_id)[0]
# User can't fetch the service itself
# If service is None, it's either not fetchable by the current user or it may have been deleted.
# In either case, it should not affect the permissions of the CTW.
if not service:
is_edit_blocked = True
continue
# Determine 'can_edit' and, while you're at it, remove the services from CTW for which
# the user does not have permissions.
if 'permissions' in service:
if 'read' in service['permissions'] and not service['permissions']['read']:
continue
elif 'write' in service['permissions']:
visible_linked_services.append(linked_service)
user_can_write = False
if service['permissions']['write']:
# A single existing service allows further linking and unlinking
new_attached_permissions['can_link'] = True
user_can_write = True
else:
# For readable services, a single uneditable service blocks all editing
is_edit_blocked = True
for kpi in linked_service['linked_kpi_ids']:
if 'can_unlink' not in new_attached_permissions:
new_attached_permissions['can_unlink'] = {}
new_attached_permissions['can_unlink'][kpi] = user_can_write
except ItoaAccessDeniedError:
continue
if not is_edit_blocked:
new_attached_permissions['can_edit'] = True
# Replace CTW linked services with a list of services that the user can actually view
custom_threshold_window['linked_services'] = visible_linked_services
# State handling
if custom_threshold_window.get('status') in CustomThresholdWindowConstants.DELETEABLE_STATES:
new_attached_permissions['can_delete'] = new_attached_permissions.get('can_edit', False)
else:
new_attached_permissions['can_delete'] = False
# 'can_transition' is just 'can_edit' that ignores state-handling
if custom_threshold_window.get('status') in CustomThresholdWindowConstants.TRANSITIONABLE_STATES:
new_attached_permissions['can_transition'] = new_attached_permissions.get('can_edit', False)
else:
new_attached_permissions['can_transition'] = False
if custom_threshold_window.get('status') not in CustomThresholdWindowConstants.EDITABLE_STATES:
new_attached_permissions['can_edit'] = False
if custom_threshold_window.get('status') not in CustomThresholdWindowConstants.LINKABLE_STATES:
new_attached_permissions['can_link'] = False
for kpi in new_attached_permissions['can_unlink']:
new_attached_permissions['can_unlink'][kpi] = False
# Safety catches (lack of lower permissions blocks higher permissions)
# can_view -> can_link -> (can_transition) -> can_edit
# can_transition is separate because it needs to be a "historical" permission
if not new_attached_permissions['can_view']:
new_attached_permissions['can_link'] = False
for kpi in new_attached_permissions['can_unlink']:
new_attached_permissions['can_unlink'][kpi] = False
if not new_attached_permissions['can_link']:
new_attached_permissions['can_edit'] = False
for kpi in new_attached_permissions['can_unlink']:
new_attached_permissions['can_unlink'][kpi] = False
# If there is only one KPI in can_unlink, it should be False regardless because the user should not
# be able to remove the last KPI from a Custom threshold window
if len(new_attached_permissions['can_unlink']) == 1 and\
len(new_attached_permissions['can_unlink']) == total_kpi_count:
for only_kpi in new_attached_permissions['can_unlink']:
new_attached_permissions['can_unlink'][only_kpi] = False
custom_threshold_window['permissions'] = new_attached_permissions
@staticmethod
def clear_ctw_reference_from_kpi(ctw_id, kpi):
if kpi.get('linked_custom_threshold_windows'):
kpi['linked_custom_threshold_windows'].remove(ctw_id)
if kpi.get('active_custom_threshold_window') == ctw_id:
del kpi['active_custom_threshold_window']
"""
Miscellaneous helper methods
"""
def get_kpis_associated_to_ctw(self, custom_threshold_window):
linked_services = custom_threshold_window.get('linked_services', [])
kpis_associated_to_ctw = []
for linked_service in linked_services:
if len(linked_service) != 0:
kpis_in_service_object = linked_service.get('linked_kpi_ids', [])
if len(kpis_in_service_object) != 0:
kpis_associated_to_ctw.extend(kpis_in_service_object)
return kpis_associated_to_ctw
def calculate_end_time(self, custom_threshold_window):
"""
Assumes custom threshold window object is valid.
Calculates when the current custom threshold window should end
@param custom_threshold_window: custom threshold window object
@return: int representing the end time for a threshold window
"""
window_duration_hours = custom_threshold_window.get('duration', 24)
window_duration = window_duration_hours * 3600
normalize_num_field(custom_threshold_window, 'start_time')
window_start_time = custom_threshold_window.get('start_time')
return window_start_time + window_duration
def calculate_next_scheduled_time(
self, custom_threshold_window, use_custom_time=False, custom_time=datetime.now().timestamp()
):
"""
Assumes custom threshold window object is valid.
Checks to see if recurrent schedule is active. Otherwise, it'll check for a start_time
Calculates what the next_scheduled_time should be. If its a non-recurring CTW, then next_scheduled_time will be
set to an empty string. NOTE - future functions that use this field will need to check if empty string or int
@param use_custom_time: Bool that indicates whether a custom date time should be used. For testing purposes
mainly
@param custom_time: some custom time for calculation. Should only be used for testing purposes
@param custom_threshold_window: the custom threshold window itself
@return None - modification is made directly to the custom threshold window
"""
if custom_threshold_window.get('start_time') is None and custom_threshold_window.get('cron_schedule') is None:
self.raise_error_bad_validation(logger, _('Custom time window has no valid start time set.'))
if custom_threshold_window.get('recurrence', False):
# Calculate based off the cron schedule
threshold_cron_schedule = custom_threshold_window.get('cron_schedule')
try:
if use_custom_time:
logger.debug('calculate_next_scheduled_time is using a custom time for calculation.')
# This should only enter during a special scenario, usually testing
next_start_time = int(croniter(threshold_cron_schedule, custom_time).get_next())
else:
next_start_time, user_timezone = calculate_next_cron_time(custom_threshold_window)
logger.debug('Custom threshold window {} is recurring and determined next scheduled time '
'{} based on the timezone {}'.format(custom_threshold_window.get('_key'),
next_start_time,
str(user_timezone))
)
custom_threshold_window['next_scheduled_time'] = next_start_time
except ValueError:
self.raise_error_bad_validation(logger,
_('Cron schedule was set incorrectly in custom threshold window - '
'given {} as schedule.'.format(threshold_cron_schedule)))
else:
# If it is not a recurrence, then the next execution will just be the first execution (start time specified)
normalize_num_field(custom_threshold_window, 'start_time')
window_start_time = custom_threshold_window.get('start_time')
current_time = custom_time if use_custom_time else int(datetime.now().timestamp())
if current_time >= window_start_time:
custom_threshold_window['next_scheduled_time'] = ''
else:
custom_threshold_window['next_scheduled_time'] = window_start_time
return custom_threshold_window['next_scheduled_time']
def determine_modified_kpis(self, updated_ctw, existing_ctw):
"""
Helper method that checks to see if there are any new KPIs that were added to the CTW.
@type updated_ctw: dict
@param updated_ctw: The CTW that is being updated
@type existing_ctw: dict
@param existing_ctw: The CTW with the same key that exists in the kvstore currently (older version)
@rtype: dict
@returns: a dict of the service -> KPI ids that have been added for this specific CTW, which need to
go through the associate update handler
"""
new_service_kpis = {}
removed_service_kpis = {}
existing_linked_services = {service.get('service_id'): service.get('linked_kpi_ids', [])
for service in existing_ctw.get('linked_services', [])}
updated_linked_services = {service.get('service_id'): service.get('linked_kpi_ids', [])
for service in updated_ctw.get('linked_services', [])}
affected_service_ids = [*existing_linked_services] + [*updated_linked_services]
for service_id in set(affected_service_ids):
existing_service_kpis = set(existing_linked_services.get(service_id, []))
updated_service_kpis = set(updated_linked_services.get(service_id, []))
added_kpis = updated_service_kpis.difference(existing_service_kpis)
removed_kpis = existing_service_kpis.difference(updated_service_kpis)
if added_kpis:
new_service_kpis[service_id] = list(added_kpis)
if removed_kpis:
removed_service_kpis[service_id] = list(removed_kpis)
return new_service_kpis, removed_service_kpis
def update_kpi_objects(self, kpi_list, active_kpis, inactive_kpis):
"""
Updates the linked KPIs in Custom Threshold Window.
If a window becomes active, calculate the custom thresholds for each KPI affected and save to the KPI object.
If a window is ending, remove any custom threshold fields from the KPI object
@type: object
@param self: The self reference
@type: list
@param kpi_list: The list of kpis in service
@type: dict
@param active_kpis: Key value pair of the KPIs and the related active CTWs
@type: dict
@param inactive_kpis: Key value pair of the KPIs and the related non-operative CTWs
@rtype: list
@return: The list of updated KPIs
"""
updated_kpis = []
if len(active_kpis) > 0:
filter_data = {'$or': [{'_key': active_ctw_key} for active_ctw_key in active_kpis.values()]}
fields = ['_key', 'window_type', 'window_config_percentage']
active_ctws = self.get_bulk('nobody', filter_data=filter_data, fields=fields)
active_ctw_map = {ctw.get('_key'): ctw for ctw in active_ctws}
logger.debug('{} custom threshold windows have become active and fetched. Linked KPIs to be updated.'
.format(len(active_ctw_map)))
for kpi in kpi_list:
if kpi.get('_key', '') in active_kpis.keys():
ctw_key = active_kpis[kpi.get('_key', '')]
kpi['active_custom_threshold_window'] = ctw_key
linked_ctw = kpi.get('linked_custom_threshold_windows', [])
if ctw_key not in linked_ctw:
linked_ctw.append(ctw_key)
kpi['linked_custom_threshold_windows'] = linked_ctw
logger.debug('Calculating custom thresholds for kpi {} with key {}'
.format(kpi.get('title'), kpi.get('_key')))
kpi = calculate_custom_thresholds(kpi, active_ctw_map.get(ctw_key))
updated_kpis.append(kpi)
elif kpi.get('_key', '') in inactive_kpis.keys():
kpi['active_custom_threshold_window'] = ''
kpi.pop('aggregate_thresholds_custom', None)
if kpi['time_variate_thresholds'] is True:
kpi.pop('time_variate_thresholds_specification_custom', None)
updated_kpis.append(kpi)
else:
updated_kpis.append(kpi)
return updated_kpis
def update_service_object(self, active_services, active_kpis, inactive_services, inactive_kpis):
"""
Updates the services with the updated KPIs linked to Custom Threshold Windows
@type: object
@param self: The self reference
@type: list
@param active_services: List of the services affected active CTWs
@type: list
@param inactive_services: List of the services affected non-operative CTWs
@type: dict
@param active_kpis: Key value pair of the KPIs and the related active CTWs
@type: dict
@param inactive_kpis: Key value pair of the KPIs and the related non-operative CTWs
@rtype: None
@return: None
"""
all_updated_service_ids = active_services + inactive_services
if len(all_updated_service_ids) > 0:
filter_data = {'$or': [{'_key': object_id} for object_id in all_updated_service_ids]}
all_services = self.service_object.get_bulk('nobody', filter_data=filter_data)
updated_services = []
for service in all_services:
updated_kpis = self.update_kpi_objects(service['kpis'], active_kpis, inactive_kpis)
service['kpis'] = updated_kpis
updated_services.append(service)
self.service_object.save_batch('nobody', updated_services, False)
def update_ctw_object_status(
self,
to_be_active_windows,
to_be_ended_windows,
is_stopping=False,
stop_reason_message=''
):
"""
Updates the status of the Custom Threshold Window
@type: list
@param to_be_active_windows: List of CTWs to update to the "Active" state
@type: list
@param to_be_ended_windows: List of CTWs to update to the "Scheduled", "Stopped", or "Completed" state
@type: string
@param stop_reason_message: Reason for stopping CTW
@rtype: None
@return: None
"""
to_update_windows = to_be_active_windows + to_be_ended_windows
filter = {'$or': [{'_key': ctw['_key']} for ctw in to_update_windows]}
to_update_ctws = self.get_bulk(
'nobody',
# Note: Don't fetch 'linked_services' to prevent a bug where a status change will move the CTW into a
# non-editable state that KPIs can't be edited onto.
fields=['_key', 'start_time', 'end_time', 'next_scheduled_time', 'recurrence', 'status', 'cron_schedule',
'stop_reason', 'user_timezone', 'duration'],
filter_data=filter,
)
to_be_active_keys = [window['_key'] for window in to_be_active_windows]
to_be_ended_keys = [window['_key'] for window in to_be_ended_windows]
updated_ctw_objects = []
current_time = int(time.time())
for window in to_update_ctws:
# Status change for CTW to Scheduled -> Active
if window['_key'] in to_be_active_keys:
if window['status'] != CustomThresholdWindowConstants.STATUS_ACTIVE:
window['status'] = CustomThresholdWindowConstants.STATUS_ACTIVE
window['stop_reason'] = ''
# Calculate next_scheduled_time, start_time, end_time for recurring CTWs
if window['recurrence']:
time_now = get_current_utc_epoch()
next_scheduled_time = window.get('next_scheduled_time', 0)
if next_scheduled_time < time_now:
window['start_time'] = window['next_scheduled_time']
window['next_scheduled_time'] = self.calculate_next_scheduled_time(window)
else:
# For non-recurring reset next_scheduled_time
window['next_scheduled_time'] = ''
updated_ctw_objects.append(window)
# Status change for CTW to Active -> Completed or Active -> Stopped
elif window['_key'] in to_be_ended_keys:
if is_stopping:
if window['recurrence']:
end_status = CustomThresholdWindowConstants.STATUS_SCHEDULED
window['next_scheduled_time'] = self.calculate_next_scheduled_time(window)
window['start_time'] = window['next_scheduled_time']
window['end_time'] = self.calculate_end_time(window)
else:
end_status = CustomThresholdWindowConstants.STATUS_STOPPED
window['next_scheduled_time'] = ''
window['last_stopped_time'] = current_time
window['stop_reason'] = stop_reason_message
else:
end_status = CustomThresholdWindowConstants.STATUS_COMPLETED
window['status'] = end_status
updated_ctw_objects.append(window)
else:
logger.error('Unhandled custom threshold window reached in update_ctw_object_status() (%s)' % window)
# Save updated CTW after status change
if len(updated_ctw_objects) > 0:
self.save_batch('nobody', updated_ctw_objects, False, is_partial_data=True)
for window in updated_ctw_objects:
if window['status'] == CustomThresholdWindowConstants.STATUS_ACTIVE:
self.message_handler.post_or_update_message(
'ctw_%s' % window['_key'], 'info',
'Custom threshold window (%s) is now active on %s and will conclude on %s.' %
(window['title'], get_human_readable_time_str(current_time),
get_human_readable_time_str(window['end_time'])),
)
elif is_stopping:
self.message_handler.post_or_update_message(
'ctw_%s' % window['_key'], 'info',
'Custom threshold window (%s) has been manually stopped (%s to %s)' %
(window['title'], get_human_readable_time_str(window['start_time']),
get_human_readable_time_str(current_time)),
)
else:
self.message_handler.post_or_update_message(
'ctw_%s' % window['_key'], 'info',
'Custom threshold window (%s) has successfully completed (%s to %s)' %
(window['title'], get_human_readable_time_str(window['start_time']),
get_human_readable_time_str(current_time)),
)
"""
Delete handler methods
"""
@skip_validation_method
def disconnect_deleted_kpis_from_all_ctws(self, owner, kpi_id=None, transaction_id=None):
"""
This function is used to disassociate the deleted KPIs from a CTW objects
@type: string
@param owner: Splunk user to own object
@type kpi_id: string
@param kpi_id: id of KPI object deleted
@type transaction_id: string
@param transaction_id: Transaction id
"""
def remove_kpi_from_linked_services(ctw_obj, kpi_id):
"""
Helper function to remove a KPI from linked_services
:type: dict
:param ctw_obj: A CTW object
:type: string
:param kpi_id: KPI ID to remove from the linked_services field
"""
linked_services = ctw_obj.get('linked_services', [])
for linked_service in linked_services:
kpis_in_service_object = linked_service.get('linked_kpi_ids', [])
if len(kpis_in_service_object) != 0:
kpis_in_service_object.remove(kpi_id)
if not kpis_in_service_object:
linked_services.remove(linked_service)
else:
linked_service['linked_kpi_ids'] = kpis_in_service_object
ctw_obj['linked_services'] = linked_services
# This is the proper syntax for multivalue fields in a KVStore query
filter_data = {'$or': [{'linked_services.linked_kpi_ids': kpi_id}]}
ctw_objects = ItoaObject.get_bulk(self, owner, filter_data=filter_data, req_source='get_bulk',
transaction_id=transaction_id)
for ctw_obj in ctw_objects:
kpis_for_threshold_window = self.get_kpis_associated_to_ctw(ctw_obj)
if kpi_id in kpis_for_threshold_window:
remove_kpi_from_linked_services(ctw_obj, kpi_id)
if not ctw_obj.get('linked_services'):
ctw_obj['status'] = CustomThresholdWindowConstants.STATUS_INVALID
ctw_obj['last_error'] = 'Custom threshold window invalidated by deletion of last linked KPI ' \
'%s' % kpi_id
self.update(owner, ctw_obj.get('_key'), ctw_obj, transaction_id=transaction_id)
logger.info('Successfully Disassociated the KPI from custom threshold window %s', ctw_obj.get('title'))
logger.info('Successfully Disassociated the KPI from all custom threshold windows')
@skip_validation_method
def disconnect_deleted_service_from_all_ctws(self, owner, deleted_service_ids=None, transaction_id=None):
"""
This function is used to disconnect deleted KPIs from CTW objects
@type: string
@param owner: Splunk user to own object
@type deleted_service_ids: list
@param deleted_service_ids: ids for services deleted
@type transaction_id: string
@param transaction_id: Transaction id
"""
def remove_services_from_linked_services(ctw_obj, service_ids_set):
"""
Helper function to remove services from linked_services
:type: dict
:param ctw_obj: A CTW object
:type: set of string
:param service_ids_set: Service IDs to remove from the linked_services field
:type: list of string
:return: List of removed service IDs
"""
rv = []
linked_services = ctw_obj.get('linked_services', [])
for linked_service in linked_services:
service_id = linked_service.get('service_id', [])
if service_id in service_ids_set:
linked_services.remove(linked_service)
rv.append(service_id)
ctw_obj['linked_services'] = linked_services
return rv
filter_data = {'$or': [{'linked_services.service_id': service_id} for service_id in deleted_service_ids]}
ctw_objects = ItoaObject.get_bulk(self, owner, filter_data=filter_data, req_source='get_bulk',
transaction_id=transaction_id)
deleted_service_ids_set = set(deleted_service_ids)
for ctw_obj in ctw_objects:
removed_service_ids = remove_services_from_linked_services(ctw_obj, deleted_service_ids_set)
if not ctw_obj.get('linked_services'):
ctw_obj['status'] = CustomThresholdWindowConstants.STATUS_INVALID
ctw_obj['last_error'] = 'Custom threshold window invalidated by deletion of last linked service(s) ' \
'%s' % ', '.join(removed_service_ids)
self.update(owner, ctw_obj.get('_key'), ctw_obj, transaction_id=transaction_id)
logger.info('Successfully Disassociated the service and KPI from custom threshold window %s',
ctw_obj.get('title'))
logger.info('Successfully Disassociated the service and KPIs from all custom threshold windows')