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.

341 lines
14 KiB

# Copyright (C) 2005-2024 Splunk Inc. All Rights Reserved.
import itsi_py3
from ITOA.itoa_common import is_valid_str, normalize_num_field
from ITOA.setup_logging import getLogger, setup_logging
from ITOA.itoa_factory import instantiate_object
from itsi.itsi_time_block_utils import ItsiTimeBlockUtils, CRON_ELEMENT_TYPES
logger = getLogger()
def migrate_timezones(splunkd_session_key, tool_options):
"""
In addition to the above migration handlers, we need a manual tool for admins to apply timezone offsets
to objects that were configures in browser timezone which we cannot auto detect. This tool will be exposed
as a mode in existing kvstore_to_json tool. But keeping its implementation logic here owing to relevance
in this migration code
Performs timezone offset operations for mode 3 of the tool
@param splunkd_session_key: Session key for Splunkd operations
@param tool_options: options passed in from the tool
@return: None, output is written to stdout
"""
owner = 'nobody'
# Validate group options
object_type = tool_options.object_type
object_title = tool_options.object_title
is_get = tool_options.is_get
tz_logger = setup_logging(logger=logger, is_console_header=True)
object_title_filter = None
if is_valid_str(object_title):
object_title_filter = {'title': object_title}
try:
object_type_instance = instantiate_object(splunkd_session_key, owner, object_type, logger=tz_logger)
except Exception as e:
logger.exception(e)
raise Exception('Specified object type "%s" is invalid.' % object_type)
objects = object_type_instance.get_bulk(owner, filter_data=object_title_filter)
if len(objects) < 1:
print('No objects matched request. Stopping here')
return
print('\n%s object(s) match request' % len(objects) if objects is not None else 0)
if is_get:
print('Retrieved requested object(s):\n' + str(objects))
else:
print('Applying timezone change on requested object(s): ' + str([data.get('title') for data in objects]))
object_attributes_map = {
'maintenance_calendar': ['start_time', 'end_time']
}
offset_in_sec = None
try:
offset_in_sec = float(tool_options.offset_in_sec)
except Exception:
pass # Will detect below and show meaningful error
if not isinstance(offset_in_sec, float):
raise Exception('Specified timezone offset to apply is invalid. Must pick a number.')
for json_data in objects:
if object_type == 'service':
apply_timezone_offset_for_service(json_data, int(offset_in_sec / 60))
elif object_type == 'kpi_threshold_template':
apply_timezone_offset_for_kpi_threshold_template(json_data, int(offset_in_sec / 60))
else:
for json_field in object_attributes_map[object_type]:
# Only convert offset if end_time is not set to "Indefinite" for maintenance calendars
if object_type == 'maintenance_calendar' and json_field == 'end_time' and \
_is_maintenance_end_time_indefinite(json_data):
continue
apply_timezone_offset(json_data, json_field, offset_in_sec)
object_type_instance.save_batch(owner, objects, True)
print('Timezone offset has been applied on the objects requested.\n')
def apply_timezone_offset(json_data, json_field, offset_in_sec):
"""
Utility method to apply a timezone offset to an epoch field
@type json_data: json dict
@param json_data: the payload to update
@type json_field: basestring
@param json_field: the name of the field in the payload containing epoch value to update
@type offset_in_sec: float
@param offset_in_sec: the seconds offset to apply to the epoch
@rtype: None
@return: None, the json payload is updated in place
"""
if not isinstance(json_data, dict):
message = '`json_data` is not a valid dictionary, found type %s. Cannot apply timezone offset.' % type(
json_data).__name__
logger.error(message)
raise TypeError(message)
if not isinstance(json_field, itsi_py3.string_type):
message = '`json_field` is not a valid string, found type %s. Cannot apply timezone offset.' % type(
json_field).__name__
logger.error(message)
raise TypeError(message)
if not (isinstance(offset_in_sec, float) or isinstance(offset_in_sec, int)):
message = '`offset_in_sec` is not a valid number, found type %s. Cannot apply timezone offset.' % type(
offset_in_sec).__name__
logger.error(message)
raise TypeError(message)
if json_field in json_data:
normalize_num_field(json_data, json_field, numclass=float)
json_data[json_field] += offset_in_sec
def apply_timezone_offset_for_service(service, offset_in_min):
"""
Given the number of hours needed to offset time_blocks, apply the change to all KPIs in a service
@type service: object
@param service: Service object instance to update
@type offset_in_min: int
@param offset_in_min: the # of hours of offset to apply
@rtype: None
@return: None although service input reference will be updated
"""
# Compute the day/hour/minute change as needed for the time blocks based on the offset
if not (-1440 <= offset_in_min <= 1440):
message = 'Timezone offset specified is invalid. Must be within a 24-hour (1440 minute) range. Specified value: %s ' % offset_in_min
logger.error(message)
raise ValueError(message)
if not isinstance(service, dict):
message = '`service` is not a valid dictionary, found type %s. Cannot apply timezone offset.' % type(service).__name__
logger.error(message)
raise TypeError(message)
if not isinstance(offset_in_min, int):
message = '`offset_in_min` is not a valid int, found type %s. Cannot apply timezone offset.' % type(offset_in_min).__name__
logger.error(message)
raise TypeError(message)
# Only migrate valid time blocks, ignore bad config
for kpi in service.get('kpis', []):
if not isinstance(kpi, dict):
logger.warn('KPI looks invalid, skipping timezone offset adjustment for KPI "%s"', kpi)
continue # Ignore
policy_spec = kpi.get('time_variate_thresholds_specification')
if not isinstance(policy_spec, dict):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
policies = policy_spec.get('policies')
if not isinstance(policies, dict):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
for policy_key, policy in policies.items():
if not isinstance(policy, dict):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
time_blocks = policy.get('time_blocks')
if not isinstance(time_blocks, list):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
kpi['time_variate_thresholds_specification']['policies'][policy_key]['time_blocks'] = get_new_time_blocks(time_blocks, offset_in_min)
def apply_timezone_offset_for_kpi_threshold_template(kpi_threshold_template, offset_in_min):
"""
Given the number of hours needed to offset time_blocks, apply the change to the given KPI threshold template
@type kpi_threshold_template: object
@param kpi_threshold_template: KPI threshold template object instance to update
@type offset_in_min: int
@param offset_in_min: the # of hours of offset to apply
@rtype: None
@return: None although kpi_threshold_template input reference will be updated
"""
# Compute the day/hour/minute change as needed for the time blocks based on the offset
if not (-1440 <= offset_in_min <= 1440):
message = 'Timezone offset specified is invalid. Must be within a 24-hour (1440 minute) range. Specified value: %s ' % offset_in_min
logger.error(message)
raise ValueError(message)
if not isinstance(kpi_threshold_template, dict):
message = '`kpi_threshold_template` is not a valid dictionary, found type %s. Cannot apply timezone offset.' % type(kpi_threshold_template).__name__
logger.error(message)
raise TypeError(message)
if not isinstance(offset_in_min, int):
message = '`offset_in_min` is not a valid int, found type %s. Cannot apply timezone offset.' % type(offset_in_min).__name__
logger.error(message)
raise TypeError(message)
policy_spec = kpi_threshold_template.get('time_variate_thresholds_specification')
if not isinstance(policy_spec, dict):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
policies = policy_spec.get('policies')
if not isinstance(policies, dict):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
for policy_key, policy in policies.items():
if not isinstance(policy, dict):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
time_blocks = policy.get('time_blocks')
if not isinstance(time_blocks, list):
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
kpi_threshold_template['time_variate_thresholds_specification']['policies'][policy_key]['time_blocks'] = get_new_time_blocks(time_blocks, offset_in_min)
def get_new_time_blocks(time_blocks, offset_in_min):
"""
Return a list of new time_blocks by manipulating current time_blocks using the offset_in_min value
@type time_blocks: list
@param time_blocks: time blocks list that needs to be updated
@type offset_in_min: int
@param offset_in_min: the # of hours of offset to apply
@rtype: list
@return: empty list if time_blocks is empty or else non-empty list with updated time blocks
"""
new_time_blocks = []
for time_block in time_blocks:
try:
# validate time block before applying offset
ItsiTimeBlockUtils.expand_time_block(time_block)
except Exception as e:
logger.exception(e)
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
# expand time block into time blocks with single numbers only aka no ranges
expanded_time_blocks = ItsiTimeBlockUtils.expand_time_block_cron(time_block)
if not len(expanded_time_blocks) > 0:
logger.warn('KPI time block policy looks invalid, skipping timezone offset adjustment for policy')
continue # Ignore
shifted_time_blocks = []
for expanded_time_block in expanded_time_blocks:
split_time_block_cron = expanded_time_block[0].split(' ')
expanded_time_block_min = int(split_time_block_cron[CRON_ELEMENT_TYPES.index('minute')])
expanded_time_block_hour = int(split_time_block_cron[CRON_ELEMENT_TYPES.index('hour')])
expanded_time_block_day = int(split_time_block_cron[CRON_ELEMENT_TYPES.index('day_of_week')])
new_time_block_min = (expanded_time_block_min + offset_in_min) % 60
overflow_hours = (expanded_time_block_min + offset_in_min) // 60
new_time_block_hour = (expanded_time_block_hour + overflow_hours) % 24
overflow_days = (expanded_time_block_hour + overflow_hours) // 24
new_time_block_day = (expanded_time_block_day + overflow_days) % 7
new_time_block = [
' '.join([
str(new_time_block_min),
str(new_time_block_hour),
'*',
'*',
str(new_time_block_day)
]),
expanded_time_block[1]
]
shifted_time_blocks.append(new_time_block)
# collapse shifted time blocks into a single time block
list_of_days = [int(shifted_time_block[0].split(' ')[CRON_ELEMENT_TYPES.index('day_of_week')]) for shifted_time_block in shifted_time_blocks]
collapsed_list_of_days = ItsiTimeBlockUtils.convert_numbers_to_cron_element(list_of_days)
first_time_block = shifted_time_blocks[0]
first_split_time_block_cron = first_time_block[0].split(' ')
time_block_to_add = [
' '.join([
str(first_split_time_block_cron[0]),
str(first_split_time_block_cron[1]),
'*',
'*',
collapsed_list_of_days
]),
first_time_block[1]
]
new_time_blocks.append(time_block_to_add)
return new_time_blocks
"""
UI interprets indefinite end time for maintenance windows as JS Date(2038, 0, 18)
The epoch value for JS Date(2038, 0, 18) can vary for different timezones
The minimum value for indefinite maintenance window epoch will therefore be considered
JS Date(2038, 0, 18) - 24 hours (maximum possible timezone offset) = 2147414400 - 86400 = 2147328000
Backend will consider any epoch end time greater than 2147328000 as indefinite end time
"""
_maintenance_calendar_min_indefinite_end_time = 2147328000
def _is_maintenance_end_time_indefinite(maintenance_calendar_json):
"""
Helper method to detect if maintenance calendar is configured with indefinite end time
@type maintenance_calendar_json: JSON dict
@param maintenance_calendar_json: JSON payload of maintenance configuration
@rtype: boolean
@return: True if yes, False if no
"""
if not isinstance(maintenance_calendar_json, dict):
return False
end_time = maintenance_calendar_json.get('end_time')
if isinstance(end_time, str) or isinstance(end_time, int):
try:
end_time = float(end_time)
except (TypeError, ValueError):
# Ignore any conversion failures
pass
return isinstance(end_time, float) and end_time >= _maintenance_calendar_min_indefinite_end_time