示例#1
0
    def run(self):
        src_dir, s3_bucket = self.get_arguments()

        content = Zipper().zip_dir(src_dir)

        md5 = hashlib.new('md5')
        md5.update(content)

        s3_key = get_s3_name(md5)

        self.logger.debug('{} resolved to s3://{}/{}'.format(
            src_dir, s3_bucket, s3_key))

        # The docs say this is available on the Stack instance, but it
        # is not always initialized.
        connection_manager = ConnectionManager(self.stack.region,
                                               self.stack.profile,
                                               self.stack.external_name)

        try:
            connection_manager.call(
                service='s3',
                command='head_object',
                kwargs={
                    'Bucket': s3_bucket,
                    'Key': s3_key
                },
            )

            self.logger.debug('s3://{}/{} already up to date'.format(
                s3_bucket, s3_key))
        except ClientError as e:
            if e.response['Error']['Code'] not in ['404', '412']:
                raise e

            self.logger.debug('putting {} to s3://{}/{}'.format(
                src_dir, s3_bucket, s3_key))

            connection_manager.call(
                service='s3',
                command='put_object',
                kwargs={
                    'Bucket': s3_bucket,
                    'Key': s3_key,
                    'Body': content
                },
            )

            self.logger.debug('s3://{}/{} put for {}'.format(
                s3_bucket, s3_key, src_dir))
