# 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: , changed_object_key: , changed_object_type: , change_detail: : service_kpi_mapping: , method: 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')