###################################################################################################################### # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # # # Licensed under the Apache License Version 2.0 (the "License"). You may not use this file except in compliance # # with the License. A copy of the License is located at # # # # http://www.apache.org/licenses/ # # # # or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES # # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # # and limitations under the License. # ###################################################################################################################### import copy import datetime import decimal import json import os import re import types import boto3 import actions import boto_retry import configuration import handlers import metrics import metrics.task_metrics import pytz import services from boto_retry import get_client_with_retries from helpers import safe_json from outputs import raise_exception, raise_value_error from scheduling.cron_expression import CronExpression VALID_EVENT_SCOPES = [handlers.EVENT_SCOPE_RESOURCE, handlers.EVENT_SCOPE_REGION] VALID_TASK_ATTRIBUTES = [ configuration.CONFIG_ACCOUNTS, configuration.CONFIG_ACTION_NAME, configuration.CONFIG_TASK_CROSS_ACCOUNT_ROLE_NAME, configuration.CONFIG_DEBUG, configuration.CONFIG_DESCRIPTION, configuration.CONFIG_DRYRUN, configuration.CONFIG_ECS_COMPLETION_MEMORY, configuration.CONFIG_ECS_EXECUTE_MEMORY, configuration.CONFIG_ECS_SELECT_MEMORY, configuration.CONFIG_EVENT_SOURCE_TAG_FILTER, configuration.CONFIG_ENABLED, configuration.CONFIG_EVENT_SCOPES, configuration.CONFIG_EVENTS, configuration.CONFIG_INTERNAL, configuration.CONFIG_INTERVAL, configuration.CONFIG_PARAMETERS, configuration.CONFIG_REGIONS, configuration.CONFIG_STACK_ID, configuration.CONFIG_TAG_FILTER, configuration.CONFIG_TASK_COMPLETION_SIZE, configuration.CONFIG_TASK_EXECUTE_SIZE, configuration.CONFIG_TASK_METRICS, configuration.CONFIG_TASK_NAME, configuration.CONFIG_TASK_NOTIFICATIONS, configuration.CONFIG_TASK_SELECT_SIZE, configuration.CONFIG_TASK_TIMEOUT, configuration.CONFIG_THIS_ACCOUNT, configuration.CONFIG_TIMEZONE ] SSM_PARAM_REGEX = r"^{ssm:(.+)\}$" VALID_ACCOUNT_REGEX = r"^\d{12}$" VALID_ROLE_NAME_REGEX = "^[\w+=,\.@-]{1,64}$" DEFAULT_TIMEZONE = "UTC" INF_READ_ARN_RESULT = "Read {} cross account arns for task with name {}{}" INF_READING_OBJECT = "Reading task cross account roles file {}" INF_REMOVE_TOPIC_PERMISSION = "Remove permission for account {} to public on ops automator topic, label = {}" INF_ADD_ACCOUNT_TOPIC_PERMISSION = "Add permission for account {} to publish on ops automator topic, label is {}" WARN_NOT_REGIONAL_SERVICE = "One or more regions ({}) are specified but service \"{}\" or action {} is not a regional service" WARN_READING_TASK_ROLES = "Error reading roles from {} in bucket {}, ({})" WARN_IGNORED_YEAR = "Year field not supported for cron expression {}, value in years field \"{}\" is ignored as it's a wildcard" WARN_INVALID_PARAMETER = "Parameter \"{}\" is not a valid parameter for action \"{}\", valid parameters are {}" WARN_NO_PARAMETERS = "Parameter \"{}\" is not a valid parameter, action \"{}\" has no parameters" WARN_DOUBLE_ACCOUNTS = "Account {} in account list is duplicated or same as own account number for task {}" ERR_ACTION_ONLY_INTERNAL = "Action {} is marked as an internal action and can only be used in internal tasks" ERR_BAD_REGION = "Region \"{}\" is not a valid region for service \"{}\", available regions are {}" ERR_CREATING_TASK_OBJECT = "Error creating task config objects for task {} in {}/{}, {}" ERR_DETAIL_TYPE_NOT_HANDLED = "Event detail type {} not handled by action, types supported for source {} are {}" ERR_ERR_IN_CONFIG_ITEM = "Error in configuration item : {} ({})" ERR_EVENT_NOT_HANDLED = "Event {} for event source {}, detail type {} is not handled by this action, valid events are {}" ERR_EVENT_SCOPE_DETAIL_TYPE_NOT_HANDLED = "Event detail type {} not handled by action or detail type does not allow scope " \ "definition" ERR_EVENT_SCOPE_EVENT_NOT_HANDLED = "Event {} for event source {}, detail type {} is not handled by this action or event does " \ "not allow setting the event scope" ERR_EVENT_SCOPE_SOURCE_NOT_HANDLED = "Events of source {} are not supported by this action or source has no events that allow " \ "setting the event scope" ERR_EVENT_SOURCE_NOT_HANDLED = "Events of source {} are not supported by this action, supported sources are \"{}\"" ERR_INVALID_ACTION_NAME = "Action with name \"{}\" does not exist, available actions are {}" ERR_INVALID_BOOL = "\"{}\" is not a valid boolean value" ERR_INVALID_CRON_EXPRESSION = "{} is not a valid cron expression, ({})" ERR_INVALID_ECS_MEMORY_SIZE = "{} is not a valid value for Ecs memory size {} parameter." ERR_INVALID_LAMBDA_SIZE = "{} is not a valid size, possible values are {}" ERR_INVALID_NUMERIC_TIMEOUT = "{} is not a valid numeric value for timeout parameter ({})" ERR_INVALID_TASK_INTERVAL = "Interval cron expression \"{}\" for task {} must have 5 fields" ERR_INVALID_TIMEZONE = "\"{}\" is not a valid timezone" ERR_MAX_LEN = "Value \"{}\" is longer than maximum length {} for parameter {}" ERR_MAX_VALUE = "Value {} is higher than maximum value {} for parameter {}" ERR_MIN_LEN = "Value \"{}\" is shorter than minimum length {} for parameter {}" ERR_MIN_VALUE = "Value {} is less than minimum value {} for parameter {}" ERR_MISSING_REQUIRED_PARAMETER = "error: parameter \"{}\" must exists and can not be empty" ERR_NO_CROSS_ACCOUNT_OPERATIONS = "Action {} does not support cross account operations" ERR_NO_TAG_FILTER = "Resource type \"{}\" for task does not support tags, tag-filer \"{}\" not allowed for action \"{}\"" ERR_NO_TASK_ACTION_NAME = "Action name not specified" ERR_NO_WILDCARDS_TAG_FILTER_ALLOWED = "Tag wildcard filter \"{}\" is not allowed for name of tag in tagfilter for action \"{}\"" ERR_NOT_ALLOWED_VALUE = "Value \"{}\" is not in list of allowed values ({}) for parameter {}" ERR_PATTERN_VALUE = "Value \"{}\" does not match allowed pattern \"{}\" for parameter \"{}\"" ERR_REQUIRED_PARAM_MISSING = "Required parameter \"{}\" is missing" ERR_SSM_PARAM_NOT_FOUND = "SSM Parameter {} not found" ERR_TASK_INTERVAL_TOO_SHORT = "Interval between executions must be at least {} minutes, interval \"{}\" can not be used as its " \ "time interval is {} minutes" ERR_THIS_ACCOUNT_MUST_BE_TRUE = "Action {} only supports operations in this account, parameter \"{}\"must be set to value true" ERR_TIMEOUT_MUST_BE_GREATER_0 = "Timeout parameter value must be > 0, current value is {}" ERR_TIMEOUT_NOT_ALLOWED = "Action {} has no completion handling, timeout parameter not allowed" ERR_UNKNOWN_PARAMETER = "error: parameter \"{}\" is not a valid parameter, valid parameters are {}" ERR_VALIDATING_TASK_PARAMETERS = "Error validating parameters for task {}, {}" ERR_WRONG_PARAM_TYPE = "Type of parameter \"{}\" must be \"{}\", current type for value {} is {}" ERR_INVALID_EVENT_SCOPE = "Event scope {} is not valid, valid values are {}" ERR_INVALID_ACCOUNT_NUMBER_FORMAT = "{} is not a valid account number" ERR_INVALID_ROLE_NAME = "\"{}\" in task {} is not a valid role name" ERR_FETCHING_TASKS_FROM_CONFIG = "Error getting tasks {}" _checked_timezones = dict() _invalid_timezones = set() _service_regions = {} _service_is_regional = {} class TaskConfiguration(object): """ Task configuration actions """ def __init__(self, context=None, logger=None): """ Initializes the instance :param context: Lambda context :param logger: Optional logger for warnings, if None then warnings are printed to console """ self._logger = logger self._this_account = None self._context = context self._all_timezones = {tz.lower(): tz for tz in pytz.all_timezones} self._all_actions = actions.all_actions() self._s3_client = None self._s3_configured_cross_account_roles = None self._ssm_client = None @property def config_table(self): """ Returns the configuration table :return: the configuration table """ table_name = os.getenv(configuration.ENV_CONFIG_TABLE) table = boto3.resource("dynamodb").Table(table_name) boto_retry.add_retry_methods_to_resource(table, ["scan", "get_item", "delete_item", "put_item"], context=self._context) return table @property def s3_client(self): """ Returns S3 client for handling configuration files :return: S3 client """ if self._s3_client is None: # IMPORTANT, list_objects and list_objects_v2 require s3:ListBucket permission !! self._s3_client = boto_retry.get_client_with_retries("s3", ["list_objects_v2", "get_object"]) return self._s3_client @property def ssm_client(self): if self._ssm_client is None: self._ssm_client = get_client_with_retries("ssm", ["get_parameters"]) return self._ssm_client @staticmethod def config_table_exists(): tablename = os.environ[configuration.ENV_CONFIG_TABLE] for t in boto3.resource("dynamodb").tables.all(): if t.table_name == tablename: return True return False def _info(self, msg, *args): if self._logger: self._logger.info(msg, *args) else: print((msg.format(*args))) def config_items(self, include_internal=False): """ Returns all items from the configuration table :return: all items from the configuration table """ scan_args = { } while True: scan_resp = self.config_table.scan_with_retries(**scan_args) for item in scan_resp.get("Items", []): if not item.get(configuration.CONFIG_INTERNAL, False) or include_internal: yield item if "LastEvaluatedKey" in scan_resp: scan_args["ExclusiveStartKey"] = scan_resp["LastEvaluatedKey"] else: break def get_config_item(self, name): """ Reads a specific item from the configuration using its name as the key :param name: name of the task :return: The item for the task, None if it does not exist """ query_resp = self.config_table.get_item_with_retries(Key={configuration.CONFIG_TASK_NAME: name}, ConsistentRead=True) return query_resp.get("Item", None) def delete_config_item(self, name): """ Deletes a task item from the configuration table :param name: name of the task :return: """ # get the regions for which item may have event permissions self.config_table.delete_item_with_retries(Key={configuration.CONFIG_TASK_NAME: name}) # update event topic permissions self._update_ops_automator_topic_permissions() return {"name": name} def put_config_item(self, **kwargs): """ Writes a task item to the table after validating the input arguments. If the item with the name as specified in the task argument already exists it is overwritten :param kwargs: :return: """ def remove_empty_strings(item): if item is None: return None if isinstance(item, list): return [remove_empty_strings(j) for j in item] if isinstance(item, dict): item = {i: remove_empty_strings(item[i]) for i in item} if isinstance(item, str): if len(item.strip()) == 0: return None return item config_item = self._verify_configuration_item(**kwargs) config_item = remove_empty_strings(remove_empty_strings(config_item)) # test if update has action that has event settings event_regions = set() if len(actions.get_action_properties(config_item[configuration.CONFIG_ACTION_NAME]).get(actions.ACTION_EVENTS, {})) > 0: # get the regions with event bus permissions event_regions.update(self._regions_for_tasks_with_events()) event_regions.update(config_item.get(configuration.CONFIG_REGIONS, [])) self.config_table.put_item_with_retries(Item=config_item) # update topic permissions self._update_ops_automator_topic_permissions() if config_item[configuration.CONFIG_TASK_METRICS]: metrics.setup_tasks_metrics(task=config_item[configuration.CONFIG_TASK_NAME], action_name=config_item[configuration.CONFIG_ACTION_NAME], task_level_metrics=config_item[configuration.CONFIG_TASK_METRICS], context=self._context, logger=self._logger) self.create_task_config_objects(config_item) return config_item def create_task_config_objects(self, config_item): # get the class that implements the action and test if there is a static method for creating templates action_class = actions.get_action_class(config_item[configuration.CONFIG_ACTION_NAME]) create_task_objects_method = getattr(action_class, "create_task_config_objects", None) # if the method exists then validate the parameters using the business logic for that class bucket = os.getenv(configuration.ENV_CONFIG_BUCKET) prefix = "{}/{}/{}/".format(configuration.TASKS_OBJECTS, config_item[configuration.CONFIG_ACTION_NAME], config_item[configuration.CONFIG_TASK_NAME]) task_name = config_item[configuration.CONFIG_TASK_NAME] try: if create_task_objects_method is not None: cfg = self.get_parameters(config_item) objects = create_task_objects_method(cfg) if objects is not None: s3 = boto3.client("s3") for t in objects: s3.put_object(Bucket=bucket, Key=prefix + t, Body=objects[t]) self._logger.info("Created config object {}/{} in bucket {} for task {}", prefix, t, bucket, task_name) except Exception as ex: self._logger.error(ERR_CREATING_TASK_OBJECT, task_name, bucket, prefix, ex) @classmethod def service_regions(cls, service_name): """ Returns available regions for a service :param service_name: Name of the service :return: list of regions in which the service is available """ available_regions = _service_regions.get(service_name) if available_regions is not None: return available_regions available_regions = services.create_service(service_name).service_regions() _service_regions[service_name] = available_regions return available_regions @classmethod def service_is_regional(cls, service_name): """ Returns if a service is a regional service :param service_name: Name of the service :return: True if service is regional """ is_regional = _service_is_regional.get(service_name) if is_regional is not None: return is_regional service_class = services.get_service_class(service_name) is_regional = service_class.is_regional() _service_is_regional[service_name] = is_regional return is_regional @property def this_account(self): """ Returns the AWS account number :return: AWS account number """ if self._this_account is None: client = boto_retry.get_client_with_retries("sts", ["get_caller_identity"], context=self._context) self._this_account = client.get_caller_identity_with_retries()["Account"] return self._this_account @staticmethod def is_valid_account_number(arn): return re.match(VALID_ACCOUNT_REGEX, arn) is not None def validate_action(self, action_name): """ Tests if an action name is a known action name :param action_name: The name of the action :return: The name of the action if it is valid, if not an exception is raised """ if action_name is None or action_name.strip() == "": raise_value_error(ERR_NO_TASK_ACTION_NAME) result = action_name.strip() if result not in self._all_actions: raise_value_error(ERR_INVALID_ACTION_NAME, result, ",".join(sorted(self._all_actions))) return result @staticmethod def validate_tagfilter(tag_filter, action_name): """ Tests if tags are supported by the resources for the action. If this is nit the case then the use of tag filters is not possible and an exception is raised :param tag_filter: Tag filter value :param action_name: Name of the action :return: Filter if tags are supported and the filter can be used, otherwise an exception is raised """ if tag_filter is not None: tag_filter = tag_filter.strip() if tag_filter in ["None", None, ""]: return None action_properties = actions.get_action_properties(action_name) resources = action_properties.get(actions.ACTION_RESOURCES) resources_with_tags = services.create_service(action_properties[actions.ACTION_SERVICE]).resources_with_tags resource_supports_tags = (resources == "" and len(resources_with_tags) > 0) or resources in resources_with_tags # resource does not allow tags, so tag filters can not be used if not resource_supports_tags: raise_value_error(ERR_NO_TAG_FILTER, action_properties[actions.ACTION_RESOURCES], tag_filter, action_name) # destructive actions can deny use of wildcards for tag name if not action_properties.get(actions.ACTION_ALLOW_TAGFILTER_WILDCARD, True): if "".join([s.strip() for s in tag_filter.split("=")[0:1]]) in ["*", "**", "*="]: raise_value_error(ERR_NO_WILDCARDS_TAG_FILTER_ALLOWED, tag_filter, action_name) return tag_filter def verify_task_parameters(self, task_parameters, task_settings, action_name): """ Validates parameter set and values for the specified action. A ValueException is raised in the following cases: -Required parameter is not available and there is no default specified for the action -Unknown parameter found -Type of parameter is wrong and value can task_parameters not be converted to the required type -Value is out of range specified by min and max value for numeric parameters -Value is too long, too short or does not mats the allowed pattern for string parameters :param task_parameters: Dictionary of parameters, keys are name of the parameters, value is parameter value :param task_settings: Task settings without parameters included yet :param action_name: Name of the action :return: Dictionary of validated parameters, missing non required parameters are set to default if specified in action implementation """ validated_parameters = {} def verify_numeric_parameter(value, action_param): if type(value) in [int, float, int, complex, decimal]: if actions.PARAM_MIN_VALUE in action_param and value < action_param[actions.PARAM_MIN_VALUE]: raise_value_error(ERR_MIN_VALUE, value, action_param[actions.PARAM_MIN_VALUE], param_name) if actions.PARAM_MAX_VALUE in action_param and value > action_param[actions.PARAM_MAX_VALUE]: raise_value_error(ERR_MAX_VALUE, value, action_param[actions.PARAM_MAX_VALUE], param_name) def verify_string_parameter(value, action_param): if type(value) in [str, str]: if actions.PARAM_MIN_LEN in action_param and len(value) < action_param[actions.PARAM_MIN_LEN]: raise_value_error(ERR_MIN_LEN, value, action_param[actions.PARAM_MIN_LEN], param_name) if actions.PARAM_MAX_LEN in action_param and len(value) > action_param[actions.PARAM_MAX_LEN]: raise_value_error(ERR_MAX_LEN, value, action_param[actions.PARAM_MAX_LEN], param_name) if actions.PARAM_PATTERN in action_param and not re.match(action_param[actions.PARAM_PATTERN], value): raise_value_error(ERR_PATTERN_VALUE, value, action_param[actions.PARAM_PATTERN], param_name) def verify_known_parameter(parameters, action_params): # test for unknown parameters in the task definition for tp in parameters: if tp not in action_params: if len(action_params) > 0: self._logger.warning(WARN_INVALID_PARAMETER, tp, action_name, ", ".join(action_params)) else: self._logger.warning(WARN_NO_PARAMETERS, tp, action_name) def verify_parameter_type(value, action_param): parameter_type = action_param.get(actions.PARAM_TYPE) if parameter_type is not None: if type(value) != parameter_type: try: # value does not match type, try to convert if parameter_type == bool: return TaskConfiguration.as_boolean(str(value)) return parameter_type(value) except Exception: # not possible to convert to required type raise ValueError( ERR_WRONG_PARAM_TYPE.format(param_name, str(parameter_type), parameter_value, type(parameter_value))) return value def verify_allowed_values(value, action_param): if actions.PARAM_ALLOWED_VALUES in action_param and value not in action_param[actions.PARAM_ALLOWED_VALUES]: raise ValueError( ERR_NOT_ALLOWED_VALUE.format(str(parameter_value), ",".join(action_param[actions.PARAM_ALLOWED_VALUES]), param_name)) def verify_required_parameter_available(parameter_name, action_params, parameters): if action_params[parameter_name].get(actions.PARAM_REQUIRED, False) and parameter_name not in parameters: raise_value_error(ERR_REQUIRED_PARAM_MISSING, parameter_name) def get_param_value(name, action_param, parameters): value = parameters.get(name) if value is None: value = action_param.get(actions.PARAM_DEFAULT) return value def action_class_parameter_check(parameters, tsk_settings, name): # get the class that implements the action and test if there is a static method for additional checks of the parameters action_class = actions.get_action_class(name) validate_params_method = getattr(action_class, handlers.ACTION_VALIDATE_PARAMETERS_METHOD, None) # if the method exists then validate the parameters using the business logic for that class try: if validate_params_method is not None: return validate_params_method(parameters, tsk_settings, self._logger) except Exception as ex: self._logger.error(ERR_VALIDATING_TASK_PARAMETERS, name, ex) raise_value_error(ERR_VALIDATING_TASK_PARAMETERS, name, ex) return parameters action_properties = actions.get_action_properties(action_name) action_parameters = action_properties.get(actions.ACTION_PARAMETERS, {}) verify_known_parameter(task_parameters, action_parameters) for param_name in action_parameters: verify_required_parameter_available(param_name, action_parameters, task_parameters) action_parameter = action_parameters[param_name] parameter_value = get_param_value(param_name, action_parameter, task_parameters) if parameter_value is not None: parameter_value = verify_parameter_type(parameter_value, action_parameter) verify_allowed_values(parameter_value, action_parameter) verify_numeric_parameter(parameter_value, action_parameter) verify_string_parameter(parameter_value, action_parameter) validated_parameters[param_name] = parameter_value validated_parameters = action_class_parameter_check(parameters=validated_parameters, tsk_settings=task_settings, name=action_name) return validated_parameters @staticmethod def validate_events(events, action_name): validated = {} # get properties for action for the task and the actions parameter definitions action_properties = actions.get_action_properties(action_name) action_events = action_properties.get(configuration.CONFIG_EVENTS, {}) for source in events: if source not in action_events: raise_value_error(ERR_EVENT_SOURCE_NOT_HANDLED, source, ",".join(action_events)) action_detail_types = action_events.get(source, {}) for detail_type in events[source]: if detail_type not in action_detail_types: raise_value_error(ERR_DETAIL_TYPE_NOT_HANDLED, detail_type, source, ",".join(action_detail_types)) action_event_names = action_detail_types.get(detail_type, []) for event in events[source][detail_type]: if event not in action_event_names: raise_value_error(ERR_EVENT_NOT_HANDLED, event, source, detail_type, ",".join(action_event_names)) # if the events are validated from a dynamodb item it is a list of events if isinstance(events[source][detail_type], list): events_for_detail_type = events[source][detail_type] else: # coming from update in cloudformation, it is a dictionary where the value for every key holds # the value of the event is used or not events_for_detail_type = [e for e in events[source][detail_type] if TaskConfiguration.as_boolean(events[source][detail_type][e])] if len(events_for_detail_type) > 0: if source not in validated: validated[source] = {} validated[source][detail_type] = events_for_detail_type return validated @staticmethod def validate_event_scopes(scopes, action_name): validated = {} # get properties for action for the task and the actions parameter definitions action_properties = actions.get_action_properties(action_name) action_scopes = action_properties.get(configuration.CONFIG_EVENT_SCOPES, {}) action_events = action_properties.get(configuration.CONFIG_EVENTS, {}) for source in scopes: if source not in action_scopes or source not in action_events: raise_value_error(ERR_EVENT_SCOPE_SOURCE_NOT_HANDLED, source) action_detail_event_scopes = action_scopes.get(source, {}) action_detail_types = action_events.get(source, {}) for detail_scopes_type in scopes[source]: if detail_scopes_type not in action_detail_event_scopes or detail_scopes_type not in action_detail_types: raise_value_error(ERR_EVENT_SCOPE_DETAIL_TYPE_NOT_HANDLED, detail_scopes_type, source) action_scope_events = action_detail_event_scopes.get(detail_scopes_type, []) action_supported_events = action_detail_types.get(detail_scopes_type, []) for event in scopes[source][detail_scopes_type]: if event not in action_scope_events or event not in action_supported_events: raise_value_error(ERR_EVENT_SCOPE_EVENT_NOT_HANDLED, event, source, detail_scopes_type) if action_scope_events[event] not in VALID_EVENT_SCOPES: raise_value_error(ERR_INVALID_EVENT_SCOPE, action_scope_events[event], ",".join(VALID_EVENT_SCOPES)) # only use values other than default resource value scopes_for_detail_type = {s: scopes[source][detail_scopes_type][s] for s in scopes[source][detail_scopes_type] if scopes[source][detail_scopes_type][s] != handlers.EVENT_SCOPE_RESOURCE} if len(scopes_for_detail_type) > 0: if source not in validated: validated[source] = {} validated[source][detail_scopes_type] = scopes_for_detail_type return validated def validate_regions(self, regions, action_name, ): action_properties = actions.get_action_properties(action_name) service_name = action_properties[actions.ACTION_SERVICE] is_multi_region_action = action_properties.get(actions.ACTION_MULTI_REGION, True) if self.service_is_regional(service_name): if regions is None or len(regions) == 0: return [services.get_session().region_name] else: available_regions = self.service_regions(service_name) if len(regions) == 1 and list(regions)[0] == "*": return available_regions if is_multi_region_action else [services.get_session().region_name] for region in regions: if region not in available_regions: raise_value_error(ERR_BAD_REGION, region, service_name, ",".join(available_regions)) return list(regions) else: if regions is not None and len(regions) != 0: if self._logger is not None: self._logger.warning(WARN_NOT_REGIONAL_SERVICE, ",".join(regions), service_name, action_name) return [] def verified_timezone(self, tz_name): tz_lower = str(tz_name).lower() if tz_lower in _checked_timezones: return str(_checked_timezones[tz_lower]) if tz_lower in _invalid_timezones: return None validated = self._all_timezones.get(tz_lower, None) if validated is not None: # keep list off approved timezones to make next checks much faster _checked_timezones[tz_lower] = pytz.timezone(validated) return validated else: _invalid_timezones.add(tz_lower) raise_value_error(ERR_INVALID_TIMEZONE, tz_name) @staticmethod def as_boolean(val): if val is not None: if type(val) == bool: return val s = str(val.lower()) if s in configuration.BOOLEAN_TRUE_VALUES: return True if s in configuration.BOOLEAN_FALSE_VALUES: return False raise_value_error(ERR_INVALID_BOOL, str(val)) @staticmethod def verify_internal(internal, action_name): """ Tests if the tasks that are not internal do not use actions that are marked as internal. If an internal action is used for a task that is not internal an exception is raised. :param internal: Value of task internal attribute :param action_name: name of the action :return: Validated internal setting """ action_properties = actions.get_action_properties(action_name) action_is_internal = action_properties.get(actions.ACTION_INTERNAL, False) if not internal and action_is_internal: raise_value_error(ERR_ACTION_ONLY_INTERNAL, action_name) return internal def verify_accounts(self, this_account, accounts, action_name, task_name): results = [] action_properties = actions.get_action_properties(action_name) if not action_properties.get(actions.ACTION_CROSS_ACCOUNT, True): if len(accounts) > 0: raise_value_error(ERR_NO_CROSS_ACCOUNT_OPERATIONS, action_name) if this_account is None: raise_value_error(ERR_THIS_ACCOUNT_MUST_BE_TRUE, action_name, configuration.CONFIG_THIS_ACCOUNT) for account in set(accounts): if not TaskConfiguration.is_valid_account_number(account): raise_value_error(ERR_INVALID_ACCOUNT_NUMBER_FORMAT, account) if account in results or account == this_account: self._logger.warning(WARN_DOUBLE_ACCOUNTS, account, task_name) else: results.append(account) return results @staticmethod def verify_task_role_name(role_name, action_name): if role_name in ["", None]: return None role_name = role_name.strip() if re.match(VALID_ROLE_NAME_REGEX, role_name) is None: raise_value_error(ERR_INVALID_ROLE_NAME, role_name, action_name) return role_name def verify_interval(self, interval, item, action_name, task_name): action_properties = actions.get_action_properties(action_name) use_intervals = actions.ACTION_TRIGGER_INTERVAL[0] in action_properties.get(actions.ACTION_TRIGGERS, actions.ACTION_TRIGGER_BOTH) if not use_intervals and interval is not None: raise ValueError("Interval is not used for action {}".format(action_name)) if interval is not None: try: cron_elements = interval.split(" ") if len(cron_elements) != 5: if len(cron_elements) == 6 and cron_elements[5] in ["?", "*"]: self._logger.warning(WARN_IGNORED_YEAR, interval, cron_elements[5]) else: raise_exception(ERR_INVALID_TASK_INTERVAL, interval, task_name) expression = CronExpression(interval) expression.validate() # test if there are concurrency restrictions min_interval = action_properties.get(actions.ACTION_MIN_INTERVAL_MIN) # no maximum if min_interval is not None: # property may be a lambda function, call the function with parameters of task as lambda parameters if types.FunctionType == type(min_interval): parameters = item min_interval = min_interval(parameters) if min_interval is not None: min_interval = max(1, min_interval) e = CronExpression(" ".join(interval.split(" ")[0:2]) + " * * ?") last = None for i in e.within_next(timespan=datetime.timedelta(hours=25), start_dt=datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - datetime.timedelta( minutes=1)): if last is not None: between = i - last if between < datetime.timedelta(minutes=min_interval): raise_value_error(ERR_TASK_INTERVAL_TOO_SHORT.format(min_interval, interval, int(between.total_seconds() / 60))) last = i return interval except Exception as ex: raise_value_error(ERR_INVALID_CRON_EXPRESSION, interval, str(ex)) return interval @staticmethod def verify_timeout(action_name, timeout): completion_method = getattr(actions.get_action_class(action_name), handlers.COMPLETION_METHOD, None) if completion_method is None and timeout is not None: raise_value_error(ERR_TIMEOUT_NOT_ALLOWED, action_name) if completion_method is None: return None if timeout is None: action_properties = actions.get_action_properties(action_name) return action_properties.get(actions.ACTION_COMPLETION_TIMEOUT_MINUTES, actions.DEFAULT_COMPLETION_TIMEOUT_MINUTES_DEFAULT) try: result = int(str(timeout).partition(".")[0]) if result > 0: return result else: raise_value_error(ERR_TIMEOUT_MUST_BE_GREATER_0, result) except ValueError as ex: raise_value_error(ERR_INVALID_NUMERIC_TIMEOUT, timeout, ex) @staticmethod def validate_lambda_size(size): valid_sizes = {a.lower(): a for a in actions.ACTION_SIZE_ALL_WITH_ECS} if size.lower() not in valid_sizes: raise_value_error(ERR_INVALID_LAMBDA_SIZE, ", ".join(actions.ACTION_SIZE_ALL_WITH_ECS)) return valid_sizes[size.lower()] def get_parameters(self, itm): def get_param(value): if isinstance(value, str) or isinstance(value, str): m = re.match(SSM_PARAM_REGEX, value) if m is not None: name = m.groups()[0] resp = self.ssm_client.get_parameters_with_retries(Names=[name]) if len(resp.get("Parameters", [])) > 0: ssm_value = resp["Parameters"][0].get("Value", "") ssm_type = resp["Parameters"][0].get("Type", "") if ssm_type == "StringList": return ssm_value.split(",") return ssm_value else: self._logger.error(ERR_SSM_PARAM_NOT_FOUND, name) return value result_item = copy.deepcopy(itm) for i in result_item: if isinstance(result_item[i], dict): result_item[i] = self.get_parameters(result_item[i]) continue if isinstance(result_item[i], list): temp = [] for l in result_item[i]: v = get_param(l) if isinstance(v, list): temp += v else: temp.append(v) result_item[i] = temp continue if isinstance(result_item[i], str) or isinstance(result_item[i], str): result_item[i] = get_param(result_item[i]) return result_item def configuration_item_to_task(self, item): """ Processes a configuration item into an internally used task specification. The method verifies the attributes from the configuration and sets defaults for missing items. :param item: Task configuration item :return: Task item """ action_name = self.validate_action(item.get(configuration.CONFIG_ACTION_NAME)) conf_item = self.get_parameters(item) process_this_account = TaskConfiguration.as_boolean(conf_item.get(configuration.CONFIG_THIS_ACCOUNT, True)) account = self.this_account if process_this_account else None task_name = conf_item[configuration.CONFIG_TASK_NAME] try: result = { handlers.TASK_NAME: task_name, handlers.TASK_ACTION: action_name, handlers.TASK_REGIONS: self.validate_regions(regions=conf_item.get(configuration.CONFIG_REGIONS, []), action_name=action_name), handlers.TASK_THIS_ACCOUNT: process_this_account, handlers.TASK_INTERVAL: self.verify_interval( interval=conf_item.get(configuration.CONFIG_INTERVAL, None), item=item, action_name=action_name, task_name=task_name), handlers.TASK_EVENTS: TaskConfiguration.validate_events( conf_item.get(configuration.CONFIG_EVENTS, {}), action_name), handlers.TASK_EVENT_SCOPES: TaskConfiguration.validate_event_scopes( conf_item.get(configuration.CONFIG_EVENT_SCOPES, {}), action_name), handlers.TASK_TIMEZONE: self.verified_timezone( tz_name=conf_item.get(configuration.CONFIG_TIMEZONE, DEFAULT_TIMEZONE)), handlers.TASK_SELECT_SIZE: TaskConfiguration.validate_lambda_size( conf_item.get(configuration.CONFIG_TASK_SELECT_SIZE, actions.ACTION_SIZE_STANDARD)), handlers.TASK_SELECT_ECS_MEMORY: conf_item.get(configuration.CONFIG_ECS_SELECT_MEMORY, None), handlers.TASK_EXECUTE_SIZE: TaskConfiguration.validate_lambda_size( conf_item.get(configuration.CONFIG_TASK_EXECUTE_SIZE, actions.ACTION_SIZE_STANDARD)), handlers.TASK_EXECUTE_ECS_MEMORY: conf_item.get( configuration.CONFIG_ECS_EXECUTE_MEMORY, None), handlers.TASK_COMPLETION_SIZE: TaskConfiguration.validate_lambda_size( conf_item.get(configuration.CONFIG_TASK_COMPLETION_SIZE, actions.ACTION_SIZE_STANDARD)), handlers.TASK_COMPLETION_ECS_MEMORY: conf_item.get( configuration.CONFIG_ECS_SELECT_MEMORY, None), handlers.TASK_TIMEOUT: self.verify_timeout( action_name=action_name, timeout=conf_item.get(configuration.CONFIG_TASK_TIMEOUT)), handlers.TASK_TAG_FILTER: TaskConfiguration.validate_tagfilter( tag_filter=conf_item.get(configuration.CONFIG_TAG_FILTER), action_name=action_name), handlers.TASK_EVENT_SOURCE_TAG_FILTER: TaskConfiguration.validate_tagfilter( tag_filter=conf_item.get(configuration.CONFIG_EVENT_SOURCE_TAG_FILTER), action_name=action_name), handlers.TASK_DRYRUN: TaskConfiguration.as_boolean( val=conf_item.get(configuration.CONFIG_DRYRUN, False)), handlers.TASK_DEBUG: TaskConfiguration.as_boolean( val=conf_item.get(configuration.CONFIG_DEBUG, False)), handlers.TASK_NOTIFICATIONS: TaskConfiguration.as_boolean( val=conf_item.get(configuration.CONFIG_TASK_NOTIFICATIONS, False)), handlers.TASK_ENABLED: TaskConfiguration.as_boolean( val=conf_item.get(configuration.CONFIG_ENABLED, True)), handlers.TASK_INTERNAL: TaskConfiguration.as_boolean( val=conf_item.get(configuration.CONFIG_INTERNAL, False)), handlers.TASK_METRICS: TaskConfiguration.as_boolean( val=conf_item.get(configuration.CONFIG_TASK_METRICS, False)), handlers.TASK_ACCOUNTS: self.verify_accounts( account, conf_item.get(configuration.CONFIG_ACCOUNTS, []), action_name, task_name), handlers.TASK_ROLE: self.verify_task_role_name( role_name=conf_item.get(configuration.CONFIG_TASK_CROSS_ACCOUNT_ROLE_NAME, ""), action_name=action_name), handlers.TASK_DESCRIPTION: conf_item.get(configuration.CONFIG_DESCRIPTION), handlers.TASK_PARAMETERS: self.verify_task_parameters( task_parameters=conf_item.get(configuration.CONFIG_PARAMETERS, {}), task_settings=conf_item, action_name=action_name), handlers.TASK_SERVICE: actions.get_action_properties(action_name).get(actions.ACTION_SERVICE, ""), handlers.TASK_RESOURCE_TYPE: actions.get_action_properties(action_name).get(actions.ACTION_RESOURCES, "") } return result except ValueError as ex: raise_value_error(ERR_ERR_IN_CONFIG_ITEM, safe_json(conf_item, indent=3), str(ex)) def _verify_configuration_item(self, **task_attributes): """ Verifies the parameters for creating or updating a task configuration item :param task_attributes: The configuration parameters Constants for dictionary keys can be found in configuration/__init__.py :return: Verified configuration item """ result = {} valid_attributes = VALID_TASK_ATTRIBUTES def remove_empty_attributes(o): def clean_dict(d): result_dict = {} for k, v in d.items(): vv = remove_empty_attributes(v) if vv is not None: result_dict[k] = vv return result_dict if len(result_dict) > 0 else None def clean_val(l): result_list = [] for i in l: ii = remove_empty_attributes(i) if ii is not None: result_list.append(ii) return result_list if len(result_list) > 0 else None if isinstance(o, dict): return clean_dict(o) elif isinstance(o, type([])): return clean_val(o) else: return o if o is not None and len(str(o)) > 0 else None attributes = remove_empty_attributes(task_attributes) # test for missing required parameters for attr in [configuration.CONFIG_TASK_NAME, configuration.CONFIG_ACTION_NAME]: if attr not in attributes or len(attributes[attr]) == 0: raise_value_error(ERR_MISSING_REQUIRED_PARAMETER, attr) # test for unknown parameters for attr in attributes: if attr not in valid_attributes: raise_value_error(ERR_UNKNOWN_PARAMETER, attr, ",".join(valid_attributes)) result[configuration.CONFIG_TASK_NAME] = attributes[configuration.CONFIG_TASK_NAME] action_name = self.validate_action(attributes[configuration.CONFIG_ACTION_NAME]) result[configuration.CONFIG_ACTION_NAME] = action_name process_this_account = TaskConfiguration.as_boolean(attributes.get(configuration.CONFIG_THIS_ACCOUNT, True)) result[configuration.CONFIG_THIS_ACCOUNT] = process_this_account account = self.this_account if process_this_account else None for attr in attributes: if attr in [configuration.CONFIG_TASK_NAME, configuration.CONFIG_ACTION_NAME, configuration.CONFIG_PARAMETERS]: continue try: # verify cross-account roles if attr == configuration.CONFIG_ACCOUNTS: if len(attributes[attr]) > 0: result[attr] = self.verify_accounts(account, attributes[attr], action_name, attributes[configuration.CONFIG_TASK_NAME]) continue if attr == configuration.CONFIG_TASK_CROSS_ACCOUNT_ROLE_NAME: result[attr] = TaskConfiguration.verify_task_role_name(attributes[attr], action_name) # verify boolean enabled, dryrun and debug parameters if attr in [ configuration.CONFIG_ENABLED, configuration.CONFIG_DRYRUN, configuration.CONFIG_DEBUG, configuration.CONFIG_TASK_NOTIFICATIONS, configuration.CONFIG_TASK_METRICS ]: result[attr] = TaskConfiguration.as_boolean(attributes[attr]) continue # verify interval (cron) expression if attr == configuration.CONFIG_INTERVAL: result[attr] = self.verify_interval(attributes[attr], attributes, action_name=action_name, task_name=attributes[configuration.CONFIG_TASK_NAME]) continue # verify timeout for task if attr == configuration.CONFIG_TASK_TIMEOUT: timeout = TaskConfiguration.verify_timeout(action_name, attributes[attr]) if timeout is not None: result[attr] = timeout continue # memory settings for task if attr in [ configuration.CONFIG_TASK_SELECT_SIZE, configuration.CONFIG_TASK_EXECUTE_SIZE, configuration.CONFIG_TASK_COMPLETION_SIZE ]: result[attr] = TaskConfiguration.validate_lambda_size(attributes[attr]) continue # Ecs memory settings for task if attr in [ configuration.CONFIG_ECS_SELECT_MEMORY, configuration.CONFIG_ECS_EXECUTE_MEMORY, configuration.CONFIG_ECS_COMPLETION_MEMORY ]: try: if attributes[attr] is not None: result[attr] = int(attributes[attr]) except ValueError: raise_exception(ERR_INVALID_ECS_MEMORY_SIZE, attributes[attr], attr) continue # verify timezone if attr == configuration.CONFIG_TIMEZONE: result[attr] = self.verified_timezone(attributes[attr]) or DEFAULT_TIMEZONE continue # verify tag filter if attr == configuration.CONFIG_TAG_FILTER: tag_filter = TaskConfiguration.validate_tagfilter(attributes[attr], action_name) if tag_filter is not None: result[attr] = tag_filter continue # verify events if attr == configuration.CONFIG_EVENTS: result[attr] = TaskConfiguration.validate_events(attributes[attr], action_name) continue # verify event scopes if attr == configuration.CONFIG_EVENT_SCOPES: result[attr] = TaskConfiguration.validate_event_scopes(attributes[attr], action_name) continue # verify regions if attr == configuration.CONFIG_REGIONS: result[attr] = "*" if attributes[attr] in ["*", ["*"]] else self.validate_regions(attributes[attr], action_name) # verify internal if attr == configuration.CONFIG_INTERNAL: result[attr] = TaskConfiguration.verify_internal(attributes[attr], action_name) continue # copy description and stack if attr in [configuration.CONFIG_DESCRIPTION, configuration.CONFIG_STACK_ID]: result[attr] = attributes[attr] except ValueError as ex: raise ValueError("Parameter : {}, ({})".format(attr, str(ex))) # default for enabled parameter if configuration.CONFIG_ENABLED not in result: result[configuration.CONFIG_ENABLED] = True # default for metrics parameter if configuration.CONFIG_TASK_METRICS not in result: result[configuration.CONFIG_TASK_METRICS] = False # default for region if configuration.CONFIG_REGIONS not in result: regions = self.validate_regions(None, action_name) if len(regions) > 0: result[configuration.CONFIG_REGIONS] = self.validate_regions(None, action_name) # set internal flag if the task action is internal if configuration.CONFIG_INTERNAL not in result: if actions.get_action_properties(action_name).get(actions.ACTION_INTERNAL, False): result[configuration.CONFIG_INTERNAL] = True result[configuration.CONFIG_PARAMETERS] = self.verify_task_parameters( attributes.get(configuration.CONFIG_PARAMETERS, {}), task_settings=result, action_name=action_name) return result def get_external_task_configuration_stacks(self): """ Returns list of external stacks that have task configuration items in the configuration table :return: list of task configuration stacks """ stacks = [] this_stack = os.getenv(handlers.ENV_STACK_ID) for item in self.config_items(include_internal=True): stack = item.get(configuration.CONFIG_STACK_ID) if stack is not None and stack != this_stack: stacks.append(stack) return stacks def get_tasks(self, include_internal=True): """ Gets a list of all configured tasks, processed to be used internally by the scheduler :param: include_internal: include internal tasks :return: """ for config_item in self.config_items(include_internal=include_internal): try: yield self.configuration_item_to_task(config_item) except Exception as ex: if self._logger is not None: self._logger.error(ERR_FETCHING_TASKS_FROM_CONFIG, ex) def get_task(self, name): """ Gets a configured task by name :param: name of the task :return: The task, or None of the task does not exist """ item = self.get_config_item(name=name) if item is not None: return self.configuration_item_to_task(item) return None def _regions_for_tasks_with_events(self, task_name=None): regions = set() if task_name is None: tasks = self.get_tasks(include_internal=False) else: task = self.get_task(task_name) tasks = [task] if task is not None else [] for task in tasks: task_events = task.get(handlers.TASK_EVENTS, {}) if len(task_events) == 0: continue regions.update(task.get(handlers.TASK_REGIONS, [])) return regions @staticmethod def _event_bus_permissions_sid_prefix(): return "ops-automator-{}-{}-".format(os.getenv(handlers.ENV_STACK_NAME).lower(), services.get_session().region_name) def _update_ops_automator_topic_permissions(self): def get_accounts_with_events(): accounts_with_events = set() for task in self.get_tasks(include_internal=False): task_events = task.get(handlers.TASK_EVENTS, {}) if len(task_events) == 0: continue task_accounts = task.get(handlers.TASK_ACCOUNTS, []) accounts_with_events.update(task_accounts) return accounts_with_events external_account_with_events = get_accounts_with_events() methods = ["add_permission", "remove_permission", "get_topic_attributes"] sns_client = boto_retry.get_client_with_retries("sns", methods=methods, context=self._context) topic_arn = os.getenv(handlers.ENV_EVENTS_TOPIC_ARN) # get policy document for topics statement = json.loads(sns_client.get_topic_attributes_with_retries( TopicArn=topic_arn).get("Attributes", {}).get("Policy", "{}")).get("Statement", []) # get all sid for all accounts that have permission where the sid starts with prefix used for this stack accounts_with_topic_permissions = {s["Principal"]["AWS"].split(":")[-2]: s["Sid"] for s in statement if len(s.get("Principal", {}).get("AWS", "").split(":")) == 6 and s["Sid"].startswith(TaskConfiguration._event_bus_permissions_sid_prefix())} # add permission for other accounts that have tasks don't have permission yet for account in external_account_with_events: if account not in accounts_with_topic_permissions: label = self._event_bus_permissions_sid_prefix() + account if self._logger is not None: self._logger.info(INF_ADD_ACCOUNT_TOPIC_PERMISSION, account, label) sns_client.add_permission_with_retries(TopicArn=topic_arn, AWSAccountId=[account], ActionName=["Publish"], Label=label) # remove permissions for accounts that don't have tasks that use events for account in accounts_with_topic_permissions: if account not in external_account_with_events: if self._logger is not None: self._logger.info(INF_REMOVE_TOPIC_PERMISSION, account, accounts_with_topic_permissions[account]) sns_client.remove_permission_with_retries(TopicArn=topic_arn, Label=accounts_with_topic_permissions[account]) def remove_stack_event_topic_permissions(self): """ Removes all permissions for accounts for putting events on the event bus of the Ops Automator account. Only permissions created by this stack are removed. :return: """ topic_arn = os.getenv(handlers.ENV_EVENTS_TOPIC_ARN) sns_client = boto_retry.get_client_with_retries("sns", methods=["remove_permission", "get_topic_attributes"], context=self._context) statement = json.loads(sns_client.get_topic_attributes_with_retries( TopicArn=topic_arn).get("Attributes", {}).get("Policy", "{}")).get("Statement", []) permission_sids_for_stack = [s["Sid"] for s in statement if s["Sid"].startswith(TaskConfiguration._event_bus_permissions_sid_prefix())] for label in permission_sids_for_stack: sns_client.remove_permission_with_retries(Label=label, TopicArn=topic_arn)