示例#2
0
    def test_call_with_valid_service_and_stack_name_call(self):
        service = 's3'
        command = 'list_buckets'

        connection_manager = ConnectionManager(region=self.region,
                                               stack_name='stack')

        return_value = connection_manager.call(service,
                                               command, {},
                                               stack_name='stack')
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200
示例#3
0
class StackActions(object):
    """
    StackActions stores the operations a Stack can take, such as creating or
    deleting the Stack.

    :param stack: A Stack object
    :type stack: sceptre.stack.Stack
    """
    def __init__(self, stack):
        self.stack = stack
        self.name = self.stack.name
        self.logger = logging.getLogger(__name__)
        self.connection_manager = ConnectionManager(self.stack.region,
                                                    self.stack.profile,
                                                    self.stack.external_name)

    @add_stack_hooks
    def create(self, wait_action):
        """
        Creates a Stack.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()
        self.logger.info("%s - Creating Stack", self.stack.name)
        create_stack_kwargs = {
            "StackName":
            self.stack.external_name,
            "Parameters":
            self._format_parameters(self.stack.parameters),
            "Capabilities": [
                'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM',
                'CAPABILITY_AUTO_EXPAND'
            ],
            "NotificationARNs":
            self.stack.notifications,
            "Tags": [{
                "Key": str(k),
                "Value": str(v)
            } for k, v in self.stack.tags.items()]
        }

        if self.stack.on_failure:
            create_stack_kwargs.update({"OnFailure": self.stack.on_failure})
        create_stack_kwargs.update(
            self.stack.template.get_boto_call_parameter())
        create_stack_kwargs.update(self._get_role_arn())
        create_stack_kwargs.update(self._get_stack_timeout())

        try:
            if not wait_action == 'wait_only':
                response = self.connection_manager.call(
                    service="cloudformation",
                    command="create_stack",
                    kwargs=create_stack_kwargs)

                self.logger.debug("%s - Create stack response: %s",
                                  self.stack.name, response)

            if wait_action == 'yes' or wait_action == 'wait_only':
                status = self._wait_for_completion()
            else:
                status = StackStatus.IN_PROGRESS
                self.most_recent_event_datetime = (datetime.now(tzutc()) -
                                                   timedelta(seconds=3))
                elapsed = 0
                status = self._get_simplified_status(self._get_status())
                # Force exit code of 0
                if status == StackStatus.IN_PROGRESS:
                    return StackStatus.COMPLETE

                return status
        except botocore.exceptions.ClientError as exp:
            if exp.response["Error"]["Code"] == "AlreadyExistsException":
                self.logger.info("%s - Stack already exists", self.stack.name)

                status = "COMPLETE"
            else:
                raise

        return status

    @add_stack_hooks
    def update(self, wait_action):
        """
        Updates the Stack.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()
        self.logger.info("%s - Updating Stack", self.stack.name)
        try:
            if not wait_action == 'wait_only':
                self.logger.info("%s - Updating Stack", self.stack.name)
                update_stack_kwargs = {
                    "StackName":
                    self.stack.external_name,
                    "Parameters":
                    self._format_parameters(self.stack.parameters),
                    "Capabilities": [
                        'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM',
                        'CAPABILITY_AUTO_EXPAND'
                    ],
                    "NotificationARNs":
                    self.stack.notifications,
                    "Tags": [{
                        "Key": str(k),
                        "Value": str(v)
                    } for k, v in self.stack.tags.items()]
                }
                update_stack_kwargs.update(
                    self.stack.template.get_boto_call_parameter())
                update_stack_kwargs.update(self._get_role_arn())
                response = self.connection_manager.call(
                    service="cloudformation",
                    command="update_stack",
                    kwargs=update_stack_kwargs)
                # status = self._wait_for_completion(self.stack.stack_timeout)
                self.logger.debug("%s - Update Stack response: %s",
                                  self.stack.name, response)

            if wait_action == 'yes' or wait_action == 'wait_only':
                status = self._wait_for_completion(self.stack.stack_timeout)
                # Cancel update after timeout
                if status == StackStatus.IN_PROGRESS:
                    status = self.cancel_stack_update()

                return status
            else:
                status = StackStatus.IN_PROGRESS
                self.most_recent_event_datetime = (datetime.now(tzutc()) -
                                                   timedelta(seconds=3))
                elapsed = 0
                status = self._get_simplified_status(self._get_status())
                # Force exit code of 0
                if status == StackStatus.IN_PROGRESS:
                    return StackStatus.COMPLETE
                # Cancel update after timeout well not really...
                #if status == StackStatus.IN_PROGRESS:
                #    status = self.cancel_stack_update()

                return status
        except botocore.exceptions.ClientError as exp:
            error_message = exp.response["Error"]["Message"]
            if error_message == "No updates are to be performed.":
                self.logger.info("%s - No updates to perform.",
                                 self.stack.name)
                return StackStatus.COMPLETE
            else:
                raise

    def cancel_stack_update(self):
        """
        Cancels a Stack update.

        :returns: The cancelled Stack status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self.logger.warning(
            "%s - Update Stack time exceeded the specified timeout",
            self.stack.name)
        response = self.connection_manager.call(
            service="cloudformation",
            command="cancel_update_stack",
            kwargs={"StackName": self.stack.external_name})
        self.logger.debug("%s - Cancel update Stack response: %s",
                          self.stack.name, response)
        return self._wait_for_completion()

    def launch(self, wait_action):
        """
        Launches the Stack.

        If the Stack status is create_failed or rollback_complete, the
        Stack is deleted. Launch then tries to create or update the Stack,
        depending if it already exists. If there are no updates to be
        performed, launch exits gracefully.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()
        self.logger.info("%s - Launching Stack", self.stack.name)
        try:
            existing_status = self._get_status()
        except StackDoesNotExistError:
            existing_status = "PENDING"

        self.logger.info("%s - Stack is in the %s state", self.stack.name,
                         existing_status)

        if existing_status.endswith(
                "UPDATE_IN_PROGRESS") or existing_status.endswith(
                    "CREATE_IN_PROGRESS") or existing_status.endswith(
                        "UPDATE_ROLLBACK_IN_PROGRESS"
                    ) or existing_status.endswith(
                        "CLEANUP_IN_PROGRESS") or existing_status.endswith(
                            "DELETE_IN_PROGRESS") or existing_status.endswith(
                                "ROLLBACK_IN_PROGRESS"):
            # wait until it finalize then lets proceed..
            self.logger.info(
                "%s - Stack is %s, waiting before launching stack action.",
                self.stack.name, existing_status)
            existing_status = self._wait_for_completion()

            try:
                existing_status = self._get_status()
                time.sleep(4)
                existing_status = self._get_status()
            except StackDoesNotExistError:
                existing_status = "PENDING"

            self.logger.info(
                "%s - Stack is now in the following state: %s. Will proceed with command action.",
                self.stack.name, existing_status)

            if existing_status.endswith("ROLLBACK_COMPLETE"):
                existing_status = "PENDING"
            elif existing_status.endswith("DELETE_IN_PROGRESS"):
                # Force dlete / create
                existing_status = "PENDING"
            elif existing_status.endswith("CREATE_IN_PROGRESS"):
                existing_status = "CREATE_IN_PROGRESS"
            elif existing_status.endswith("UPDATE_IN_PROGRESS"):
                existing_status = "UPDATE_IN_PROGRESS"
            else:
                existing_status = "UPDATE_ROLLBACK_COMPLETE"

        if existing_status == "PENDING":
            status = self.create(wait_action)
        elif existing_status in [
                "CREATE_FAILED", "ROLLBACK_COMPLETE", "ROLLBACK_FAILED"
        ]:
            self.delete()
            status = self.create(wait_action)
        elif existing_status.endswith("COMPLETE") or (
                existing_status.endswith("IN_PROGRESS")
                and wait_action == 'wait_only'):
            try:
                status = self.update(wait_action)
            except botocore.exceptions.ClientError as exp:
                error_message = exp.response["Error"]["Message"]
                if error_message == "No updates are to be performed.":
                    self.logger.info("%s - No updates to perform.",
                                     self.stack.name)
                    status = StackStatus.COMPLETE
                else:
                    raise
        #    status = self.create()
        #elif existing_status.endswith("COMPLETE"):
        #    status = self.update()
        elif existing_status.endswith("IN_PROGRESS"):
            self.logger.info(
                "%s - Stack action is already in progress state and cannot "
                "be updated", self.stack.name)
            status = StackStatus.IN_PROGRESS
        elif existing_status.endswith("FAILED"):
            status = StackStatus.FAILED
            raise CannotUpdateFailedStackError(
                "'{0}' is in a the state '{1}' and cannot be updated".format(
                    self.stack.name, existing_status))
        else:
            raise UnknownStackStatusError(
                "{0} is unknown".format(existing_status))
        return status

    @add_stack_hooks
    def delete(self):
        """
        Deletes the Stack.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()

        self.logger.info("%s - Deleting stack", self.stack.name)
        try:
            status = self._get_status()
        except StackDoesNotExistError:
            self.logger.info("%s - Does not exist.", self.stack.name)
            status = StackStatus.COMPLETE
            return status

        delete_stack_kwargs = {"StackName": self.stack.external_name}
        delete_stack_kwargs.update(self._get_role_arn())
        self.connection_manager.call(service="cloudformation",
                                     command="delete_stack",
                                     kwargs=delete_stack_kwargs)

        try:
            status = self._wait_for_completion()
        except StackDoesNotExistError:
            status = StackStatus.COMPLETE
        except botocore.exceptions.ClientError as error:
            if error.response["Error"]["Message"].endswith("does not exist"):
                status = StackStatus.COMPLETE
            else:
                raise
        self.logger.info("%s - delete %s", self.stack.name, status)
        return status

    def lock(self):
        """
        Locks the Stack by applying a deny-all updates Stack Policy.
        """
        policy_path = path.join(
            # need to get to the base install path. __file__ will take us into
            # sceptre/actions so need to walk up the path.
            path.abspath(path.join(__file__, "..", "..")),
            "stack_policies/lock.json")
        self.set_policy(policy_path)
        self.logger.info("%s - Successfully locked Stack", self.stack.name)

    def unlock(self):
        """
        Unlocks the Stack by applying an allow-all updates Stack Policy.
        """
        policy_path = path.join(
            # need to get to the base install path. __file__ will take us into
            # sceptre/actions so need to walk up the path.
            path.abspath(path.join(__file__, "..", "..")),
            "stack_policies/unlock.json")
        self.set_policy(policy_path)
        self.logger.info("%s - Successfully unlocked Stack", self.stack.name)

    def describe(self):
        """
        Returns the a description of the Stack.

        :returns: A Stack description.
        :rtype: dict
        """
        try:
            return self.connection_manager.call(
                service="cloudformation",
                command="describe_stacks",
                kwargs={"StackName": self.stack.external_name})
        except botocore.exceptions.ClientError as e:
            if e.response["Error"]["Message"].endswith("does not exist"):
                return
            raise

    def describe_events(self):
        """
        Returns the CloudFormation events for a Stack.

        :returns: CloudFormation events for a Stack.
        :rtype: dict
        """
        return self.connection_manager.call(
            service="cloudformation",
            command="describe_stack_events",
            kwargs={"StackName": self.stack.external_name})

    def describe_resources(self):
        """
        Returns the logical and physical resource IDs of the Stack's resources.

        :returns: Information about the Stack's resources.
        :rtype: dict
        """
        self.logger.debug("%s - Describing stack resources", self.stack.name)
        try:
            response = self.connection_manager.call(
                service="cloudformation",
                command="describe_stack_resources",
                kwargs={"StackName": self.stack.external_name})
        except botocore.exceptions.ClientError as e:
            if e.response["Error"]["Message"].endswith("does not exist"):
                return {self.stack.name: []}
            raise

        self.logger.debug("%s - Describe Stack resource response: %s",
                          self.stack.name, response)

        desired_properties = ["LogicalResourceId", "PhysicalResourceId"]

        formatted_response = {
            self.stack.name:
            [{k: v
              for k, v in item.items() if k in desired_properties}
             for item in response["StackResources"]]
        }
        return formatted_response

    def describe_outputs(self):
        """
        Returns the Stack's outputs.

        :returns: The Stack's outputs.
        :rtype: list
        """
        self.logger.debug("%s - Describing stack outputs", self.stack.name)

        try:
            response = self._describe()
        except botocore.exceptions.ClientError:
            return []

        return {self.stack.name: response["Stacks"][0].get("Outputs", [])}

    def continue_update_rollback(self):
        """
        Rolls back a Stack in the UPDATE_ROLLBACK_FAILED state to
        UPDATE_ROLLBACK_COMPLETE.
        """
        self.logger.debug("%s - Continuing update rollback", self.stack.name)
        continue_update_rollback_kwargs = {
            "StackName": self.stack.external_name
        }
        continue_update_rollback_kwargs.update(self._get_role_arn())
        self.connection_manager.call(service="cloudformation",
                                     command="continue_update_rollback",
                                     kwargs=continue_update_rollback_kwargs)
        self.logger.info(
            "%s - Successfully initiated continuation of update rollback",
            self.stack.name)

    def set_policy(self, policy_path):
        """
        Applies a Stack Policy.

        :param policy_path: The relative path of JSON file containing\
                the AWS Policy to apply.
        :type policy_path: str
        """
        with open(policy_path) as f:
            policy = f.read()

        self.logger.debug("%s - Setting Stack policy: \n%s", self.stack.name,
                          policy)

        self.connection_manager.call(service="cloudformation",
                                     command="set_stack_policy",
                                     kwargs={
                                         "StackName": self.stack.external_name,
                                         "StackPolicyBody": policy
                                     })
        self.logger.info("%s - Successfully set Stack Policy", self.stack.name)

    def get_policy(self):
        """
        Returns a Stack's Policy.

        :returns: The Stack's Stack Policy.
        :rtype: str
        """
        self.logger.debug("%s - Getting Stack Policy", self.stack.name)
        response = self.connection_manager.call(
            service="cloudformation",
            command="get_stack_policy",
            kwargs={"StackName": self.stack.external_name})
        json_formatting = json.loads(
            response.get("StackPolicyBody",
                         json.dumps("No Policy Information")))
        return {self.stack.name: json_formatting}

    def create_change_set(self, change_set_name):
        """
        Creates a Change Set with the name ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        """
        create_change_set_kwargs = {
            "StackName":
            self.stack.external_name,
            "Parameters":
            self._format_parameters(self.stack.parameters),
            "Capabilities": [
                'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM',
                'CAPABILITY_AUTO_EXPAND'
            ],
            "ChangeSetName":
            change_set_name,
            "NotificationARNs":
            self.stack.notifications,
            "Tags": [{
                "Key": str(k),
                "Value": str(v)
            } for k, v in self.stack.tags.items()]
        }
        create_change_set_kwargs.update(
            self.stack.template.get_boto_call_parameter())
        create_change_set_kwargs.update(self._get_role_arn())
        self.logger.debug("%s - Creating Change Set '%s'", self.stack.name,
                          change_set_name)
        self.connection_manager.call(service="cloudformation",
                                     command="create_change_set",
                                     kwargs=create_change_set_kwargs)
        # After the call successfully completes, AWS CloudFormation
        # starts creating the Change Set.
        self.logger.info(
            "%s - Successfully initiated creation of Change Set '%s'",
            self.stack.name, change_set_name)

    def delete_change_set(self, change_set_name):
        """
        Deletes the Change Set ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        """
        self.logger.debug("%s - Deleting Change Set '%s'", self.stack.name,
                          change_set_name)
        self.connection_manager.call(service="cloudformation",
                                     command="delete_change_set",
                                     kwargs={
                                         "ChangeSetName": change_set_name,
                                         "StackName": self.stack.external_name
                                     })
        # If the call successfully completes, AWS CloudFormation
        # successfully deleted the Change Set.
        self.logger.info("%s - Successfully deleted Change Set '%s'",
                         self.stack.name, change_set_name)

    def describe_change_set(self, change_set_name):
        """
        Describes the Change Set ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The description of the Change Set.
        :rtype: dict
        """
        self.logger.debug("%s - Describing Change Set '%s'", self.stack.name,
                          change_set_name)
        return self.connection_manager.call(service="cloudformation",
                                            command="describe_change_set",
                                            kwargs={
                                                "ChangeSetName":
                                                change_set_name,
                                                "StackName":
                                                self.stack.external_name
                                            })

    def execute_change_set(self, change_set_name):
        """
        Executes the Change Set ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The Stack status
        :rtype: str
        """
        self._protect_execution()
        self.logger.debug("%s - Executing Change Set '%s'", self.stack.name,
                          change_set_name)
        self.connection_manager.call(service="cloudformation",
                                     command="execute_change_set",
                                     kwargs={
                                         "ChangeSetName": change_set_name,
                                         "StackName": self.stack.external_name
                                     })

        status = self._wait_for_completion()
        return status

    def list_change_sets(self):
        """
        Lists the Stack's Change Sets.

        :returns: The Stack's Change Sets.
        :rtype: dict or list
        """
        self.logger.debug("%s - Listing change sets", self.stack.name)
        try:
            response = self.connection_manager.call(
                service="cloudformation",
                command="list_change_sets",
                kwargs={"StackName": self.stack.external_name})
            return {self.stack.name: response.get("Summaries", [])}
        except botocore.exceptions.ClientError:
            return []

    def generate(self):
        """
        Returns the Template for the Stack
        """
        return self.stack.template.body

    def validate(self):
        """
        Validates the Stack's CloudFormation Template.

        Raises an error if the Template is invalid.

        :returns: Validation information about the Template.
        :rtype: dict
        :raises: botocore.exceptions.ClientError
        """
        self.logger.debug("%s - Validating Template", self.stack.name)
        response = self.connection_manager.call(
            service="cloudformation",
            command="validate_template",
            kwargs=self.stack.template.get_boto_call_parameter())
        self.logger.debug("%s - Validate Template response: %s",
                          self.stack.name, response)
        return response

    def estimate_cost(self):
        """
        Estimates a Stack's cost.

        :returns: An estimate of the Stack's cost.
        :rtype: dict
        :raises: botocore.exceptions.ClientError
        """
        self.logger.debug("%s - Estimating template cost", self.stack.name)

        parameters = [{
            'ParameterKey': key,
            'ParameterValue': value
        } for key, value in self.stack.parameters.items()]

        kwargs = self.stack.template.get_boto_call_parameter()
        kwargs.update({'Parameters': parameters})

        response = self.connection_manager.call(
            service="cloudformation",
            command="estimate_template_cost",
            kwargs=kwargs)
        self.logger.debug("%s - Estimate Stack cost response: %s",
                          self.stack.name, response)
        return response

    def get_status(self):
        """
        Returns the Stack's status.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        try:
            return self._get_status()
        except StackDoesNotExistError:
            return "PENDING"

    def _format_parameters(self, parameters):
        """
        Converts CloudFormation parameters to the format used by Boto3.

        :param parameters: A dictionary of parameters.
        :type parameters: dict
        :returns: A list of the formatted parameters.
        :rtype: list
        """
        formatted_parameters = []
        for name, value in parameters.items():
            if value is None:
                continue
            if isinstance(value, list):
                value = ",".join(value)
            formatted_parameters.append({
                "ParameterKey": name,
                "ParameterValue": value
            })

        return formatted_parameters

    def _get_role_arn(self):
        """
        Returns the Role ARN assumed by CloudFormation when building a Stack.

        Returns an empty dict if no Role is to be assumed.

        :returns: The a Role ARN
        :rtype: dict
        """
        if self.stack.role_arn:
            return {"RoleARN": self.stack.role_arn}
        else:
            return {}

    def _get_stack_timeout(self):
        """
        Return the timeout before considering the Stack to be failing.

        Returns an empty dict if no timeout is set.
        :returns: the creation/update timeout
        :rtype: dict
        """
        if self.stack.stack_timeout:
            return {"TimeoutInMinutes": self.stack.stack_timeout}
        else:
            return {}

    def _protect_execution(self):
        """
        Raises a ProtectedStackError if protect == True.

        :raises: sceptre.exceptions.ProtectedStackError
        """
        if self.stack.protected:
            raise ProtectedStackError(
                "Cannot perform action on '{0}': Stack protection is "
                "currently enabled".format(self.stack.name))

    def _wait_for_completion(self, timeout=0):
        """
        Waits for a Stack operation to finish. Prints CloudFormation events
        while it waits.

        :param timeout: Timeout before returning, in minutes.

        :returns: The final Stack status.
        :rtype: sceptre.stack_status.StackStatus
        """
        timeout = 60 * timeout

        def timed_out(elapsed):
            return elapsed >= timeout if timeout else False

        status = StackStatus.IN_PROGRESS

        self.most_recent_event_datetime = (datetime.now(tzutc()) -
                                           timedelta(seconds=3))
        elapsed = 0
        while status == StackStatus.IN_PROGRESS and not timed_out(elapsed):
            status = self._get_simplified_status(self._get_status())
            self._log_new_events()
            time.sleep(4)
            elapsed += 4

        return status

    def _describe(self):
        return self.connection_manager.call(
            service="cloudformation",
            command="describe_stacks",
            kwargs={"StackName": self.stack.external_name})

    def _get_status(self):
        try:
            status = self._describe()["Stacks"][0]["StackStatus"]
        except botocore.exceptions.ClientError as exp:
            if exp.response["Error"]["Message"].endswith("does not exist"):
                raise StackDoesNotExistError(exp.response["Error"]["Message"])
            else:
                raise exp
        return status

    @staticmethod
    def _get_simplified_status(status):
        """
        Returns the simplified Stack Status.

        The simplified Stack status is represented by the struct
        ``sceptre.StackStatus()`` and can take one of the following options:

        * complete
        * in_progress
        * failed

        :param status: The CloudFormation Stack status to simplify.
        :type status: str
        :returns: The Stack's simplified status
        :rtype: sceptre.stack_status.StackStatus
        """
        if status.endswith("ROLLBACK_COMPLETE"):
            return StackStatus.FAILED
        elif status.endswith("_COMPLETE"):
            return StackStatus.COMPLETE
        elif status.endswith("_IN_PROGRESS"):
            return StackStatus.IN_PROGRESS
        elif status.endswith("_FAILED"):
            return StackStatus.FAILED
        else:
            raise UnknownStackStatusError("{0} is unknown".format(status))

    def _log_new_events(self):
        """
        Log the latest Stack events while the Stack is being built.
        """
        events = self.describe_events()["StackEvents"]
        events.reverse()
        new_events = [
            event for event in events
            if event["Timestamp"] > self.most_recent_event_datetime
        ]
        for event in new_events:
            self.logger.info(" ".join([
                self.stack.name, event["LogicalResourceId"],
                event["ResourceType"], event["ResourceStatus"],
                event.get("ResourceStatusReason", "")
            ]))
            self.most_recent_event_datetime = event["Timestamp"]

    def wait_for_cs_completion(self, change_set_name):
        """
        Waits while the Stack Change Set status is "pending".

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The Change Set's status.
        :rtype: sceptre.stack_status.StackChangeSetStatus
        """
        while True:
            status = self._get_cs_status(change_set_name)
            if status != StackChangeSetStatus.PENDING:
                break
            time.sleep(2)

        return status

    def _get_cs_status(self, change_set_name):
        """
        Returns the status of a Change Set.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The Change Set's status.
        :rtype: sceptre.stack_status.StackChangeSetStatus
        """
        cs_description = self.describe_change_set(change_set_name)

        cs_status = cs_description["Status"]
        cs_exec_status = cs_description["ExecutionStatus"]
        possible_statuses = [
            "CREATE_PENDING", "CREATE_IN_PROGRESS", "CREATE_COMPLETE",
            "DELETE_COMPLETE", "FAILED"
        ]
        possible_execution_statuses = [
            "UNAVAILABLE", "AVAILABLE", "EXECUTE_IN_PROGRESS",
            "EXECUTE_COMPLETE", "EXECUTE_FAILED", "OBSOLETE"
        ]

        if cs_status not in possible_statuses:
            raise UnknownStackChangeSetStatusError(
                "Status {0} is unknown".format(cs_status))
        if cs_exec_status not in possible_execution_statuses:
            raise UnknownStackChangeSetStatusError(
                "ExecutionStatus {0} is unknown".format(cs_status))

        if (cs_status == "CREATE_COMPLETE" and cs_exec_status == "AVAILABLE"):
            return StackChangeSetStatus.READY
        elif (cs_status
              in ["CREATE_PENDING", "CREATE_IN_PROGRESS", "CREATE_COMPLETE"]
              and cs_exec_status in ["UNAVAILABLE", "AVAILABLE"]):
            return StackChangeSetStatus.PENDING
        elif (cs_status in ["DELETE_COMPLETE", "FAILED"] or cs_exec_status in [
                "EXECUTE_IN_PROGRESS", "EXECUTE_COMPLETE", "EXECUTE_FAILED",
                "OBSOLETE"
        ]):
            return StackChangeSetStatus.DEFUNCT
        else:  # pragma: no cover
            raise Exception("This else should not be reachable.")
