示例#1
0
 def __init__(self, cloudformation_client, changeset_prefix="samcli-deploy"):
     self._client = cloudformation_client
     self.changeset_prefix = changeset_prefix
     # 500ms of sleep time between stack checks and describe stack events.
     self.client_sleep = 0.5
     # 2000ms of backoff time which is exponentially used, when there are exceptions during describe stack events
     self.backoff = 2
     # Maximum number of attempts before raising exception back up the chain.
     self.max_attempts = 3
     self.deploy_color = DeployColor()
示例#2
0
class Deployer:
    def __init__(self,
                 cloudformation_client,
                 changeset_prefix="samcli-deploy"):
        self._client = cloudformation_client
        self.changeset_prefix = changeset_prefix
        # 500ms of sleep time between stack checks and describe stack events.
        self.client_sleep = 0.5
        # 2000ms of backoff time which is exponentially used, when there are exceptions during describe stack events
        self.backoff = 2
        # Maximum number of attempts before raising exception back up the chain.
        self.max_attempts = 3
        self.deploy_color = DeployColor()

    def has_stack(self, stack_name):
        """
        Checks if a CloudFormation stack with given name exists

        :param stack_name: Name or ID of the stack
        :return: True if stack exists. False otherwise
        """
        try:
            resp = self._client.describe_stacks(StackName=stack_name)
            if not resp["Stacks"]:
                return False

            # When you run CreateChangeSet on a a stack that does not exist,
            # CloudFormation will create a stack and set it's status
            # REVIEW_IN_PROGRESS. However this stack is cannot be manipulated
            # by "update" commands. Under this circumstances, we treat like
            # this stack does not exist and call CreateChangeSet will
            # ChangeSetType set to CREATE and not UPDATE.
            stack = resp["Stacks"][0]
            return stack["StackStatus"] != "REVIEW_IN_PROGRESS"

        except botocore.exceptions.ClientError as e:
            # If a stack does not exist, describe_stacks will throw an
            # exception. Unfortunately we don't have a better way than parsing
            # the exception msg to understand the nature of this exception.

            if "Stack with id {0} does not exist".format(stack_name) in str(e):
                LOG.debug("Stack with id %s does not exist", stack_name)
                return False
        except botocore.exceptions.BotoCoreError as e:
            # If there are credentials, environment errors,
            # catch that and throw a deploy failed error.

            LOG.debug("Botocore Exception : %s", str(e))
            raise DeployFailedError(stack_name=stack_name, msg=str(e)) from e

        except Exception as e:
            # We don't know anything about this exception. Don't handle
            LOG.debug("Unable to get stack details.", exc_info=e)
            raise e

    def create_changeset(self, stack_name, cfn_template, parameter_values,
                         capabilities, role_arn, notification_arns,
                         s3_uploader, tags):
        """
        Call Cloudformation to create a changeset and wait for it to complete

        :param stack_name: Name or ID of stack
        :param cfn_template: CloudFormation template string
        :param parameter_values: Template parameters object
        :param capabilities: Array of capabilities passed to CloudFormation
        :param role_arn: the Arn of the role to create changeset
        :param notification_arns: Arns for sending notifications
        :param s3_uploader: S3Uploader object to upload files to S3 buckets
        :param tags: Array of tags passed to CloudFormation
        :return:
        """
        if not self.has_stack(stack_name):
            changeset_type = "CREATE"
            # When creating a new stack, UsePreviousValue=True is invalid.
            # For such parameters, users should either override with new value,
            # or set a Default value in template to successfully create a stack.
            parameter_values = [
                x for x in parameter_values
                if not x.get("UsePreviousValue", False)
            ]
        else:
            changeset_type = "UPDATE"
            # UsePreviousValue not valid if parameter is new
            summary = self._client.get_template_summary(StackName=stack_name)
            existing_parameters = [
                parameter["ParameterKey"]
                for parameter in summary["Parameters"]
            ]
            parameter_values = [
                x for x in parameter_values
                if not (x.get("UsePreviousValue", False)
                        and x["ParameterKey"] not in existing_parameters)
            ]

        # Each changeset will get a unique name based on time.
        # Description is also setup based on current date and that SAM CLI is used.
        kwargs = {
            "ChangeSetName":
            self.changeset_prefix + str(int(time.time())),
            "StackName":
            stack_name,
            "TemplateBody":
            cfn_template,
            "ChangeSetType":
            changeset_type,
            "Parameters":
            parameter_values,
            "Description":
            "Created by SAM CLI at {0} UTC".format(
                datetime.utcnow().isoformat()),
            "Tags":
            tags,
        }

        # If an S3 uploader is available, use TemplateURL to deploy rather than
        # TemplateBody. This is required for large templates.
        if s3_uploader:
            with mktempfile() as temporary_file:
                temporary_file.write(kwargs.pop("TemplateBody"))
                temporary_file.flush()

                # TemplateUrl property requires S3 URL to be in path-style format
                parts = S3Uploader.parse_s3_url(s3_uploader.upload_with_dedup(
                    temporary_file.name, "template"),
                                                version_property="Version")
                kwargs["TemplateURL"] = s3_uploader.to_path_style_s3_url(
                    parts["Key"], parts.get("Version", None))

        # don't set these arguments if not specified to use existing values
        if capabilities is not None:
            kwargs["Capabilities"] = capabilities
        if role_arn is not None:
            kwargs["RoleARN"] = role_arn
        if notification_arns is not None:
            kwargs["NotificationARNs"] = notification_arns
        return self._create_change_set(stack_name=stack_name,
                                       changeset_type=changeset_type,
                                       **kwargs)

    def _create_change_set(self, stack_name, changeset_type, **kwargs):
        try:
            resp = self._client.create_change_set(**kwargs)
            return resp, changeset_type
        except botocore.exceptions.ClientError as ex:
            if "The bucket you are attempting to access must be addressed using the specified endpoint" in str(
                    ex):
                raise DeployBucketInDifferentRegionError(
                    f"Failed to create/update stack {stack_name}") from ex
            raise ChangeSetError(stack_name=stack_name, msg=str(ex)) from ex

        except Exception as ex:
            LOG.debug("Unable to create changeset", exc_info=ex)
            raise ChangeSetError(stack_name=stack_name, msg=str(ex)) from ex

    @pprint_column_names(
        format_string=DESCRIBE_CHANGESET_FORMAT_STRING,
        format_kwargs=DESCRIBE_CHANGESET_DEFAULT_ARGS,
        table_header=DESCRIBE_CHANGESET_TABLE_HEADER_NAME,
    )
    def describe_changeset(self, change_set_id, stack_name, **kwargs):
        """
        Call Cloudformation to describe a changeset

        :param change_set_id: ID of the changeset
        :param stack_name: Name of the CloudFormation stack
        :param kwargs: Other arguments to pass to pprint_columns()
        :return: dictionary of changes described in the changeset.
        """
        paginator = self._client.get_paginator("describe_change_set")
        response_iterator = paginator.paginate(ChangeSetName=change_set_id,
                                               StackName=stack_name)
        changes = {"Add": [], "Modify": [], "Remove": []}
        changes_showcase = {
            "Add": "+ Add",
            "Modify": "* Modify",
            "Remove": "- Delete"
        }
        changeset = False
        for item in response_iterator:
            cf_changes = item.get("Changes")
            for change in cf_changes:
                changeset = True
                resource_props = change.get("ResourceChange")
                action = resource_props.get("Action")
                changes[action].append({
                    "LogicalResourceId":
                    resource_props.get("LogicalResourceId"),
                    "ResourceType":
                    resource_props.get("ResourceType"),
                    "Replacement":
                    "N/A" if resource_props.get("Replacement") is None else
                    resource_props.get("Replacement"),
                })

        for k, v in changes.items():
            for value in v:
                row_color = self.deploy_color.get_changeset_action_color(
                    action=k)
                pprint_columns(
                    columns=[
                        changes_showcase.get(k, k),
                        value["LogicalResourceId"],
                        value["ResourceType"],
                        value["Replacement"],
                    ],
                    width=kwargs["width"],
                    margin=kwargs["margin"],
                    format_string=DESCRIBE_CHANGESET_FORMAT_STRING,
                    format_args=kwargs["format_args"],
                    columns_dict=DESCRIBE_CHANGESET_DEFAULT_ARGS.copy(),
                    color=row_color,
                )

        if not changeset:
            # There can be cases where there are no changes,
            # but could be an an addition of a SNS notification topic.
            pprint_columns(
                columns=["-", "-", "-", "-"],
                width=kwargs["width"],
                margin=kwargs["margin"],
                format_string=DESCRIBE_CHANGESET_FORMAT_STRING,
                format_args=kwargs["format_args"],
                columns_dict=DESCRIBE_CHANGESET_DEFAULT_ARGS.copy(),
            )

        return changes

    def wait_for_changeset(self, changeset_id, stack_name):
        """
        Waits until the changeset creation completes

        :param changeset_id: ID or name of the changeset
        :param stack_name:   Stack name
        """
        sys.stdout.write("\nWaiting for changeset to be created..\n")
        sys.stdout.flush()

        # Wait for changeset to be created
        waiter = self._client.get_waiter("change_set_create_complete")
        # Poll every 5 seconds. Changeset creation should be fast
        waiter_config = {"Delay": 5}
        try:
            waiter.wait(ChangeSetName=changeset_id,
                        StackName=stack_name,
                        WaiterConfig=waiter_config)
        except botocore.exceptions.WaiterError as ex:

            resp = ex.last_response
            status = resp["Status"]
            reason = resp["StatusReason"]

            if (status == "FAILED"
                    and "The submitted information didn't contain changes."
                    in reason or "No updates are to be performed" in reason):
                raise deploy_exceptions.ChangeEmptyError(stack_name=stack_name)

            raise ChangeSetError(stack_name=stack_name,
                                 msg="ex: {0} Status: {1}. Reason: {2}".format(
                                     ex, status, reason)) from ex

    def execute_changeset(self, changeset_id, stack_name):
        """
        Calls CloudFormation to execute changeset

        :param changeset_id: ID of the changeset
        :param stack_name: Name or ID of the stack
        :return: Response from execute-change-set call
        """
        try:
            return self._client.execute_change_set(ChangeSetName=changeset_id,
                                                   StackName=stack_name)
        except botocore.exceptions.ClientError as ex:
            raise DeployFailedError(stack_name=stack_name, msg=str(ex)) from ex

    def get_last_event_time(self, stack_name):
        """
        Finds the last event time stamp thats present for the stack, if not get the current time
        :param stack_name: Name or ID of the stack
        :return: unix epoch
        """
        try:
            return utc_to_timestamp(
                self._client.describe_stack_events(
                    StackName=stack_name)["StackEvents"][0]["Timestamp"])
        except KeyError:
            return time.time()

    @pprint_column_names(
        format_string=DESCRIBE_STACK_EVENTS_FORMAT_STRING,
        format_kwargs=DESCRIBE_STACK_EVENTS_DEFAULT_ARGS,
        table_header=DESCRIBE_STACK_EVENTS_TABLE_HEADER_NAME,
    )
    def describe_stack_events(self, stack_name, time_stamp_marker, **kwargs):
        """
        Calls CloudFormation to get current stack events
        :param stack_name: Name or ID of the stack
        :param time_stamp_marker: last event time on the stack to start streaming events from.
        :param kwargs: Other arguments to pass to pprint_columns()
        """

        stack_change_in_progress = True
        events = set()
        retry_attempts = 0

        while stack_change_in_progress and retry_attempts <= self.max_attempts:
            try:
                # Only sleep if there have been no retry_attempts
                time.sleep(0 if retry_attempts else self.client_sleep)
                describe_stacks_resp = self._client.describe_stacks(
                    StackName=stack_name)
                paginator = self._client.get_paginator("describe_stack_events")
                response_iterator = paginator.paginate(StackName=stack_name)
                stack_status = describe_stacks_resp["Stacks"][0]["StackStatus"]
                latest_time_stamp_marker = time_stamp_marker
                for event_items in response_iterator:
                    for event in event_items["StackEvents"]:
                        if event["EventId"] not in events and utc_to_timestamp(
                                event["Timestamp"]) > time_stamp_marker:
                            events.add(event["EventId"])
                            latest_time_stamp_marker = max(
                                latest_time_stamp_marker,
                                utc_to_timestamp(event["Timestamp"]))
                            row_color = self.deploy_color.get_stack_events_status_color(
                                status=event["ResourceStatus"])
                            pprint_columns(
                                columns=[
                                    event["ResourceStatus"],
                                    event["ResourceType"],
                                    event["LogicalResourceId"],
                                    event.get("ResourceStatusReason", "-"),
                                ],
                                width=kwargs["width"],
                                margin=kwargs["margin"],
                                format_string=
                                DESCRIBE_STACK_EVENTS_FORMAT_STRING,
                                format_args=kwargs["format_args"],
                                columns_dict=DESCRIBE_STACK_EVENTS_DEFAULT_ARGS
                                .copy(),
                                color=row_color,
                            )
                        # Skip already shown old event entries
                        elif utc_to_timestamp(
                                event["Timestamp"]) <= time_stamp_marker:
                            time_stamp_marker = latest_time_stamp_marker
                            break
                    else:  # go to next loop if not break from inside loop
                        time_stamp_marker = latest_time_stamp_marker  # update marker if all events are new
                        continue
                    break  # reached here only if break from inner loop!

                if self._check_stack_complete(stack_status):
                    stack_change_in_progress = False
                    break
            except botocore.exceptions.ClientError as ex:
                retry_attempts = retry_attempts + 1
                if retry_attempts > self.max_attempts:
                    LOG.error("Describing stack events for %s failed: %s",
                              stack_name, str(ex))
                    return
                # Sleep in exponential backoff mode
                time.sleep(math.pow(self.backoff, retry_attempts))

    @staticmethod
    def _check_stack_complete(status: str) -> bool:
        return "COMPLETE" in status and "CLEANUP" not in status

    def wait_for_execute(self, stack_name, changeset_type):
        """
        Wait for changeset to execute and return when execution completes.
        If the stack has "Outputs," they will be printed.

        Parameters
        ----------
        stack_name : str
            The name of the stack
        changeset_type : str
            The type of the changeset, 'CREATE' or 'UPDATE'
        """
        sys.stdout.write("\n{} - Waiting for stack create/update "
                         "to complete\n".format(
                             datetime.now().strftime("%Y-%m-%d %H:%M:%S")))
        sys.stdout.flush()

        self.describe_stack_events(stack_name,
                                   self.get_last_event_time(stack_name))

        # Pick the right waiter
        if changeset_type == "CREATE":
            waiter = self._client.get_waiter("stack_create_complete")
        elif changeset_type == "UPDATE":
            waiter = self._client.get_waiter("stack_update_complete")
        else:
            raise RuntimeError(
                "Invalid changeset type {0}".format(changeset_type))

        # Poll every 30 seconds. Polling too frequently risks hitting rate limits
        # on CloudFormation's DescribeStacks API
        waiter_config = {"Delay": 30, "MaxAttempts": 120}

        try:
            waiter.wait(StackName=stack_name, WaiterConfig=waiter_config)
        except botocore.exceptions.WaiterError as ex:
            LOG.debug("Execute changeset waiter exception", exc_info=ex)

            raise deploy_exceptions.DeployFailedError(stack_name=stack_name,
                                                      msg=str(ex))

        outputs = self.get_stack_outputs(stack_name=stack_name, echo=False)
        if outputs:
            self._display_stack_outputs(outputs)

    def create_and_wait_for_changeset(self, stack_name, cfn_template,
                                      parameter_values, capabilities, role_arn,
                                      notification_arns, s3_uploader, tags):
        try:
            result, changeset_type = self.create_changeset(
                stack_name, cfn_template, parameter_values, capabilities,
                role_arn, notification_arns, s3_uploader, tags)
            self.wait_for_changeset(result["Id"], stack_name)
            self.describe_changeset(result["Id"], stack_name)
            return result, changeset_type
        except botocore.exceptions.ClientError as ex:
            raise DeployFailedError(stack_name=stack_name, msg=str(ex)) from ex

    @staticmethod
    @pprint_column_names(format_string=OUTPUTS_FORMAT_STRING,
                         format_kwargs=OUTPUTS_DEFAULTS_ARGS,
                         table_header=OUTPUTS_TABLE_HEADER_NAME)
    def _display_stack_outputs(stack_outputs: List[Dict], **kwargs) -> None:
        for counter, output in enumerate(stack_outputs):
            for k, v in [
                ("Key", output.get("OutputKey")),
                ("Description", output.get("Description", "-")),
                ("Value", output.get("OutputValue")),
            ]:
                pprint_columns(
                    columns=["{k:<{0}}{v:<{0}}".format(MIN_OFFSET, k=k, v=v)],
                    width=kwargs["width"],
                    margin=kwargs["margin"],
                    format_string=OUTPUTS_FORMAT_STRING,
                    format_args=kwargs["format_args"],
                    columns_dict=OUTPUTS_DEFAULTS_ARGS.copy(),
                    color="green",
                    replace_whitespace=False,
                    break_long_words=False,
                    drop_whitespace=False,
                )
            newline_per_item(stack_outputs, counter)

    def get_stack_outputs(self, stack_name, echo=True):
        try:
            stacks_description = self._client.describe_stacks(
                StackName=stack_name)
            try:
                outputs = stacks_description["Stacks"][0]["Outputs"]
                if echo:
                    sys.stdout.write("\nStack {stack_name} outputs:\n".format(
                        stack_name=stack_name))
                    sys.stdout.flush()
                    self._display_stack_outputs(stack_outputs=outputs)
                return outputs
            except KeyError:
                return None

        except botocore.exceptions.ClientError as ex:
            raise DeployStackOutPutFailedError(stack_name=stack_name,
                                               msg=str(ex)) from ex