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.
821 lines
36 KiB
821 lines
36 KiB
# Copyright (C) 2005-2025 Splunk Inc. All Rights Reserved.
|
|
|
|
import itsi_py3
|
|
from splunk.util import normalizeBoolean
|
|
|
|
import ITOA.itoa_common as utils
|
|
from ITOA.itoa_exceptions import ItoaDatamodelContextError
|
|
from ITOA.itoa_object import ItoaObject
|
|
from ITOA.saved_search_utility import SavedSearch
|
|
from ITOA.setup_logging import logger
|
|
from itsi.itsi_utils import ITOAInterfaceUtils
|
|
from itsi.itsi_utils import GLOBAL_SECURITY_GROUP_CONFIG
|
|
from itsi.searches.itsi_searches import ItsiKpiSearches
|
|
|
|
BASE_SEARCH_KPI_ATTRIBUTES = [
|
|
'base_search',
|
|
'search_alert_earliest',
|
|
'is_entity_breakdown',
|
|
'entity_id_fields',
|
|
'entity_breakdown_id_fields',
|
|
'alert_period',
|
|
'alert_lag',
|
|
'is_service_entity_filter',
|
|
'metric_qualifier',
|
|
'sec_grp',
|
|
'is_metric'
|
|
]
|
|
|
|
DEFAULT_VALUE_KPI_ATTRIBUTES_DICT = {
|
|
'search_alert_earliest': '5',
|
|
'metric_qualifier': '',
|
|
'alert_period': '5',
|
|
'alert_lag': '30',
|
|
'_owner': 'nobody'
|
|
}
|
|
|
|
DEFAULT_GAP_SEVERITY_ATTRIBUTES_VALUES = {
|
|
'gap_severity': 'unknown',
|
|
'gap_severity_color': '#CCCCCC',
|
|
'gap_severity_value': '-1',
|
|
'gap_severity_color_light': '#EEEEEE'
|
|
}
|
|
|
|
ANOMALY_DETECTION_ATTRBUTES = [
|
|
'anomaly_detection_is_enabled',
|
|
'cohesive_anomaly_detection_is_enabled',
|
|
'anomaly_detection_alerting_enabled',
|
|
'trending_ad',
|
|
'cohesive_ad'
|
|
]
|
|
|
|
SEARCH_AND_CALCULATE_ATTRIBUTES = [
|
|
'base_search_id',
|
|
'is_service_entity_filter',
|
|
'is_entity_breakdown',
|
|
'entity_breakdown_id_fields',
|
|
'alert_period',
|
|
'base_search',
|
|
'search_alert_earliest',
|
|
'title',
|
|
'alert_lag',
|
|
'unit',
|
|
'entity_id_fields',
|
|
'threshold_field',
|
|
'search_type',
|
|
'entity_statop',
|
|
'base_search_metric',
|
|
'aggregate_statop',
|
|
'metric_qualifier',
|
|
'fill_gaps',
|
|
'gap_custom_alert_value',
|
|
] + list(DEFAULT_GAP_SEVERITY_ATTRIBUTES_VALUES.keys())
|
|
|
|
GENERATED_SEARCH_ATTRIBUTES = [
|
|
'search',
|
|
'search_aggregate',
|
|
'kpi_base_search',
|
|
'search_entities',
|
|
'search_time_series',
|
|
'search_time_series_aggregate',
|
|
'search_time_series_entities',
|
|
'search_time_compare',
|
|
'search_alert',
|
|
'search_alert_entities'
|
|
]
|
|
|
|
BACKFILL_ATTRIBUTES = [
|
|
'backfill_enabled',
|
|
'backfill_earliest_time'
|
|
]
|
|
|
|
THRESHOLDS_ATTRIBUTES = [
|
|
'kpi_threshold_template_id',
|
|
'tz_offset',
|
|
'time_variate_thresholds',
|
|
'adaptive_thresholds_is_enabled',
|
|
'adaptive_thresholding_training_window',
|
|
'aggregate_thresholds',
|
|
'entity_thresholds',
|
|
'time_variate_thresholds_specification',
|
|
'aggregate_thresholds_alert_enabled',
|
|
'aggregate_thresholds_custom_alert_enabled',
|
|
'aggregate_thresholds_custom_alert_rules'
|
|
]
|
|
|
|
BASE_SEARCH_METRIC_KPI_ATTRIBUTES = [
|
|
'threshold_field',
|
|
'unit',
|
|
'entity_statop',
|
|
'aggregate_statop',
|
|
'fill_gaps',
|
|
'gap_custom_alert_value',
|
|
] + list(DEFAULT_GAP_SEVERITY_ATTRIBUTES_VALUES.keys())
|
|
|
|
RECOMMENDATION_RESTORE_EXCLUDE_FIELDS = [
|
|
'is_recommended_time_policies',
|
|
'was_recommendation_modified',
|
|
'did_load_recommendation',
|
|
'recommendation_training_window',
|
|
'recommendation_start_date',
|
|
'threshold_direction',
|
|
'threshold_recommendation_summary'
|
|
]
|
|
|
|
# Fields that are returned in the pseudo-KPI object that aren't part of it natively
|
|
EXTERNAL_FIELDS = set([
|
|
"services_depending_on_me", "enabled"
|
|
])
|
|
|
|
# Service level fields that would be used for filtering condition to fetch kpis
|
|
SERVICE_LEVEL_FILTER_FIELDS = set([
|
|
"enabled",
|
|
])
|
|
|
|
|
|
def validate_data_gaps_filling_options(object, object_type='kpi'):
|
|
"""
|
|
Validate data gaps filling attribute (fill_gaps) for a KPI or a metric in a KPI Base Search.
|
|
@type object: dict
|
|
@param object: KPI object or metric object in KPI Base Search
|
|
@type object_type: basestring
|
|
@param object_type: type of object for which validation is being performed, 'kpi' or 'kpi_base_search'
|
|
@return: True, if validation is successful. else, False.
|
|
"""
|
|
supported_options = ['null_value', 'custom_value', 'last_available_value']
|
|
msg = ''
|
|
obj_name = 'kpi'
|
|
if object_type != 'kpi':
|
|
obj_name = 'metric'
|
|
if not object.get('fill_gaps') or object['fill_gaps'] == 'null_value':
|
|
# Case: if fill_gaps field is missing, consider it a 'null_value' scenario and validate gap severities.
|
|
object['fill_gaps'] = 'null_value'
|
|
else:
|
|
# Case: validate 'custom_value' or 'last_available_value' options for fill_gaps attribute
|
|
if object['fill_gaps'] not in supported_options:
|
|
msg = ('Invalid option provided to fill data gaps for object "%s". option_provided="%s", '
|
|
'supported_options="%s", %s_key="%s"') % \
|
|
(object_type, object['fill_gaps'], supported_options, obj_name, object.get('_key'))
|
|
return False, msg
|
|
|
|
if object['fill_gaps'] == 'custom_value':
|
|
if 'gap_custom_alert_value' not in object:
|
|
msg = ('Custom value to fill data gaps not provided. Provide a static value when "custom_value" '
|
|
'option is selected to fill data gaps. %s_key="%s", missing_field="gap_custom_alert_value"') \
|
|
% (obj_name, object.get('_key'))
|
|
return False, msg
|
|
|
|
if not utils.is_string_numeric(object['gap_custom_alert_value']):
|
|
msg = ('Custom value provided to fill in data gap is not valid. Only numeric values are accepted.'
|
|
' %s_key="%s", invalid_field="gap_custom_alert_value"') % (obj_name, object.get('_key'))
|
|
return False, msg
|
|
|
|
# only allow positive number as custom values for data gaps
|
|
if float(object['gap_custom_alert_value']) < 0:
|
|
msg = ('Only positive numeric values are accepted to fill data gaps for object "%s". '
|
|
'Negative value provided. %s_key="%s"') % (object_type, obj_name, object.get('_key'))
|
|
return False, msg
|
|
|
|
any_gap_severity_field_missing = False
|
|
for field in list(DEFAULT_GAP_SEVERITY_ATTRIBUTES_VALUES.keys()):
|
|
if field not in object:
|
|
any_gap_severity_field_missing = True
|
|
break
|
|
if any_gap_severity_field_missing:
|
|
# if any of the gap severity fields is missing, set all the gap severity fields to default values
|
|
for field in list(DEFAULT_GAP_SEVERITY_ATTRIBUTES_VALUES.keys()):
|
|
object[field] = DEFAULT_GAP_SEVERITY_ATTRIBUTES_VALUES[field]
|
|
|
|
# if 'gap_custom_alert_value' field is missing, then add it with default value (0)
|
|
if 'gap_custom_alert_value' not in object:
|
|
object['gap_custom_alert_value'] = '0'
|
|
|
|
return True, msg
|
|
|
|
|
|
def convert_filter_to_kpi_filter(filter_data):
|
|
"""
|
|
Convert a KPI filter subcomponent to its "correct" form (since KPI's aren't real objects of the KVstore)
|
|
|
|
This is a recursive call used for parsing.
|
|
|
|
Reference: https://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTkvstore
|
|
$not is not supported, as pwu can't find any documentation on how it's supposed to work in the first place.
|
|
|
|
:param filter_data: KVstore filter subcomponent
|
|
:type filter_data: any
|
|
|
|
:return: KVstore filter subcomponent
|
|
:rtype: any
|
|
"""
|
|
if type(filter_data) is dict:
|
|
rv = {}
|
|
for key, value in filter_data.items():
|
|
if key == "$ne":
|
|
"""
|
|
Dirty hack for fetching objects with multi-value fields by fetching all objects and relying on
|
|
apply_filter_to_results() to filter correctly.
|
|
|
|
Otherwise, the query {"foo": {"$ne": 0}} can't fetch a service like:
|
|
{
|
|
...
|
|
"kpis": [
|
|
{"foo": 0},
|
|
{"foo": 1},
|
|
],
|
|
...
|
|
}
|
|
|
|
pwu: I'm willing to bet $100 that no customer will ever query this value organically.
|
|
"""
|
|
rv[key] = "H1A2C3K4Y5V5A6L"
|
|
elif key.startswith("$"):
|
|
rv[key] = convert_filter_to_kpi_filter(value)
|
|
elif key in SERVICE_LEVEL_FILTER_FIELDS:
|
|
rv[key] = convert_filter_to_kpi_filter(value)
|
|
else:
|
|
rv["kpis.%s" % key] = convert_filter_to_kpi_filter(value)
|
|
return rv
|
|
elif type(filter_data) is list:
|
|
return [convert_filter_to_kpi_filter(subdata) for subdata in filter_data]
|
|
else:
|
|
return filter_data
|
|
|
|
|
|
def convert_kpi_to_modified_kpi(service_obj, kpi_obj):
|
|
"""
|
|
Convert a KPI into a modified for use in the KPI API. This modifies the base KPI.
|
|
|
|
This function defines what a modified KPI is.
|
|
|
|
:param service_obj: Service object containing the target KPI
|
|
:type service_obj: dict
|
|
:param kpi_obj: Target KPI
|
|
:type kpi_obj: dict
|
|
|
|
:return: Target KPI, modified
|
|
:rtype: dict
|
|
"""
|
|
kpi_obj["object_type"] = "modified_kpi"
|
|
kpi_obj["services_depending_on_me"] = []
|
|
kpi_obj["permissions"] = service_obj["permissions"]
|
|
kpi_obj["sec_grp"] = service_obj["sec_grp"]
|
|
# Assume that the service is disabled by default
|
|
kpi_obj["enabled"] = service_obj.get('enabled', 0)
|
|
key = kpi_obj["_key"]
|
|
for obj in service_obj.get("services_depending_on_me", []):
|
|
if key in obj["kpis_depending_on"]:
|
|
kpi_obj["services_depending_on_me"].append(obj["serviceid"])
|
|
return kpi_obj
|
|
|
|
|
|
class ItsiKpi(ItoaObject):
|
|
"""
|
|
Implements ITSI KPI
|
|
"""
|
|
collection_name = 'itsi_services'
|
|
|
|
def __init__(self, session_key, current_user_name):
|
|
super(ItsiKpi, self).__init__(session_key, current_user_name, 'kpi', collection_name=self.collection_name)
|
|
|
|
@staticmethod
|
|
def get_kpi_saved_search_name(kpi_id):
|
|
if not isinstance(kpi_id, itsi_py3.string_type):
|
|
message = 'Invalid type="%s" for kpi_id. Expecting string type.' % type(kpi_id).__name_
|
|
logger.error(message)
|
|
raise TypeError(message)
|
|
return 'Indicator - ' + kpi_id + ' - ITSI Search'
|
|
|
|
def _populate_with_base_search_attr(self, kpi, sec_grp):
|
|
"""
|
|
Populate given KPI object with base search attributes if applicable.
|
|
|
|
@type kpi: dict
|
|
@param kpi: kpi object to populate
|
|
|
|
@type sec_grp: basestring
|
|
@param sec_grp: security group of service
|
|
|
|
@rtype: None
|
|
@returns: Nothing. Given KPI object is modified in-place.
|
|
"""
|
|
if not isinstance(kpi, dict):
|
|
message = 'Invalid type="%s" for KPI. Expecting a dictionary.' % type(kpi).__name__
|
|
logger.error(message)
|
|
raise TypeError(message)
|
|
|
|
if kpi.get('search_type') != 'shared_base':
|
|
# guard against inadvertent call
|
|
logger.warning('Search type="%s" not applicable. Will pass.', kpi.get('search_type'))
|
|
return
|
|
|
|
backend = self.storage_interface.get_backend(self.session_key)
|
|
shared_base_search = backend.get(self.session_key, 'nobody', 'kpi_base_search', kpi.get('base_search_id'))
|
|
|
|
if not isinstance(shared_base_search, dict):
|
|
msg = 'Base search with ID: "%s" does not exist. No attributes to populate.' % kpi.get('base_search_id')
|
|
logger.warning(msg)
|
|
return
|
|
|
|
if shared_base_search.get('sec_grp') not in [sec_grp, GLOBAL_SECURITY_GROUP_CONFIG.get('key')]:
|
|
self.raise_error(logger, 'Shared base search configured on KPI "%s" does not match security '
|
|
'group of KPI/Service. Check the team on the service.' % kpi.get('title'))
|
|
|
|
for attr in BASE_SEARCH_KPI_ATTRIBUTES:
|
|
kpi[attr] = shared_base_search.get(attr, '')
|
|
|
|
metrics = shared_base_search.get('metrics', [])
|
|
for metric in metrics:
|
|
if isinstance(metric, dict) and metric.get('_key') != kpi.get('base_search_metric'):
|
|
continue # configured kpi isnt concerned with this metric
|
|
for attr in BASE_SEARCH_METRIC_KPI_ATTRIBUTES:
|
|
kpi[attr] = metric.get(attr, '')
|
|
break # there can be only one selected metric, we got ours.
|
|
return
|
|
|
|
def _gen_and_update_searches(self, kpi, service_entity_rules, sec_grp):
|
|
"""
|
|
Update KPI search strings for given KPI
|
|
|
|
@type kpi: dict
|
|
@param kpi: kpi object
|
|
|
|
@type service_entity_rules: list
|
|
@param service_entity_rules: entity rules corresponding to the service
|
|
|
|
@type: basestring
|
|
@param sec_grp: security group of the service
|
|
|
|
@rtype: none
|
|
@return: nothing. updates search strings in the KPI passed in.
|
|
"""
|
|
# Now generate search strings for KPI & update the KPI.
|
|
searches = ItsiKpiSearches(self.session_key,
|
|
kpi, service_entity_rules, sec_grp=sec_grp).gen_kpi_searches(gen_alert_search=True)
|
|
|
|
kpi['kpi_base_search'] = searches['kpi_base_search']
|
|
kpi['search'] = searches['alert_search']
|
|
kpi['search_aggregate'] = searches['single_value_search']
|
|
kpi['search_entities'] = searches['single_value_search']
|
|
kpi['search_time_series'] = searches['time_series_search']
|
|
kpi['search_time_series_aggregate'] = searches['time_series_search']
|
|
kpi['search_time_series_entities'] = searches['entity_time_series_search']
|
|
kpi['search_time_compare'] = searches['compare_search']
|
|
kpi['search_alert'] = searches['alert_search']
|
|
|
|
# Assume search fields are always present
|
|
if kpi.get('search_type', 'adhoc') == 'datamodel':
|
|
# Assume default to be true to avoid accidental overwrite here
|
|
# User specifies base_search for adhoc searches but is generated
|
|
# for datamodel searches, so set it explicitly after generation
|
|
kpi['base_search'] = kpi['kpi_base_search']
|
|
|
|
# KPI thresholds searches need to be updated too.
|
|
# we do not need to save search strings in threshold objects
|
|
if (isinstance(kpi.get('aggregate_thresholds'), dict)
|
|
and isinstance(kpi['aggregate_thresholds'].get('search'), itsi_py3.string_type)):
|
|
kpi['aggregate_thresholds']['search'] = ''
|
|
|
|
if (isinstance(kpi.get('entity_thresholds'), dict)
|
|
and isinstance(kpi['entity_thresholds'].get('search'), itsi_py3.string_type)):
|
|
kpi['entity_thresholds']['search'] = ''
|
|
|
|
# KPI time variate threshold searches need to be updated too
|
|
policies = iter(kpi.get('time_variate_thresholds_specification', {}).get('policies', {}).values())
|
|
for policy in policies:
|
|
if isinstance(policy, dict):
|
|
aggregate_thresholds = policy['aggregate_thresholds']
|
|
if 'search' in aggregate_thresholds:
|
|
aggregate_thresholds['search'] = ''
|
|
entity_thresholds = policy['entity_thresholds']
|
|
if 'search' in entity_thresholds:
|
|
entity_thresholds['search'] = ''
|
|
return
|
|
|
|
def populate(self, kpi, service_entity_rules, service_id, service_title, service_is_enabled, sec_grp):
|
|
"""
|
|
populate a KPI object.
|
|
@type kpi: dict
|
|
@param kpi: kpi object
|
|
|
|
@type service_entity_rules: list
|
|
@param service_entity_rules: entity rules for service
|
|
|
|
@type service_id: basestring
|
|
@param service_id: identifier of the service
|
|
|
|
@type service_title: basestring
|
|
@param service_title: title of the service
|
|
|
|
@type service_is_enabled: boolean
|
|
@param service_is_enabled: Indicates if service is enabled or disabled.
|
|
|
|
@type: basestring
|
|
@param sec_grp: security group of the service
|
|
|
|
@rtype None
|
|
@returns: nothing. in-place population.
|
|
"""
|
|
if not isinstance(kpi, dict):
|
|
raise TypeError('Invalid type for KPI. Expecting a dictionary.')
|
|
if not isinstance(service_entity_rules, list):
|
|
# if service_entity_rules is set to none explicitly, convert to list
|
|
if service_entity_rules is None:
|
|
service_entity_rules = []
|
|
else:
|
|
raise TypeError('Invalid type for service_entity_rules. Expecting valid list')
|
|
if not isinstance(service_id, itsi_py3.string_type):
|
|
raise TypeError('Invalid type for service_id. Expecting valid string.')
|
|
if not isinstance(service_title, itsi_py3.string_type):
|
|
raise TypeError('Invalid type for service_title. Expecting valid string.')
|
|
if not isinstance(service_is_enabled, int):
|
|
raise TypeError('Invalid type for service_is_enabled. Expecting int.')
|
|
|
|
if 'search_occurrences' in kpi:
|
|
kpi['search_occurrences'] = int(kpi['search_occurrences']) # Convert to valid number
|
|
|
|
kpi['service_id'] = service_id
|
|
kpi['service_title'] = service_title
|
|
kpi['enabled'] = service_is_enabled
|
|
|
|
kpi['backfill_earliest_time'] = kpi.get('backfill_earliest_time', '-7d').strip().replace(' ', '')
|
|
|
|
if kpi.get('search_type') == 'shared_base':
|
|
self._populate_with_base_search_attr(kpi, sec_grp)
|
|
|
|
self._gen_and_update_searches(kpi, service_entity_rules, sec_grp)
|
|
|
|
def escape_backslash_and_double_quote_in_kpi_title(self, kpi_title):
|
|
"""
|
|
Escape backslash and quote in the kpi title.
|
|
|
|
Args:
|
|
kpi_title (string): Kpi title
|
|
|
|
Returns:
|
|
string : escaped Kpi title
|
|
"""
|
|
escaped_kpi = ''
|
|
for char in kpi_title:
|
|
if char == '\\' or char == '"':
|
|
escaped_kpi += '\\'
|
|
escaped_kpi += char
|
|
return escaped_kpi
|
|
|
|
def generate_saved_search_settings(self, kpi, service_entity_rules, sec_grp, acl_update=True,
|
|
sync_schedule_disabled=True):
|
|
"""
|
|
Generate a dictionary representing settings (kv pairs) for a savedsearches.conf stanza
|
|
|
|
@type kpi: dict
|
|
@param kpi: corresponding kpi object
|
|
|
|
@type service_entity_rules: list
|
|
@param service_entity_rules: entity rules for the given service
|
|
|
|
@type: basestring
|
|
@param sec_grp: security group of the service
|
|
|
|
@rtype: dict
|
|
@param: requested saved search settings
|
|
"""
|
|
saved_search_settings = {}
|
|
saved_search_id = self.get_kpi_saved_search_name(kpi['_key'])
|
|
saved_search_settings['name'] = saved_search_id
|
|
kpi_copy = kpi.copy()
|
|
# Created copy of kpi because we only escape the kpi title
|
|
# in the generated search not in kpi object
|
|
kpi_copy['title'] = self.escape_backslash_and_double_quote_in_kpi_title(kpi_copy.get('title'))
|
|
# NOTE: We set generate_entity_filter to true so that we use the entity_filter lookup macro
|
|
searches = ItsiKpiSearches(
|
|
self.session_key,
|
|
kpi_copy, service_entity_rules,
|
|
generate_entity_filter=True,
|
|
sec_grp=sec_grp
|
|
).gen_kpi_searches(
|
|
gen_alert_search=True
|
|
)
|
|
saved_search_settings['search'] = searches['alert_search']
|
|
|
|
saved_search_settings['description'] = 'Auto created scheduled search during kpi creation'
|
|
saved_search_settings['disabled'] = '0' if kpi.get('enabled') == 1 else '1'
|
|
|
|
# Handle the timing of a KPI search, some data may not be coming in real time so we allow for a
|
|
# configurable lag in the KPI search up to 30 minutes, our values are in seconds for lag, minutes for
|
|
# earliest, so we convert all searches to seconds based time modifiers
|
|
alert_lag = int(kpi.get('alert_lag', 30))
|
|
alert_earliest = int(kpi.get('search_alert_earliest', 5)) * 60
|
|
if alert_lag == 0:
|
|
# Real Time case we need to set latest time to now
|
|
saved_search_settings['dispatch.earliest_time'] = '-' + str(alert_earliest) + 's'
|
|
saved_search_settings['dispatch.latest_time'] = 'now'
|
|
elif alert_lag <= 1800:
|
|
# Normal Case, adjust search timing to account for the lag
|
|
saved_search_settings['dispatch.earliest_time'] = '-' + str(alert_earliest + alert_lag) + 's'
|
|
saved_search_settings['dispatch.latest_time'] = '-' + str(alert_lag) + 's'
|
|
else:
|
|
raise ValueError('Invalid alert_lag passed to saved search management, must be below 30 minutes')
|
|
|
|
saved_search_settings['enableSched'] = '1'
|
|
|
|
# Regenerate a random cron every time in order to take into account a change in the alert period
|
|
# Technically this means on save there is a potential for a kpi to execute slightly off rhythm at
|
|
# the point of save if the start point of the cron changes for a 5 or 15 period kpi
|
|
saved_search_settings['cron_schedule'] = SavedSearch.generate_cron_schedule(kpi.get('alert_period', 5),
|
|
sync_schedule_disabled)
|
|
|
|
saved_search_settings['alert.suppress'] = '0'
|
|
saved_search_settings['alert.track'] = '0'
|
|
saved_search_settings['alert.digest_mode'] = '1'
|
|
|
|
saved_search_settings['actions'] = 'indicator'
|
|
saved_search_settings['action.indicator._itsi_kpi_id'] = kpi.get('_key', '')
|
|
saved_search_settings['action.indicator._itsi_service_id'] = kpi.get('service_id', '')
|
|
saved_search_settings['kpi_title'] = kpi.get('title', '')
|
|
saved_search_settings['acl_update'] = acl_update
|
|
return saved_search_settings
|
|
|
|
def check_perc_value(self, statop):
|
|
"""
|
|
Checks for valid stats operator.
|
|
If the stats operator is percNN, make sure NN is within the valid percentage range
|
|
@type statop: string
|
|
@param statop: stats operator
|
|
@type return: None
|
|
@param return: raises exceptions on invalid stats operators
|
|
"""
|
|
if not utils.is_stats_operation(statop):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
'An invalid aggregation operator is specified for a KPI. '
|
|
'Refer to the ITSI documentation for a list of valid operators and syntax.'
|
|
)
|
|
|
|
# if the statop is 'percNN', validate the percentage range
|
|
# the format of string 'perc' has already been validated by is_stats_operation()
|
|
if 'perc' in statop:
|
|
if not utils.is_valid_perc(statop[4:]):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Invalid percentile value enter, the value must be a whole number between '
|
|
'1 and 99.')
|
|
)
|
|
|
|
def _set_entity_breakdown_field(self, kpi):
|
|
"""
|
|
Set entity_breakdown_id_fields to entity_id_fields,
|
|
if entity_breakdown_id_fields is missing or empty.
|
|
@param kpi: kpi object
|
|
@return: None
|
|
"""
|
|
# PBL-5603: changes made in this story, allow user to split KPI by a different entity field from
|
|
# entity filtering field. As a part of this change, new field 'entity_breakdown_id_fields'
|
|
# was added to kpi object. To guard against migration issues and cases where
|
|
# 'entity_breakdown_id_fields' would be missing in kpi object, added following check. We fall back
|
|
# to 'entity_id_fields', in cases when 'entity_breakdown_id_fields' is missing.
|
|
if kpi.get('is_entity_breakdown', False):
|
|
entity_breakdown_id_fields = kpi.get('entity_breakdown_id_fields', None)
|
|
if entity_breakdown_id_fields is None or len(entity_breakdown_id_fields) == 0:
|
|
kpi['entity_breakdown_id_fields'] = kpi.get('entity_id_fields', '')
|
|
logger.debug('entity_breakdown_id_fields missing from kpi object = {}. '
|
|
'Setting it to entity_id_fields.'.format(kpi.get('_key')))
|
|
|
|
def validate_kpi_basic_structure(self, kpi, for_base_service_template=False):
|
|
"""
|
|
Validate only the KPI level validation, skips any validation that depends on parent object.
|
|
@type kpi: iterable list
|
|
@param kpi: a valid list of KPIs in json format
|
|
@type return: None
|
|
@param return: None, raise exceptions on invalid KPIs
|
|
"""
|
|
if not utils.is_valid_str(kpi.get('title')):
|
|
self.raise_error_bad_validation(logger, 'KPIs must have a valid title.')
|
|
|
|
ITOAInterfaceUtils.validate_aggregate_thresholds(kpi)
|
|
ITOAInterfaceUtils.validate_entity_thresholds(kpi)
|
|
|
|
if not for_base_service_template:
|
|
field_validation_list = ['backfill_enabled',
|
|
'time_variate_thresholds',
|
|
'adaptive_thresholds_is_enabled',
|
|
'anomaly_detection_is_enabled',
|
|
'cohesive_anomaly_detection_is_enabled',
|
|
'anomaly_detection_alerting_enabled']
|
|
|
|
else: # do not need to validate backfill fields for Base Service Template KPIs
|
|
field_validation_list = ['time_variate_thresholds',
|
|
'adaptive_thresholds_is_enabled',
|
|
'anomaly_detection_is_enabled',
|
|
'cohesive_anomaly_detection_is_enabled',
|
|
'anomaly_detection_alerting_enabled']
|
|
|
|
for field_name in field_validation_list:
|
|
if field_name not in kpi:
|
|
kpi[field_name] = False
|
|
else:
|
|
field_value = kpi[field_name]
|
|
kpi[field_name] = normalizeBoolean(field_value, enableStrictMode=True)
|
|
|
|
alert_on = kpi.get('alert_on')
|
|
if alert_on and alert_on not in ['aggregate', 'entity', 'both']:
|
|
kpi['alert_on'] = 'aggregate'
|
|
|
|
alert_period = kpi.get('alert_period')
|
|
if alert_period and (utils.is_string_numeric(alert_period) or utils.is_valid_num(alert_period)):
|
|
kpi['alert_period'] = int(alert_period)
|
|
|
|
self._set_entity_breakdown_field(kpi)
|
|
is_valid, msg = validate_data_gaps_filling_options(kpi)
|
|
if not is_valid:
|
|
self.raise_error_bad_validation(logger, msg)
|
|
|
|
# Validate if minimal fields for search are populated
|
|
if 'search_type' in kpi:
|
|
search_type = kpi['search_type']
|
|
if search_type != 'datamodel' and search_type != 'metric':
|
|
if not utils.is_valid_str(kpi.get('base_search')):
|
|
if search_type == 'shared_base':
|
|
backend = self.storage_interface.get_backend(self.session_key)
|
|
shared_base_search = backend.get(self.session_key, 'nobody', 'kpi_base_search',
|
|
kpi.get('base_search_id'))
|
|
if shared_base_search:
|
|
if normalizeBoolean(shared_base_search.get('is_metric')):
|
|
kpi['metric'] = shared_base_search.get('metric', {})
|
|
else:
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Shared base KPIs does not seem to have populated a base search. '
|
|
'Specify a base search for the KPI.')
|
|
)
|
|
else:
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
'Ad hoc search KPIs does not seem to have populated a base search. '
|
|
'Specify a base search for the KPI.'
|
|
)
|
|
|
|
if not utils.is_valid_str(kpi.get('threshold_field')):
|
|
if search_type == 'shared_base':
|
|
backend = self.storage_interface.get_backend(self.session_key)
|
|
shared_base_search = backend.get(self.session_key, 'nobody', 'kpi_base_search',
|
|
kpi.get('base_search_id'))
|
|
if shared_base_search and not normalizeBoolean(shared_base_search.get('is_metric')):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
'A valid threshold field is not specified for a KPI with shared base search. '
|
|
'Threshold fields must be specified for shared base search KPIs.'
|
|
)
|
|
else:
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
'A valid threshold field is not specified for a KPI with ad hoc search. '
|
|
'Threshold fields must be specified for ad hoc search based KPIs.'
|
|
)
|
|
|
|
elif search_type == 'metric':
|
|
# metric based KPI
|
|
metric_search_spec = kpi.get('metric')
|
|
if not utils.is_valid_dict(metric_search_spec):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Metric search KPIs do not seem to have specified a metric search. '
|
|
'Specify a metric based search for the KPI.')
|
|
)
|
|
if (not (utils.is_valid_str(metric_search_spec.get('metric_index'))
|
|
and utils.is_valid_str(metric_search_spec.get('metric_name')))):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Metric search based KPI does not seem to have specified a valid metric search. '
|
|
'Specify metric based search KPIs with all mandatory fields: '
|
|
'metric_index, metric_name.')
|
|
)
|
|
else:
|
|
# Datamodel search based KPI
|
|
datamodel_search_spec = kpi.get('datamodel')
|
|
if not utils.is_valid_dict(datamodel_search_spec):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Data model search-based KPI does not seem to have specified a valid data model search. '
|
|
'Specify a data model search-based KPI.')
|
|
)
|
|
|
|
if (not (utils.is_valid_str(datamodel_search_spec.get('datamodel'))
|
|
and utils.is_valid_str(datamodel_search_spec.get('object'))
|
|
and utils.is_valid_str(datamodel_search_spec.get('field'))
|
|
and utils.is_valid_str(datamodel_search_spec.get('owner_field')))):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Data model search-based KPI does not seem to have specified a valid data model search. '
|
|
'Specify a data model search-based KPI with all mandatory fields: '
|
|
'data model, object, field and owner_field.')
|
|
)
|
|
|
|
datamodel_filters = kpi.get('datamodel_filter', [])
|
|
if not utils.is_valid_list(datamodel_filters):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Data model filters must be an array of filters. '
|
|
'Found a KPI with an invalid specification for data model filters.')
|
|
)
|
|
|
|
for datamodel_filter in datamodel_filters:
|
|
if not utils.is_valid_dict(datamodel_filter):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Each data model filter must be a valid JSON filter specification. '
|
|
'Found a KPI with an invalid specification for a data model filter.')
|
|
)
|
|
|
|
if not (utils.is_valid_str(datamodel_filter.get('_field'))
|
|
and utils.is_valid_str(datamodel_filter.get('_value'))):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Each data model filter must specify a field and value. '
|
|
'Found a KPI with no field or value specified for a data model filter.')
|
|
)
|
|
|
|
filter_operator = datamodel_filter.get('_operator')
|
|
if not (utils.is_valid_str(filter_operator) and (filter_operator in ['=', '>', '<'])):
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Each data model filter operator must be =, < or >. '
|
|
'Found a KPI with invalid operator specified for a data model filter.')
|
|
)
|
|
|
|
# aggregate_statop is a mandatory field, check the syntax
|
|
aggregate_statop = kpi.get('aggregate_statop')
|
|
if isinstance(aggregate_statop, itsi_py3.string_type):
|
|
self.check_perc_value(aggregate_statop)
|
|
else:
|
|
# We can infer it from the old statop field if that is present
|
|
old_statop = kpi.get('statop')
|
|
if isinstance(old_statop, itsi_py3.string_type):
|
|
self.check_perc_value(old_statop)
|
|
kpi['aggregate_statop'] = old_statop
|
|
else:
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
'A valid aggregation operator is not specified for a KPI. Aggregate operator must be specified.'
|
|
)
|
|
|
|
# entity_statop is an optional field, need to check syntax if it exists
|
|
entity_statop = kpi.get('entity_statop')
|
|
if isinstance(entity_statop, itsi_py3.string_type):
|
|
self.check_perc_value(entity_statop)
|
|
|
|
alert_lag = kpi.get('alert_lag')
|
|
try:
|
|
if alert_lag is not None:
|
|
alert_lag = int(alert_lag)
|
|
else:
|
|
alert_lag = 30
|
|
except Exception:
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
'Invalid alert_lag, must be a positive integer less than 1800 (in s = 30 minutes).'
|
|
)
|
|
|
|
if alert_lag >= 1800:
|
|
# 30 minutes enforced due to restrictions of the health scoring system for services
|
|
self.raise_error_bad_validation(
|
|
logger,
|
|
('Invalid alert_lag, must be a positive integer less than 1800 (in s = 30 minutes). '
|
|
'Specified: {0}').format(alert_lag)
|
|
)
|
|
|
|
def convert_invalid_datamodel_kpi_to_adhoc(self, kpi, cached_datamodel_dict):
|
|
"""
|
|
Fix up datamodel KPIs with invalid datamodels - this is to avoid errors during saving in several scenarios
|
|
IMPORTANT: this should really be used only in upgrade scenarios. In standard configuration, this is not needed.
|
|
@type kpi: dict
|
|
@param kpi: a single KPI
|
|
|
|
@type cached_datamodel_dict: dict
|
|
@param kpi: a prefetched list of datamodels
|
|
|
|
@type return: boolean
|
|
@param return: True if a conversion was performed
|
|
"""
|
|
if kpi.get('search_type', '') == 'datamodel':
|
|
try:
|
|
datamodel_spec = kpi.get('datamodel', {})
|
|
ItsiKpiSearches.get_datamodel_context(self.session_key,
|
|
'nobody',
|
|
datamodel_spec.get('field'),
|
|
datamodel_spec.get('datamodel'),
|
|
datamodel_object_name=datamodel_spec.get('object'),
|
|
cached_datamodel_dict=cached_datamodel_dict)
|
|
|
|
except ItoaDatamodelContextError:
|
|
'''
|
|
Mark the searches as invalid adhoc searches to provide a cue in KPI config. Altering the search
|
|
is needed since an invalid datamodel search will fail saved search creation.
|
|
'''
|
|
kpi['search_type'] = 'adhoc'
|
|
kpi['base_search'] = 'Invalid datamodel search "' + kpi.get('base_search', '') + '"'
|
|
|
|
logger.error('Found KPI (Id: %s) with stale datamodel specification. Auto converting '
|
|
'this KPI to adhoc search type to prevent migration/upgrade failures.', kpi.get('_key'))
|
|
return True
|
|
return False
|