class TestConnectionManager(object):
    def setup_method(self, test_method):
        self.iam_role = None
        self.region = "eu-west-1"

        self.connection_manager = ConnectionManager(region=self.region,
                                                    iam_role=self.iam_role)

    def test_connection_manager_initialised_with_all_parameters(self):
        connection_manager = ConnectionManager(region=self.region,
                                               iam_role=self.iam_role)
        assert connection_manager.iam_role == self.iam_role
        assert connection_manager.region == self.region
        assert connection_manager._boto_session is None
        assert connection_manager.clients == {}

    def test_connection_manager_initialised_with_no_optional_parameters(self):
        connection_manager = ConnectionManager(region=sentinel.region)

        assert connection_manager.iam_role is None
        assert connection_manager.region == sentinel.region
        assert connection_manager._boto_session is None
        assert connection_manager.clients == {}

    def test_repr(self):
        self.connection_manager.iam_role = "role"
        self.connection_manager.region = "region"
        response = self.connection_manager.__repr__()
        assert response == "sceptre.connection_manager.ConnectionManager(" \
            "region='region', iam_role='role')"

    def test_boto_session_with_cache(self):
        self.connection_manager._boto_session = sentinel.boto_session
        assert self.connection_manager.boto_session == sentinel.boto_session

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_iam_role_and_no_cache(self, mock_Session):
        mock_Session = MagicMock(name='Session', return_value=sentinel.session)
        mock_Session.get_credentials.access_key.return_value = \
            sentinel.access_key
        mock_Session.get_credentials.secret_key.return_value = \
            sentinel.secret_key
        mock_Session.get_credentials.method.return_value = \
            sentinel.method

        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = None

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session(region_name="eu-west-1"))
        mock_Session.assert_called_once_with(region_name="eu-west-1")

    @patch("sceptre.connection_manager.boto3.session.Session")
    @patch("sceptre.connection_manager.boto3.client")
    def test_boto_session_with_iam_role_and_no_cache(self, mock_client,
                                                     mock_Session):
        mock_Session.return_value = sentinel.session
        self.connection_manager.iam_role = "non-default"
        mock_credentials = {
            "Credentials": {
                "AccessKeyId": "id",
                "SecretAccessKey": "key",
                "SessionToken": "token"
            }
        }
        mock_sts_client = Mock()
        mock_sts_client.assume_role.return_value = mock_credentials
        mock_client.return_value = mock_sts_client

        boto_session = self.connection_manager.boto_session

        assert boto_session == sentinel.session
        mock_Session.assert_called_once_with(aws_access_key_id="id",
                                             aws_secret_access_key="key",
                                             aws_session_token="token",
                                             region_name=self.region)

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_two_boto_sessions(self, mock_Session):
        self.connection_manager._boto_session = None
        boto_session_1 = self.connection_manager.boto_session
        boto_session_2 = self.connection_manager.boto_session
        assert boto_session_1 == boto_session_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_no_pre_existing_clients(self,
                                                     mock_get_credentials):
        service = "s3"

        client = self.connection_manager._get_client(service)
        expected_client = Session().client(service)
        assert str(type(client)) == str(type(expected_client))

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_invalid_client_type(self, mock_get_credentials):
        service = "invalid_type"
        with pytest.raises(botocore.exceptions.UnknownServiceError):
            self.connection_manager._get_client(service)

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client(self, mock_get_credentials):
        service = "cloudformation"
        client_1 = self.connection_manager._get_client(service)
        client_2 = self.connection_manager._get_client(service)
        assert client_1 == client_2

    @mock_s3
    def test_call_with_valid_service_and_call(self):
        service = 's3'
        command = 'list_buckets'

        return_value = self.connection_manager.call(service, command, {})
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200
示例#5
0
class StackActions(object):
    """
    StackActions stores the operations a Stack can take, such as creating or
    deleting the Stack.

    :param stack: A Stack object
    :type stack: sceptre.stack.Stack
    """
    def __init__(self, stack):
        self.stack = stack
        self.name = self.stack.name
        self.logger = logging.getLogger(__name__)
        self.connection_manager = ConnectionManager(
            self.stack.region, self.stack.profile, self.stack.external_name,
            self.stack.iam_role, self.stack.iam_role_session_duration)

    @add_stack_hooks
    def create(self):
        """
        Creates a Stack.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()
        self.logger.info("%s - Creating Stack", self.stack.name)
        create_stack_kwargs = {
            "StackName":
            self.stack.external_name,
            "Parameters":
            self._format_parameters(self.stack.parameters),
            "Capabilities": [
                'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM',
                'CAPABILITY_AUTO_EXPAND'
            ],
            "NotificationARNs":
            self.stack.notifications,
            "Tags": [{
                "Key": str(k),
                "Value": str(v)
            } for k, v in self.stack.tags.items()]
        }

        if self.stack.on_failure:
            create_stack_kwargs.update({"OnFailure": self.stack.on_failure})
        create_stack_kwargs.update(
            self.stack.template.get_boto_call_parameter())
        create_stack_kwargs.update(self._get_role_arn())
        create_stack_kwargs.update(self._get_stack_timeout())

        try:
            response = self.connection_manager.call(service="cloudformation",
                                                    command="create_stack",
                                                    kwargs=create_stack_kwargs)

            self.logger.debug("%s - Create stack response: %s",
                              self.stack.name, response)

            status = self._wait_for_completion()
        except botocore.exceptions.ClientError as exp:
            if exp.response["Error"]["Code"] == "AlreadyExistsException":
                self.logger.info("%s - Stack already exists", self.stack.name)

                status = StackStatus.COMPLETE
            else:
                raise

        return status

    @add_stack_hooks
    def update(self):
        """
        Updates the Stack.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()
        self.logger.info("%s - Updating Stack", self.stack.name)
        try:
            update_stack_kwargs = {
                "StackName":
                self.stack.external_name,
                "Parameters":
                self._format_parameters(self.stack.parameters),
                "Capabilities": [
                    'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM',
                    'CAPABILITY_AUTO_EXPAND'
                ],
                "NotificationARNs":
                self.stack.notifications,
                "Tags": [{
                    "Key": str(k),
                    "Value": str(v)
                } for k, v in self.stack.tags.items()]
            }
            update_stack_kwargs.update(
                self.stack.template.get_boto_call_parameter())
            update_stack_kwargs.update(self._get_role_arn())
            response = self.connection_manager.call(service="cloudformation",
                                                    command="update_stack",
                                                    kwargs=update_stack_kwargs)
            status = self._wait_for_completion(self.stack.stack_timeout)
            self.logger.debug("%s - Update Stack response: %s",
                              self.stack.name, response)

            # Cancel update after timeout
            if status == StackStatus.IN_PROGRESS:
                status = self.cancel_stack_update()

            return status
        except botocore.exceptions.ClientError as exp:
            error_message = exp.response["Error"]["Message"]
            if error_message == "No updates are to be performed.":
                self.logger.info("%s - No updates to perform.",
                                 self.stack.name)
                return StackStatus.COMPLETE
            else:
                raise

    def cancel_stack_update(self):
        """
        Cancels a Stack update.

        :returns: The cancelled Stack status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self.logger.warning(
            "%s - Update Stack time exceeded the specified timeout",
            self.stack.name)
        response = self.connection_manager.call(
            service="cloudformation",
            command="cancel_update_stack",
            kwargs={"StackName": self.stack.external_name})
        self.logger.debug("%s - Cancel update Stack response: %s",
                          self.stack.name, response)
        return self._wait_for_completion()

    @add_stack_hooks
    def launch(self):
        """
        Launches the Stack.

        If the Stack status is create_failed or rollback_complete, the
        Stack is deleted. Launch then tries to create or update the Stack,
        depending if it already exists. If there are no updates to be
        performed, launch exits gracefully.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()
        self.logger.info("%s - Launching Stack", self.stack.name)
        try:
            existing_status = self._get_status()
        except StackDoesNotExistError:
            existing_status = "PENDING"

        self.logger.info("%s - Stack is in the %s state", self.stack.name,
                         existing_status)

        if existing_status == "PENDING":
            status = self.create()
        elif existing_status in ["CREATE_FAILED", "ROLLBACK_COMPLETE"]:
            self.delete()
            status = self.create()
        elif existing_status.endswith("COMPLETE"):
            status = self.update()
        elif existing_status.endswith("IN_PROGRESS"):
            self.logger.info(
                "%s - Stack action is already in progress state and cannot "
                "be updated", self.stack.name)
            status = StackStatus.IN_PROGRESS
        elif existing_status.endswith("FAILED"):
            status = StackStatus.FAILED
            raise CannotUpdateFailedStackError(
                "'{0}' is in a the state '{1}' and cannot be updated".format(
                    self.stack.name, existing_status))
        else:
            raise UnknownStackStatusError(
                "{0} is unknown".format(existing_status))
        return status

    @add_stack_hooks
    def delete(self):
        """
        Deletes the Stack.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        self._protect_execution()

        self.logger.info("%s - Deleting stack", self.stack.name)
        try:
            status = self._get_status()
        except StackDoesNotExistError:
            self.logger.info("%s - Does not exist.", self.stack.name)
            status = StackStatus.COMPLETE
            return status

        delete_stack_kwargs = {"StackName": self.stack.external_name}
        delete_stack_kwargs.update(self._get_role_arn())
        self.connection_manager.call(service="cloudformation",
                                     command="delete_stack",
                                     kwargs=delete_stack_kwargs)

        try:
            status = self._wait_for_completion()
        except StackDoesNotExistError:
            status = StackStatus.COMPLETE
        except botocore.exceptions.ClientError as error:
            if error.response["Error"]["Message"].endswith("does not exist"):
                status = StackStatus.COMPLETE
            else:
                raise
        self.logger.info("%s - delete %s", self.stack.name, status)
        return status

    def lock(self):
        """
        Locks the Stack by applying a deny-all updates Stack Policy.
        """
        policy_path = path.join(
            # need to get to the base install path. __file__ will take us into
            # sceptre/actions so need to walk up the path.
            path.abspath(path.join(__file__, "..", "..")),
            "stack_policies/lock.json")
        self.set_policy(policy_path)
        self.logger.info("%s - Successfully locked Stack", self.stack.name)

    def unlock(self):
        """
        Unlocks the Stack by applying an allow-all updates Stack Policy.
        """
        policy_path = path.join(
            # need to get to the base install path. __file__ will take us into
            # sceptre/actions so need to walk up the path.
            path.abspath(path.join(__file__, "..", "..")),
            "stack_policies/unlock.json")
        self.set_policy(policy_path)
        self.logger.info("%s - Successfully unlocked Stack", self.stack.name)

    def describe(self):
        """
        Returns the a description of the Stack.

        :returns: A Stack description.
        :rtype: dict
        """
        try:
            return self.connection_manager.call(
                service="cloudformation",
                command="describe_stacks",
                kwargs={"StackName": self.stack.external_name})
        except botocore.exceptions.ClientError as e:
            if e.response["Error"]["Message"].endswith("does not exist"):
                return
            raise

    def describe_events(self):
        """
        Returns the CloudFormation events for a Stack.

        :returns: CloudFormation events for a Stack.
        :rtype: dict
        """
        return self.connection_manager.call(
            service="cloudformation",
            command="describe_stack_events",
            kwargs={"StackName": self.stack.external_name})

    def describe_resources(self):
        """
        Returns the logical and physical resource IDs of the Stack's resources.

        :returns: Information about the Stack's resources.
        :rtype: dict
        """
        self.logger.debug("%s - Describing stack resources", self.stack.name)
        try:
            response = self.connection_manager.call(
                service="cloudformation",
                command="describe_stack_resources",
                kwargs={"StackName": self.stack.external_name})
        except botocore.exceptions.ClientError as e:
            if e.response["Error"]["Message"].endswith("does not exist"):
                return {self.stack.name: []}
            raise

        self.logger.debug("%s - Describe Stack resource response: %s",
                          self.stack.name, response)

        desired_properties = ["LogicalResourceId", "PhysicalResourceId"]

        formatted_response = {
            self.stack.name:
            [{k: v
              for k, v in item.items() if k in desired_properties}
             for item in response["StackResources"]]
        }
        return formatted_response

    def describe_outputs(self):
        """
        Returns the Stack's outputs.

        :returns: The Stack's outputs.
        :rtype: list
        """
        self.logger.debug("%s - Describing stack outputs", self.stack.name)

        try:
            response = self._describe()
        except botocore.exceptions.ClientError:
            return []

        return {self.stack.name: response["Stacks"][0].get("Outputs", [])}

    def continue_update_rollback(self):
        """
        Rolls back a Stack in the UPDATE_ROLLBACK_FAILED state to
        UPDATE_ROLLBACK_COMPLETE.
        """
        self.logger.debug("%s - Continuing update rollback", self.stack.name)
        continue_update_rollback_kwargs = {
            "StackName": self.stack.external_name
        }
        continue_update_rollback_kwargs.update(self._get_role_arn())
        self.connection_manager.call(service="cloudformation",
                                     command="continue_update_rollback",
                                     kwargs=continue_update_rollback_kwargs)
        self.logger.info(
            "%s - Successfully initiated continuation of update rollback",
            self.stack.name)

    def set_policy(self, policy_path):
        """
        Applies a Stack Policy.

        :param policy_path: The relative path of JSON file containing\
                the AWS Policy to apply.
        :type policy_path: str
        """
        with open(policy_path) as f:
            policy = f.read()

        self.logger.debug("%s - Setting Stack policy: \n%s", self.stack.name,
                          policy)

        self.connection_manager.call(service="cloudformation",
                                     command="set_stack_policy",
                                     kwargs={
                                         "StackName": self.stack.external_name,
                                         "StackPolicyBody": policy
                                     })
        self.logger.info("%s - Successfully set Stack Policy", self.stack.name)

    def get_policy(self):
        """
        Returns a Stack's Policy.

        :returns: The Stack's Stack Policy.
        :rtype: str
        """
        self.logger.debug("%s - Getting Stack Policy", self.stack.name)
        response = self.connection_manager.call(
            service="cloudformation",
            command="get_stack_policy",
            kwargs={"StackName": self.stack.external_name})
        json_formatting = json.loads(
            response.get("StackPolicyBody",
                         json.dumps("No Policy Information")))
        return {self.stack.name: json_formatting}

    @add_stack_hooks
    def create_change_set(self, change_set_name):
        """
        Creates a Change Set with the name ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        """
        create_change_set_kwargs = {
            "StackName":
            self.stack.external_name,
            "Parameters":
            self._format_parameters(self.stack.parameters),
            "Capabilities": [
                'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM',
                'CAPABILITY_AUTO_EXPAND'
            ],
            "ChangeSetName":
            change_set_name,
            "NotificationARNs":
            self.stack.notifications,
            "Tags": [{
                "Key": str(k),
                "Value": str(v)
            } for k, v in self.stack.tags.items()]
        }
        create_change_set_kwargs.update(
            self.stack.template.get_boto_call_parameter())
        create_change_set_kwargs.update(self._get_role_arn())
        self.logger.debug("%s - Creating Change Set '%s'", self.stack.name,
                          change_set_name)
        self.connection_manager.call(service="cloudformation",
                                     command="create_change_set",
                                     kwargs=create_change_set_kwargs)
        # After the call successfully completes, AWS CloudFormation
        # starts creating the Change Set.
        self.logger.info(
            "%s - Successfully initiated creation of Change Set '%s'",
            self.stack.name, change_set_name)

    def delete_change_set(self, change_set_name):
        """
        Deletes the Change Set ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        """
        self.logger.debug("%s - Deleting Change Set '%s'", self.stack.name,
                          change_set_name)
        self.connection_manager.call(service="cloudformation",
                                     command="delete_change_set",
                                     kwargs={
                                         "ChangeSetName": change_set_name,
                                         "StackName": self.stack.external_name
                                     })
        # If the call successfully completes, AWS CloudFormation
        # successfully deleted the Change Set.
        self.logger.info("%s - Successfully deleted Change Set '%s'",
                         self.stack.name, change_set_name)

    def describe_change_set(self, change_set_name):
        """
        Describes the Change Set ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The description of the Change Set.
        :rtype: dict
        """
        self.logger.debug("%s - Describing Change Set '%s'", self.stack.name,
                          change_set_name)
        return self.connection_manager.call(service="cloudformation",
                                            command="describe_change_set",
                                            kwargs={
                                                "ChangeSetName":
                                                change_set_name,
                                                "StackName":
                                                self.stack.external_name
                                            })

    def execute_change_set(self, change_set_name):
        """
        Executes the Change Set ``change_set_name``.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The Stack status
        :rtype: str
        """
        self._protect_execution()
        change_set = self.describe_change_set(change_set_name)
        status = change_set.get("Status")
        reason = change_set.get("StatusReason")
        if status == "FAILED" and self.change_set_creation_failed_due_to_no_changes(
                reason):
            self.logger.info(
                "Skipping ChangeSet on Stack: {} - there are no changes".
                format(change_set.get("StackName")))
            return 0

        self.logger.debug("%s - Executing Change Set '%s'", self.stack.name,
                          change_set_name)
        self.connection_manager.call(service="cloudformation",
                                     command="execute_change_set",
                                     kwargs={
                                         "ChangeSetName": change_set_name,
                                         "StackName": self.stack.external_name
                                     })

        status = self._wait_for_completion()
        return status

    def change_set_creation_failed_due_to_no_changes(self,
                                                     reason: str) -> bool:
        """Indicates the change set failed when it was created because there were actually
        no changes introduced from the change set.

        :param reason: The reason reported by CloudFormation for the Change Set failure
        """
        reason = reason.lower()
        no_change_substrings = (
            "submitted information didn't contain changes",
            "no updates are to be performed"  # The reason returned for SAM templates
        )

        for substring in no_change_substrings:
            if substring in reason:
                return True
        return False

    def list_change_sets(self, url=False):
        """
        Lists the Stack's Change Sets.

        :param url: Write out a console URL instead.
        :type url: bool

        :returns: The Stack's Change Sets.
        :rtype: dict or list
        """
        response = self._list_change_sets()
        summaries = response.get("Summaries", [])

        if url:
            summaries = self._convert_to_url(summaries)

        return {self.stack.name: summaries}

    def _list_change_sets(self):
        self.logger.debug("%s - Listing change sets", self.stack.name)
        try:
            return self.connection_manager.call(
                service="cloudformation",
                command="list_change_sets",
                kwargs={"StackName": self.stack.external_name})
        except botocore.exceptions.ClientError:
            return []

    def _convert_to_url(self, summaries):
        """
        Convert the list_change_sets response from
        CloudFormation to a URL in the AWS Console.
        """
        new_summaries = []

        for summary in summaries:
            stack_id = summary["StackId"]
            change_set_id = summary["ChangeSetId"]

            region = self.stack.region
            encoded = urllib.parse.urlencode({
                "stackId": stack_id,
                "changeSetId": change_set_id
            })

            new_summaries.append(
                f"https://{region}.console.aws.amazon.com/cloudformation/home?"
                f"region={region}#/stacks/changesets/changes?{encoded}")

        return new_summaries

    @add_stack_hooks
    def generate(self):
        """
        Returns the Template for the Stack
        """
        return self.stack.template.body

    @add_stack_hooks
    def validate(self):
        """
        Validates the Stack's CloudFormation Template.

        Raises an error if the Template is invalid.

        :returns: Validation information about the Template.
        :rtype: dict
        :raises: botocore.exceptions.ClientError
        """
        self.logger.debug("%s - Validating Template", self.stack.name)
        response = self.connection_manager.call(
            service="cloudformation",
            command="validate_template",
            kwargs=self.stack.template.get_boto_call_parameter())
        self.logger.debug("%s - Validate Template response: %s",
                          self.stack.name, response)
        return response

    def estimate_cost(self):
        """
        Estimates a Stack's cost.

        :returns: An estimate of the Stack's cost.
        :rtype: dict
        :raises: botocore.exceptions.ClientError
        """
        self.logger.debug("%s - Estimating template cost", self.stack.name)

        parameters = [{
            'ParameterKey': key,
            'ParameterValue': value
        } for key, value in self.stack.parameters.items()]

        kwargs = self.stack.template.get_boto_call_parameter()
        kwargs.update({'Parameters': parameters})

        response = self.connection_manager.call(
            service="cloudformation",
            command="estimate_template_cost",
            kwargs=kwargs)
        self.logger.debug("%s - Estimate Stack cost response: %s",
                          self.stack.name, response)
        return response

    def get_status(self):
        """
        Returns the Stack's status.

        :returns: The Stack's status.
        :rtype: sceptre.stack_status.StackStatus
        """
        try:
            return self._get_status()
        except StackDoesNotExistError:
            return "PENDING"

    def _format_parameters(self, parameters):
        """
        Converts CloudFormation parameters to the format used by Boto3.

        :param parameters: A dictionary of parameters.
        :type parameters: dict
        :returns: A list of the formatted parameters.
        :rtype: list
        """
        formatted_parameters = []
        for name, value in parameters.items():
            if value is None:
                continue
            if isinstance(value, list):
                value = ",".join(value)
            formatted_parameters.append({
                "ParameterKey": name,
                "ParameterValue": value
            })

        return formatted_parameters

    def _get_role_arn(self):
        """
        Returns the Role ARN assumed by CloudFormation when building a Stack.

        Returns an empty dict if no Role is to be assumed.

        :returns: The a Role ARN
        :rtype: dict
        """
        if self.stack.role_arn:
            return {"RoleARN": self.stack.role_arn}
        else:
            return {}

    def _get_stack_timeout(self):
        """
        Return the timeout before considering the Stack to be failing.

        Returns an empty dict if no timeout is set.
        :returns: the creation/update timeout
        :rtype: dict
        """
        if self.stack.stack_timeout:
            return {"TimeoutInMinutes": self.stack.stack_timeout}
        else:
            return {}

    def _protect_execution(self):
        """
        Raises a ProtectedStackError if protect == True.

        :raises: sceptre.exceptions.ProtectedStackError
        """
        if self.stack.protected:
            raise ProtectedStackError(
                "Cannot perform action on '{0}': Stack protection is "
                "currently enabled".format(self.stack.name))

    def _wait_for_completion(self, timeout=0):
        """
        Waits for a Stack operation to finish. Prints CloudFormation events
        while it waits.

        :param timeout: Timeout before returning, in minutes.

        :returns: The final Stack status.
        :rtype: sceptre.stack_status.StackStatus
        """
        timeout = 60 * timeout

        def timed_out(elapsed):
            return elapsed >= timeout if timeout else False

        status = StackStatus.IN_PROGRESS

        self.most_recent_event_datetime = (datetime.now(tzutc()) -
                                           timedelta(seconds=3))
        elapsed = 0
        while status == StackStatus.IN_PROGRESS and not timed_out(elapsed):
            status = self._get_simplified_status(self._get_status())
            self._log_new_events()
            time.sleep(4)
            elapsed += 4

        return status

    def _describe(self):
        return self.connection_manager.call(
            service="cloudformation",
            command="describe_stacks",
            kwargs={"StackName": self.stack.external_name})

    def _get_status(self):
        try:
            status = self._describe()["Stacks"][0]["StackStatus"]
        except botocore.exceptions.ClientError as exp:
            if exp.response["Error"]["Message"].endswith("does not exist"):
                raise StackDoesNotExistError(exp.response["Error"]["Message"])
            else:
                raise exp
        return status

    @staticmethod
    def _get_simplified_status(status):
        """
        Returns the simplified Stack Status.

        The simplified Stack status is represented by the struct
        ``sceptre.StackStatus()`` and can take one of the following options:

        * complete
        * in_progress
        * failed

        :param status: The CloudFormation Stack status to simplify.
        :type status: str
        :returns: The Stack's simplified status
        :rtype: sceptre.stack_status.StackStatus
        """
        if status.endswith("ROLLBACK_COMPLETE"):
            return StackStatus.FAILED
        elif status.endswith("_COMPLETE"):
            return StackStatus.COMPLETE
        elif status.endswith("_IN_PROGRESS"):
            return StackStatus.IN_PROGRESS
        elif status.endswith("_FAILED"):
            return StackStatus.FAILED
        else:
            raise UnknownStackStatusError("{0} is unknown".format(status))

    def _log_new_events(self):
        """
        Log the latest Stack events while the Stack is being built.
        """
        events = self.describe_events()["StackEvents"]
        events.reverse()
        new_events = [
            event for event in events
            if event["Timestamp"] > self.most_recent_event_datetime
        ]
        for event in new_events:
            self.logger.info(" ".join([
                self.stack.name, event["LogicalResourceId"],
                event["ResourceType"], event["ResourceStatus"],
                event.get("ResourceStatusReason", "")
            ]))
            self.most_recent_event_datetime = event["Timestamp"]

    def wait_for_cs_completion(self, change_set_name):
        """
        Waits while the Stack Change Set status is "pending".

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The Change Set's status.
        :rtype: sceptre.stack_status.StackChangeSetStatus
        """
        while True:
            status = self._get_cs_status(change_set_name)
            if status != StackChangeSetStatus.PENDING:
                break
            time.sleep(2)

        return status

    def _get_cs_status(self, change_set_name):
        """
        Returns the status of a Change Set.

        :param change_set_name: The name of the Change Set.
        :type change_set_name: str
        :returns: The Change Set's status.
        :rtype: sceptre.stack_status.StackChangeSetStatus
        """
        cs_description = self.describe_change_set(change_set_name)

        cs_status = cs_description["Status"]
        cs_exec_status = cs_description["ExecutionStatus"]
        possible_statuses = [
            "CREATE_PENDING", "CREATE_IN_PROGRESS", "CREATE_COMPLETE",
            "DELETE_COMPLETE", "FAILED"
        ]
        possible_execution_statuses = [
            "UNAVAILABLE", "AVAILABLE", "EXECUTE_IN_PROGRESS",
            "EXECUTE_COMPLETE", "EXECUTE_FAILED", "OBSOLETE"
        ]

        if cs_status not in possible_statuses:
            raise UnknownStackChangeSetStatusError(
                "Status {0} is unknown".format(cs_status))
        if cs_exec_status not in possible_execution_statuses:
            raise UnknownStackChangeSetStatusError(
                "ExecutionStatus {0} is unknown".format(cs_status))

        if (cs_status == "CREATE_COMPLETE" and cs_exec_status == "AVAILABLE"):
            return StackChangeSetStatus.READY
        elif (cs_status
              in ["CREATE_PENDING", "CREATE_IN_PROGRESS", "CREATE_COMPLETE"]
              and cs_exec_status in ["UNAVAILABLE", "AVAILABLE"]):
            return StackChangeSetStatus.PENDING
        elif (cs_status in ["DELETE_COMPLETE", "FAILED"] or cs_exec_status in [
                "EXECUTE_IN_PROGRESS", "EXECUTE_COMPLETE", "EXECUTE_FAILED",
                "OBSOLETE"
        ]):
            return StackChangeSetStatus.DEFUNCT
        else:  # pragma: no cover
            raise Exception("This else should not be reachable.")

    def fetch_remote_template(self) -> Optional[str]:
        """
        Returns the Template for the remote Stack

        :returns: the template body.
        """
        self.logger.debug(f"{self.stack.name} - Fetching remote template")

        original_template = self._fetch_original_template_stage()

        if isinstance(original_template, dict):
            # While not documented behavior, boto3 will attempt to deserialize the TemplateBody
            # with json.loads and return the template as a dict if it is successful; otherwise (such
            # as in when the template is in yaml, it will return the string. Therefore, we need to
            # dump the template to json if we get a dict.
            original_template = json.dumps(original_template, indent=4)

        return original_template

    def _fetch_original_template_stage(self) -> Optional[Union[str, dict]]:
        try:
            response = self.connection_manager.call(
                service="cloudformation",
                command="get_template",
                kwargs={
                    "StackName": self.stack.external_name,
                    "TemplateStage": 'Original'
                })
            return response['TemplateBody']
            # Sometimes boto returns a string, sometimes a dictionary
        except botocore.exceptions.ClientError as e:
            # AWS returns a ValidationError if the stack doesn't exist
            if e.response['Error']['Code'] == 'ValidationError':
                return None
            raise

    def fetch_remote_template_summary(self):
        return self._get_template_summary(StackName=self.stack.external_name)

    def fetch_local_template_summary(self):
        boto_call_parameter = self.stack.template.get_boto_call_parameter()
        return self._get_template_summary(**boto_call_parameter)

    def _get_template_summary(self, **kwargs) -> Optional[dict]:
        try:
            template_summary = self.connection_manager.call(
                service='cloudformation',
                command='get_template_summary',
                kwargs=kwargs)
            return template_summary
        except botocore.exceptions.ClientError as e:
            error_response = e.response['Error']
            if (error_response['Code'] == 'ValidationError'
                    and 'does not exist' in error_response['Message']):
                return None
            raise

    @add_stack_hooks
    def diff(self, stack_differ):
        """
        Returns a diff of Template and Remote Template
        using a specific diff library.

        :param stack_differ: The diff lib to use, default difflib.
        :type: sceptre.diffing.stack_differ.StackDiffer

        :returns: A StackDiff object.
        :rtype: sceptre.diffing.stack_differ.StackDiff
        """
        return stack_differ.diff(self)

    @add_stack_hooks
    def drift_detect(self) -> Dict[str, str]:
        """
        Show stack drift for a running stack.

        :returns: The stack drift detection status.
        If the stack does not exist, we return a detection and
        stack drift status of STACK_DOES_NOT_EXIST.
        If drift detection times out after 5 minutes, we return
        TIMED_OUT.
        """
        try:
            self._get_status()
        except StackDoesNotExistError:
            self.logger.info(f"{self.stack.name} - Does not exist.")
            return {
                "DetectionStatus": "STACK_DOES_NOT_EXIST",
                "StackDriftStatus": "STACK_DOES_NOT_EXIST"
            }

        response = self._detect_stack_drift()
        detection_id = response["StackDriftDetectionId"]

        try:
            response = self._wait_for_drift_status(detection_id)
        except TimeoutError as exc:
            self.logger.info(f"{self.stack.name} - {exc}")
            response = {
                "DetectionStatus": "TIMED_OUT",
                "StackDriftStatus": "TIMED_OUT"
            }

        return response

    @add_stack_hooks
    def drift_show(self) -> Tuple[str, dict]:
        """
        Detect drift status on stacks.

        :returns: The detection status and resource drifts.
        """
        response = self.drift_detect()
        detection_status = response["DetectionStatus"]

        if detection_status in ["DETECTION_COMPLETE", "DETECTION_FAILED"]:
            response = self._describe_stack_resource_drifts()
        elif detection_status in ["TIMED_OUT", "STACK_DOES_NOT_EXIST"]:
            response = {"StackResourceDriftStatus": detection_status}
        else:
            raise Exception("Not expected to be reachable")

        return (detection_status, response)

    def _wait_for_drift_status(self, detection_id: str) -> dict:
        """
        Waits for drift detection to complete.

        :param detection_id: The drift detection ID.
        :returns: The response from describe_stack_drift_detection_status.
        """
        timeout = 300
        sleep_interval = 10
        elapsed = 0

        while True:
            if elapsed >= timeout:
                raise TimeoutError(f"Timed out after {elapsed} seconds")

            self.logger.info(
                f"{self.stack.name} - Waiting for drift detection")
            response = self._describe_stack_drift_detection_status(
                detection_id)
            detection_status = response["DetectionStatus"]

            self._log_drift_status(response)

            if detection_status == "DETECTION_IN_PROGRESS":
                time.sleep(sleep_interval)
                elapsed += sleep_interval
            else:
                return response

    def _log_drift_status(self, response: dict) -> None:
        """
        Log the drift status while waiting for
        drift detection to complete.
        """
        keys = [
            "StackDriftDetectionId", "DetectionStatus",
            "DetectionStatusReason", "StackDriftStatus"
        ]

        for key in keys:
            if key in response:
                self.logger.debug(
                    f"{self.stack.name} - {key} - {response[key]}")

    def _detect_stack_drift(self) -> dict:
        """
        Run detect_stack_drift.
        """
        self.logger.info(f"{self.stack.name} - Detecting Stack Drift")

        return self.connection_manager.call(
            service="cloudformation",
            command="detect_stack_drift",
            kwargs={"StackName": self.stack.external_name})

    def _describe_stack_drift_detection_status(self,
                                               detection_id: str) -> dict:
        """
        Run describe_stack_drift_detection_status.
        """
        self.logger.info(
            f"{self.stack.name} - Describing Stack Drift Detection Status")

        return self.connection_manager.call(
            service="cloudformation",
            command="describe_stack_drift_detection_status",
            kwargs={"StackDriftDetectionId": detection_id})

    def _describe_stack_resource_drifts(self) -> dict:
        """
        Detects stack resource_drifts for a running stack.
        """
        self.logger.info(
            f"{self.stack.name} - Describing Stack Resource Drifts")

        return self.connection_manager.call(
            service="cloudformation",
            command="describe_stack_resource_drifts",
            kwargs={"StackName": self.stack.external_name})
示例#6
0
class TestConnectionManager(object):
    def setup_method(self, test_method):
        self.stack_name = None
        self.profile = None
        self.region = "eu-west-1"

        ConnectionManager._boto_sessions = {}
        ConnectionManager._clients = {}
        ConnectionManager._stack_keys = {}

        # Temporary workaround for https://github.com/spulec/moto/issues/1924
        os.environ.setdefault("AWS_ACCESS_KEY_ID", "sceptre_test_key_id")
        os.environ.setdefault("AWS_SECRET_ACCESS_KEY",
                              "sceptre_test_access_key")

        self.connection_manager = ConnectionManager(region=self.region,
                                                    stack_name=self.stack_name,
                                                    profile=self.profile)

    def test_connection_manager_initialised_with_no_optional_parameters(self):
        connection_manager = ConnectionManager(region=sentinel.region)

        assert connection_manager.stack_name is None
        assert connection_manager.profile is None
        assert connection_manager.region == sentinel.region
        assert connection_manager._boto_sessions == {}
        assert connection_manager._clients == {}
        assert connection_manager._stack_keys == {}

    def test_connection_manager_initialised_with_all_parameters(self):
        connection_manager = ConnectionManager(region=self.region,
                                               stack_name="stack",
                                               profile="profile")

        assert connection_manager.stack_name == "stack"
        assert connection_manager.profile == "profile"
        assert connection_manager.region == self.region
        assert connection_manager._boto_sessions == {}
        assert connection_manager._clients == {}
        assert connection_manager._stack_keys == {
            "stack": (self.region, "profile")
        }

    def test_repr(self):
        self.connection_manager.stack_name = "stack"
        self.connection_manager.profile = "profile"
        self.connection_manager.region = "region"
        response = self.connection_manager.__repr__()
        assert response == "sceptre.connection_manager.ConnectionManager(" \
            "region='region', profile='profile', stack_name='stack')"

    def test_boto_session_with_cache(self):
        self.connection_manager._boto_sessions["test"] = sentinel.boto_session

        boto_session = self.connection_manager._boto_sessions["test"]
        assert boto_session == sentinel.boto_session

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_profile(self, mock_Session):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.profile = None

        boto_session = self.connection_manager._get_session(
            self.connection_manager.profile, self.region)

        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(profile_name=None,
                                             region_name="eu-west-1",
                                             aws_access_key_id=ANY,
                                             aws_secret_access_key=ANY,
                                             aws_session_token=ANY)

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_profile(self, mock_Session):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.profile = "profile"

        boto_session = self.connection_manager._get_session(
            self.connection_manager.profile, self.region)

        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(profile_name="profile",
                                             region_name="eu-west-1",
                                             aws_access_key_id=ANY,
                                             aws_secret_access_key=ANY,
                                             aws_session_token=ANY)

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_two_boto_sessions(self, mock_Session):
        self.connection_manager._boto_sessions = {
            "one": mock_Session,
            "two": mock_Session
        }

        boto_session_1 = self.connection_manager._boto_sessions["one"]
        boto_session_2 = self.connection_manager._boto_sessions["two"]
        assert boto_session_1 == boto_session_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_no_pre_existing_clients(self,
                                                     mock_get_credentials):
        service = "s3"
        region = "eu-west-1"
        profile = None
        stack = self.stack_name

        client = self.connection_manager._get_client(service, region, profile,
                                                     stack)
        expected_client = Session().client(service)
        assert str(type(client)) == str(type(expected_client))

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_invalid_client_type(self, mock_get_credentials):
        service = "invalid_type"
        region = "eu-west-1"
        profile = None
        stack = self.stack_name

        with pytest.raises(UnknownServiceError):
            self.connection_manager._get_client(service, region, profile,
                                                stack)

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client(self, mock_get_credentials):
        service = "cloudformation"
        region = "eu-west-1"
        profile = None
        stack = self.stack_name

        client_1 = self.connection_manager._get_client(service, region,
                                                       profile, stack)
        client_2 = self.connection_manager._get_client(service, region,
                                                       profile, stack)
        assert client_1 == client_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client_and_profile_none(
            self, mock_get_credentials):
        service = "cloudformation"
        region = "eu-west-1"
        profile = None
        stack = self.stack_name

        self.connection_manager.profile = None
        client_1 = self.connection_manager._get_client(service, region,
                                                       profile, stack)
        client_2 = self.connection_manager._get_client(service, region,
                                                       profile, stack)
        assert client_1 == client_2

    @mock_s3
    def test_call_with_valid_service_and_call(self):
        service = 's3'
        command = 'list_buckets'

        return_value = self.connection_manager.call(service, command, {})
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200

    @mock_s3
    def test_call_with_valid_service_and_stack_name_call(self):
        service = 's3'
        command = 'list_buckets'

        connection_manager = ConnectionManager(region=self.region,
                                               stack_name='stack')

        return_value = connection_manager.call(service,
                                               command, {},
                                               stack_name='stack')
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200
示例#7
0
class TestConnectionManager(object):
    def setup_method(self, test_method):
        self.iam_role = None
        self.profile = None
        self.region = "eu-west-1"

        self.connection_manager = ConnectionManager(region=self.region,
                                                    iam_role=self.iam_role,
                                                    profile=self.profile)

    def test_connection_manager_initialised_with_all_parameters(self):
        connection_manager = ConnectionManager(region=self.region,
                                               iam_role="role",
                                               profile="profile")
        assert connection_manager.iam_role == "role"
        assert connection_manager.profile == "profile"
        assert connection_manager.region == self.region
        assert connection_manager._boto_session is None
        assert connection_manager.clients == {}

    def test_connection_manager_initialised_with_no_optional_parameters(self):
        connection_manager = ConnectionManager(region=sentinel.region)

        assert connection_manager.iam_role is None
        assert connection_manager.profile is None
        assert connection_manager.region == sentinel.region
        assert connection_manager._boto_session is None
        assert connection_manager.clients == {}

    def test_repr(self):
        self.connection_manager.iam_role = "role"
        self.connection_manager.profile = "profile"
        self.connection_manager.region = "region"
        response = self.connection_manager.__repr__()
        assert response == "sceptre.connection_manager.ConnectionManager(" \
            "region='region', iam_role='role', profile='profile')"

    def test_boto_session_with_cache(self):
        self.connection_manager._boto_session = sentinel.boto_session
        assert self.connection_manager.boto_session == sentinel.boto_session

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_iam_role_and_no_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = None
        self.connection_manager.profile = None

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(region_name="eu-west-1",
                                             profile_name=None)

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_iam_role_and_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = None
        self.connection_manager.profile = "profile"

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(region_name="eu-west-1",
                                             profile_name="profile")

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role_and_no_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = "non-default"
        self.connection_manager.profile = None

        mock_credentials = {
            "Credentials": {
                "AccessKeyId": "id",
                "SecretAccessKey": "key",
                "SessionToken": "token",
                "Expiration": datetime(2020, 1, 1)
            }
        }

        mock_Session.return_value.client.return_value.\
            assume_role.return_value = mock_credentials

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)

        mock_Session.assert_any_call(profile_name=None,
                                     region_name=self.region)
        mock_Session.assert_any_call(aws_access_key_id="id",
                                     aws_secret_access_key="key",
                                     aws_session_token="token",
                                     region_name=self.region)

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role_and_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = "non-default"
        self.connection_manager.profile = "profile"

        mock_credentials = {
            "Credentials": {
                "AccessKeyId": "id",
                "SecretAccessKey": "key",
                "SessionToken": "token",
                "Expiration": datetime(2020, 1, 1)
            }
        }

        mock_Session.return_value.client.return_value. \
            assume_role.return_value = mock_credentials

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)

        mock_Session.assert_any_call(profile_name="profile",
                                     region_name=self.region)
        mock_Session.assert_any_call(aws_access_key_id="id",
                                     aws_secret_access_key="key",
                                     aws_session_token="token",
                                     region_name=self.region)

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_two_boto_sessions(self, mock_Session):
        self.connection_manager._boto_session = None
        boto_session_1 = self.connection_manager.boto_session
        boto_session_2 = self.connection_manager.boto_session
        assert boto_session_1 == boto_session_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_no_pre_existing_clients(self,
                                                     mock_get_credentials):
        service = "s3"

        client = self.connection_manager._get_client(service)
        expected_client = Session().client(service)
        assert str(type(client)) == str(type(expected_client))

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_invalid_client_type(self, mock_get_credentials):
        service = "invalid_type"
        with pytest.raises(UnknownServiceError):
            self.connection_manager._get_client(service)

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client(self, mock_get_credentials):
        service = "cloudformation"
        client_1 = self.connection_manager._get_client(service)
        client_2 = self.connection_manager._get_client(service)
        assert client_1 == client_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client_and_iam_role_none(
            self, mock_get_credentials):
        service = "cloudformation"
        self.connection_manager._iam_role = None
        client_1 = self.connection_manager._get_client(service)
        client_2 = self.connection_manager._get_client(service)
        assert client_1 == client_2

    def test_clear_session_cache_if_expired_with_no_iam_role(self):
        self.connection_manager.iam_role = None
        self.connection_manager._boto_session_expiration = sentinel.expiration
        self.connection_manager.clients = sentinel.clients
        self.connection_manager._boto_session = sentinel.boto_session
        self.connection_manager._clear_session_cache_if_expired()
        assert self.connection_manager.clients == sentinel.clients
        assert self.connection_manager._boto_session == sentinel.boto_session

    @freeze_time("2000-01-30")
    def test_clear_session_cache_if_expired_with_future_date(self):
        self.connection_manager.iam_role = "iam_role"
        future_date = datetime(2015, 1, 30, tzinfo=tz.tzutc())
        self.connection_manager._boto_session_expiration = future_date
        self.connection_manager.clients = sentinel.clients
        self.connection_manager._boto_session = sentinel.boto_session
        self.connection_manager._clear_session_cache_if_expired()
        assert self.connection_manager.clients == sentinel.clients
        assert self.connection_manager._boto_session == sentinel.boto_session

    @freeze_time("2015-01-30")
    def test_clear_session_cache_if_expired_with_expired_date(self):
        self.connection_manager.iam_role = "iam_role"
        past_date = datetime(2000, 1, 30, tzinfo=tz.tzutc())
        self.connection_manager._boto_session_expiration = past_date

        self.connection_manager.clients = sentinel.clients
        self.connection_manager._boto_session = sentinel.boto_session

        self.connection_manager._clear_session_cache_if_expired()
        assert self.connection_manager.clients == {}
        assert self.connection_manager._boto_session is None

    @mock_s3
    def test_call_with_valid_service_and_call(self):
        service = 's3'
        command = 'list_buckets'

        return_value = self.connection_manager.call(service, command, {})
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200
class TestConnectionManager(object):

    def setup_method(self, test_method):
        self.stack_name = None
        self.profile = None
        self.iam_role = None
        self.iam_role_session_duration = 3600
        self.region = "eu-west-1"

        ConnectionManager._boto_sessions = {}
        ConnectionManager._clients = {}
        ConnectionManager._stack_keys = {}

        # Temporary workaround for https://github.com/spulec/moto/issues/1924
        os.environ.setdefault("AWS_ACCESS_KEY_ID", "sceptre_test_key_id")
        os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "sceptre_test_access_key")

        self.connection_manager = ConnectionManager(
            region=self.region,
            stack_name=self.stack_name,
            profile=self.profile,
            iam_role=self.iam_role
        )

    def test_connection_manager_initialised_with_no_optional_parameters(self):
        connection_manager = ConnectionManager(region=sentinel.region)

        assert connection_manager.stack_name is None
        assert connection_manager.profile is None
        assert connection_manager.region == sentinel.region
        assert connection_manager._boto_sessions == {}
        assert connection_manager._clients == {}
        assert connection_manager._stack_keys == {}

    def test_connection_manager_initialised_with_all_parameters(self):
        connection_manager = ConnectionManager(
            region=self.region,
            stack_name="stack",
            profile="profile",
            iam_role="iam_role",
            iam_role_session_duration=21600
        )

        assert connection_manager.stack_name == "stack"
        assert connection_manager.profile == "profile"
        assert connection_manager.iam_role == "iam_role"
        assert connection_manager.iam_role_session_duration == 21600
        assert connection_manager.region == self.region
        assert connection_manager._boto_sessions == {}
        assert connection_manager._clients == {}
        assert connection_manager._stack_keys == {
            "stack": (self.region, "profile", "iam_role")
        }

    def test_repr(self):
        self.connection_manager.stack_name = "stack"
        self.connection_manager.profile = "profile"
        self.connection_manager.region = "region"
        self.connection_manager.iam_role = "iam_role"
        response = self.connection_manager.__repr__()
        assert response == "sceptre.connection_manager.ConnectionManager(" \
            "region='region', profile='profile', stack_name='stack', "\
            "iam_role='iam_role', iam_role_session_duration='None')"

    def test_repr_with_iam_role_session_duration(self):
        self.connection_manager.stack_name = "stack"
        self.connection_manager.profile = "profile"
        self.connection_manager.region = "region"
        self.connection_manager.iam_role = "iam_role"
        self.connection_manager.iam_role_session_duration = 21600
        response = self.connection_manager.__repr__()
        assert response == "sceptre.connection_manager.ConnectionManager(" \
            "region='region', profile='profile', stack_name='stack', "\
            "iam_role='iam_role', iam_role_session_duration='21600')"

    def test_boto_session_with_cache(self):
        self.connection_manager._boto_sessions["test"] = sentinel.boto_session

        boto_session = self.connection_manager._boto_sessions["test"]
        assert boto_session == sentinel.boto_session

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_profile(
            self, mock_Session
    ):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.profile = None

        boto_session = self.connection_manager._get_session(
            self.connection_manager.profile, self.region, self.iam_role
        )

        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(
            profile_name=None,
            region_name="eu-west-1",
            aws_access_key_id=ANY,
            aws_secret_access_key=ANY,
            aws_session_token=ANY
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_profile(self, mock_Session):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.profile = "profile"

        boto_session = self.connection_manager._get_session(
            self.connection_manager.profile, self.region, self.iam_role
        )

        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(
            profile_name="profile",
            region_name="eu-west-1",
            aws_access_key_id=ANY,
            aws_secret_access_key=ANY,
            aws_session_token=ANY
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_iam_role(
            self, mock_Session
    ):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.iam_role = None

        boto_session = self.connection_manager._get_session(
            self.profile, self.region, self.connection_manager.iam_role
        )

        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(
            profile_name=None,
            region_name="eu-west-1",
            aws_access_key_id=ANY,
            aws_secret_access_key=ANY,
            aws_session_token=ANY
        )

        boto_session.client().assume_role.assert_not_called()

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role(self, mock_Session):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.iam_role = "iam_role"

        boto_session = self.connection_manager._get_session(
            self.profile, self.region, self.connection_manager.iam_role
        )

        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_any_call(
            profile_name=None,
            region_name="eu-west-1",
            aws_access_key_id=ANY,
            aws_secret_access_key=ANY,
            aws_session_token=ANY
        )

        boto_session.client().assume_role.assert_called_once_with(
            RoleArn=self.connection_manager.iam_role,
            RoleSessionName="{0}-session".format(
                self.connection_manager.iam_role.split("/")[-1]
            )
        )

        credentials = boto_session.client().assume_role()["Credentials"]

        mock_Session.assert_any_call(
            region_name="eu-west-1",
            aws_access_key_id=credentials["AccessKeyId"],
            aws_secret_access_key=credentials["SecretAccessKey"],
            aws_session_token=credentials["SessionToken"]
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role_session_duration(self, mock_Session):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.iam_role = "iam_role"
        self.connection_manager.iam_role_session_duration = 21600

        boto_session = self.connection_manager._get_session(
            self.profile, self.region, self.connection_manager.iam_role
        )

        boto_session.client().assume_role.assert_called_once_with(
            RoleArn=self.connection_manager.iam_role,
            RoleSessionName="{0}-session".format(
                self.connection_manager.iam_role.split("/")[-1]
            ),
            DurationSeconds=21600
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role_returning_empty_credentials(self, mock_Session):
        self.connection_manager._boto_sessions = {}
        self.connection_manager.iam_role = "iam_role"

        mock_Session.return_value.get_credentials.side_effect = [
            MagicMock(), None, MagicMock(), MagicMock(), MagicMock()
        ]

        with pytest.raises(InvalidAWSCredentialsError):
            self.connection_manager._get_session(
                self.profile, self.region, self.connection_manager.iam_role
            )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_two_boto_sessions(self, mock_Session):
        self.connection_manager._boto_sessions = {
            "one": mock_Session,
            "two": mock_Session
        }

        boto_session_1 = self.connection_manager._boto_sessions["one"]
        boto_session_2 = self.connection_manager._boto_sessions["two"]
        assert boto_session_1 == boto_session_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_no_pre_existing_clients(
        self, mock_get_credentials
    ):
        service = "s3"
        region = "eu-west-1"
        profile = None
        iam_role = None
        stack = self.stack_name

        client = self.connection_manager._get_client(
            service, region, profile, stack, iam_role
        )
        expected_client = Session().client(service)
        assert str(type(client)) == str(type(expected_client))

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_invalid_client_type(self, mock_get_credentials):
        service = "invalid_type"
        region = "eu-west-1"
        iam_role = None
        profile = None
        stack = self.stack_name

        with pytest.raises(UnknownServiceError):
            self.connection_manager._get_client(
                service, region, profile, stack, iam_role
            )

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client(self, mock_get_credentials):
        service = "cloudformation"
        region = "eu-west-1"
        iam_role = None
        profile = None
        stack = self.stack_name

        client_1 = self.connection_manager._get_client(
            service, region, profile, stack, iam_role
        )
        client_2 = self.connection_manager._get_client(
            service, region, profile, stack, iam_role
        )
        assert client_1 == client_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client_and_profile_none(
            self, mock_get_credentials
    ):
        service = "cloudformation"
        region = "eu-west-1"
        iam_role = None
        profile = None
        stack = self.stack_name

        self.connection_manager.profile = None
        client_1 = self.connection_manager._get_client(
            service, region, profile, stack, iam_role
        )
        client_2 = self.connection_manager._get_client(
            service, region, profile, stack, iam_role
        )
        assert client_1 == client_2

    @mock_s3
    def test_call_with_valid_service_and_call(self):
        service = 's3'
        command = 'list_buckets'

        return_value = self.connection_manager.call(service, command, {})
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200

    @mock_s3
    def test_call_with_valid_service_and_stack_name_call(self):
        service = 's3'
        command = 'list_buckets'

        connection_manager = ConnectionManager(
            region=self.region,
            stack_name='stack'
        )

        return_value = connection_manager.call(
            service, command, {}, stack_name='stack'
        )
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200
class TestConnectionManager(object):

    def setup_method(self, test_method):
        self.iam_role = None
        self.profile = None
        self.region = "eu-west-1"

        self.connection_manager = ConnectionManager(
            region=self.region,
            iam_role=self.iam_role,
            profile=self.profile
        )

    def test_connection_manager_initialised_with_all_parameters(self):
        connection_manager = ConnectionManager(
            region=self.region,
            iam_role="role",
            profile="profile"
        )
        assert connection_manager.iam_role == "role"
        assert connection_manager.profile == "profile"
        assert connection_manager.region == self.region
        assert connection_manager._boto_session is None
        assert connection_manager.clients == {}

    def test_connection_manager_initialised_with_no_optional_parameters(self):
        connection_manager = ConnectionManager(region=sentinel.region)

        assert connection_manager.iam_role is None
        assert connection_manager.profile is None
        assert connection_manager.region == sentinel.region
        assert connection_manager._boto_session is None
        assert connection_manager.clients == {}

    def test_repr(self):
        self.connection_manager.iam_role = "role"
        self.connection_manager.profile = "profile"
        self.connection_manager.region = "region"
        response = self.connection_manager.__repr__()
        assert response == "sceptre.connection_manager.ConnectionManager(" \
            "region='region', iam_role='role', profile='profile')"

    def test_boto_session_with_cache(self):
        self.connection_manager._boto_session = sentinel.boto_session
        assert self.connection_manager.boto_session == sentinel.boto_session

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_iam_role_and_no_profile(
            self, mock_Session
    ):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = None
        self.connection_manager.profile = None

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(
            region_name="eu-west-1",
            profile_name=None
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_no_iam_role_and_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = None
        self.connection_manager.profile = "profile"

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)
        mock_Session.assert_called_once_with(
            region_name="eu-west-1",
            profile_name="profile"
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role_and_no_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = "non-default"
        self.connection_manager.profile = None

        mock_credentials = {
            "Credentials": {
                "AccessKeyId": "id",
                "SecretAccessKey": "key",
                "SessionToken": "token",
                "Expiration": datetime(2020, 1, 1)
            }
        }

        mock_Session.return_value.client.return_value.\
            assume_role.return_value = mock_credentials

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)

        mock_Session.assert_any_call(
            profile_name=None,
            region_name=self.region
        )
        mock_Session.assert_any_call(
            aws_access_key_id="id",
            aws_secret_access_key="key",
            aws_session_token="token",
            region_name=self.region
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_boto_session_with_iam_role_and_profile(self, mock_Session):
        self.connection_manager._boto_session = None
        self.connection_manager.iam_role = "non-default"
        self.connection_manager.profile = "profile"

        mock_credentials = {
            "Credentials": {
                "AccessKeyId": "id",
                "SecretAccessKey": "key",
                "SessionToken": "token",
                "Expiration": datetime(2020, 1, 1)
            }
        }

        mock_Session.return_value.client.return_value. \
            assume_role.return_value = mock_credentials

        boto_session = self.connection_manager.boto_session
        assert boto_session.isinstance(mock_Session)

        mock_Session.assert_any_call(
            profile_name="profile",
            region_name=self.region
        )
        mock_Session.assert_any_call(
            aws_access_key_id="id",
            aws_secret_access_key="key",
            aws_session_token="token",
            region_name=self.region
        )

    @patch("sceptre.connection_manager.boto3.session.Session")
    def test_two_boto_sessions(self, mock_Session):
        self.connection_manager._boto_session = None
        boto_session_1 = self.connection_manager.boto_session
        boto_session_2 = self.connection_manager.boto_session
        assert boto_session_1 == boto_session_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_no_pre_existing_clients(
        self, mock_get_credentials
    ):
        service = "s3"

        client = self.connection_manager._get_client(service)
        expected_client = Session().client(service)
        assert str(type(client)) == str(type(expected_client))

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_invalid_client_type(self, mock_get_credentials):
        service = "invalid_type"
        with pytest.raises(UnknownServiceError):
            self.connection_manager._get_client(service)

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client(self, mock_get_credentials):
        service = "cloudformation"
        client_1 = self.connection_manager._get_client(service)
        client_2 = self.connection_manager._get_client(service)
        assert client_1 == client_2

    @patch("sceptre.connection_manager.boto3.session.Session.get_credentials")
    def test_get_client_with_exisiting_client_and_iam_role_none(
            self, mock_get_credentials
    ):
        service = "cloudformation"
        self.connection_manager._iam_role = None
        client_1 = self.connection_manager._get_client(service)
        client_2 = self.connection_manager._get_client(service)
        assert client_1 == client_2

    def test_clear_session_cache_if_expired_with_no_iam_role(self):
        self.connection_manager.iam_role = None
        self.connection_manager._boto_session_expiration = sentinel.expiration
        self.connection_manager.clients = sentinel.clients
        self.connection_manager._boto_session = sentinel.boto_session
        self.connection_manager._clear_session_cache_if_expired()
        assert self.connection_manager.clients == sentinel.clients
        assert self.connection_manager._boto_session == sentinel.boto_session

    @freeze_time("2000-01-30")
    def test_clear_session_cache_if_expired_with_future_date(self):
        self.connection_manager.iam_role = "iam_role"
        future_date = datetime(2015, 1, 30, tzinfo=tz.tzutc())
        self.connection_manager._boto_session_expiration = future_date
        self.connection_manager.clients = sentinel.clients
        self.connection_manager._boto_session = sentinel.boto_session
        self.connection_manager._clear_session_cache_if_expired()
        assert self.connection_manager.clients == sentinel.clients
        assert self.connection_manager._boto_session == sentinel.boto_session

    @freeze_time("2015-01-30")
    def test_clear_session_cache_if_expired_with_expired_date(self):
        self.connection_manager.iam_role = "iam_role"
        past_date = datetime(2000, 1, 30, tzinfo=tz.tzutc())
        self.connection_manager._boto_session_expiration = past_date

        self.connection_manager.clients = sentinel.clients
        self.connection_manager._boto_session = sentinel.boto_session

        self.connection_manager._clear_session_cache_if_expired()
        assert self.connection_manager.clients == {}
        assert self.connection_manager._boto_session is None

    @mock_s3
    def test_call_with_valid_service_and_call(self):
        service = 's3'
        command = 'list_buckets'

        return_value = self.connection_manager.call(service, command, {})
        assert return_value['ResponseMetadata']['HTTPStatusCode'] == 200