Skip to content

Commit

Permalink
[Resolve #1169] Add detect-stack-drift command (#1170)
Browse files Browse the repository at this point in the history
Add a `detect-stack-drift` command and tests.

The `detect-stack-drift` command calls the `detect_stack_drift`
Boto3 call, takes the Detector Id returned, then waits for
`describe_stack_drift_detection_status(StackDriftDetectionId=detector_id)`
to complete, and then finally returns
`describe_stack_resource_drifts` as a JSON document.

If `--debug` is passed, sceptre also will provide feedback on
the detection progress.
  • Loading branch information
alexharv074 committed Jan 30, 2022
1 parent d6ecbd2 commit 520b0fe
Show file tree
Hide file tree
Showing 13 changed files with 651 additions and 14 deletions.
34 changes: 34 additions & 0 deletions integration-tests/features/drift.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Feature: Drift Detection
Scenario: Detects no drift on a stack with no drift
Given stack "drift-single/A" exists using "topic.yaml"
When the user detects drift on stack "drift-single/A"
Then stack drift status is "IN_SYNC"

Scenario: Shows no drift on a stack that with no drift
Given stack "drift-single/A" exists using "topic.yaml"
When the user shows drift on stack "drift-single/A"
Then stack resource drift status is "IN_SYNC"

Scenario: Detects drift on a stack that has drifted
Given stack "drift-single/A" exists using "topic.yaml"
And a topic configuration in stack "drift-single/A" has drifted
When the user detects drift on stack "drift-single/A"
Then stack drift status is "DRIFTED"

Scenario: Shows drift on a stack that has drifted
Given stack "drift-single/A" exists using "topic.yaml"
And a topic configuration in stack "drift-single/A" has drifted
When the user shows drift on stack "drift-single/A"
Then stack resource drift status is "MODIFIED"

Scenario: Detects drift on a stack group that partially exists
Given stack "drift-group/A" exists using "topic.yaml"
And stack "drift-group/B" does not exist
And a topic configuration in stack "drift-group/A" has drifted
When the user detects drift on stack_group "drift-group"
Then stack_group drift statuses are each one of "DRIFTED,STACK_DOES_NOT_EXIST"

Scenario: Does not blow up on a stack group that doesn't exist
Given stack_group "drift-group" does not exist
When the user detects drift on stack_group "drift-group"
Then stack_group drift statuses are each one of "STACK_DOES_NOT_EXIST,STACK_DOES_NOT_EXIST"
3 changes: 3 additions & 0 deletions integration-tests/sceptre-project/config/drift-group/A.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
template:
path: loggroup.yaml
type: file
3 changes: 3 additions & 0 deletions integration-tests/sceptre-project/config/drift-group/B.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
template:
path: loggroup.yaml
type: file
3 changes: 3 additions & 0 deletions integration-tests/sceptre-project/config/drift-single/A.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
template:
path: loggroup.yaml
type: file
11 changes: 11 additions & 0 deletions integration-tests/sceptre-project/templates/topic.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Resources:
Topic:
Type: AWS::SNS::Topic
Properties:
DisplayName: MyTopic

Outputs:
TopicName:
Value: !Ref Topic
Export:
Name: !Sub "${AWS::StackName}-TopicName"
77 changes: 77 additions & 0 deletions integration-tests/steps/drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from behave import *
from helpers import get_cloudformation_stack_name

import boto3

from sceptre.plan.plan import SceptrePlan
from sceptre.context import SceptreContext


@given('a topic configuration in stack "{stack_name}" has drifted')
def step_impl(context, stack_name):
full_name = get_cloudformation_stack_name(context, stack_name)
topic_arn = _get_output("TopicName", full_name)
client = boto3.client("sns")
client.set_topic_attributes(
TopicArn=topic_arn,
AttributeName="DisplayName",
AttributeValue="WrongName"
)


def _get_output(output_name, stack_name):
client = boto3.client("cloudformation")
response = client.describe_stacks(StackName=stack_name)
for output in response["Stacks"][0]["Outputs"]:
if output["OutputKey"] == output_name:
return output["OutputValue"]


@when('the user detects drift on stack "{stack_name}"')
def step_impl(context, stack_name):
sceptre_context = SceptreContext(
command_path=stack_name + '.yaml',
project_path=context.sceptre_dir
)
sceptre_plan = SceptrePlan(sceptre_context)
values = sceptre_plan.drift_detect().values()
context.output = list(values)


@when('the user shows drift on stack "{stack_name}"')
def step_impl(context, stack_name):
sceptre_context = SceptreContext(
command_path=stack_name + '.yaml',
project_path=context.sceptre_dir
)
sceptre_plan = SceptrePlan(sceptre_context)
values = sceptre_plan.drift_show().values()
context.output = list(values)


@when('the user detects drift on stack_group "{stack_group_name}"')
def step_impl(context, stack_group_name):
sceptre_context = SceptreContext(
command_path=stack_group_name,
project_path=context.sceptre_dir
)
sceptre_plan = SceptrePlan(sceptre_context)
values = sceptre_plan.drift_detect().values()
context.output = list(values)


@then('stack drift status is "{desired_status}"')
def step_impl(context, desired_status):
assert context.output[0]["StackDriftStatus"] == desired_status


@then('stack resource drift status is "{desired_status}"')
def step_impl(context, desired_status):
assert context.output[0][1]["StackResourceDrifts"][0]["StackResourceDriftStatus"] == desired_status


@then('stack_group drift statuses are each one of "{statuses}"')
def step_impl(context, statuses):
status_list = [status.strip() for status in statuses.split(",")]
for output in context.output:
assert output["StackDriftStatus"] in status_list
8 changes: 6 additions & 2 deletions sceptre/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
from sceptre.cli.delete import delete_command
from sceptre.cli.launch import launch_command
from sceptre.cli.diff import diff_command
from sceptre.cli.drift import drift_group
from sceptre.cli.execute import execute_command
from sceptre.cli.describe import describe_group
from sceptre.cli.list import list_group
from sceptre.cli.policy import set_policy_command
from sceptre.cli.status import status_command
from sceptre.cli.template import (validate_command, generate_command,
from sceptre.cli.helpers import catch_exceptions, setup_vars

from sceptre.cli.template import (validate_command,
generate_command,
estimate_cost_command,
fetch_remote_template_command)
from sceptre.cli.helpers import catch_exceptions, setup_vars


@click.group()
Expand Down Expand Up @@ -86,3 +89,4 @@ def cli(
cli.add_command(describe_group)
cli.add_command(fetch_remote_template_command)
cli.add_command(diff_command)
cli.add_command(drift_group)
106 changes: 106 additions & 0 deletions sceptre/cli/drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import click
from click import Context

from sceptre.context import SceptreContext
from sceptre.plan.plan import SceptrePlan

from sceptre.cli.helpers import (
catch_exceptions,
deserialize_json_properties,
write
)

BAD_STATUSES = [
"DETECTION_FAILED",
"TIMED_OUT"
]


@click.group(name="drift")
def drift_group():
"""
Commands for calling drift detection.
"""
pass


@drift_group.command(name="detect", short_help="Run detect stack drift on running stacks.")
@click.argument("path")
@click.pass_context
@catch_exceptions
def drift_detect(ctx: Context, path: str):
"""
Detect stack drift and return stack drift status.
In the event that the stack does not exist, we return
a DetectionStatus and StackDriftStatus of STACK_DOES_NOT_EXIST.
In the event that drift detection times out, we return
a DetectionStatus and StackDriftStatus of TIMED_OUT.
The timeout is set at 5 minutes, a value that cannot be configured.
"""
context = SceptreContext(
command_path=path,
project_path=ctx.obj.get("project_path"),
user_variables=ctx.obj.get("user_variables"),
options=ctx.obj.get("options"),
output_format=ctx.obj.get("output_format"),
ignore_dependencies=ctx.obj.get("ignore_dependencies")
)

plan = SceptrePlan(context)
responses = plan.drift_detect()

output_format = "json" if context.output_format == "json" else "yaml"

exit_status = 0
for stack, response in responses.items():
status = response["DetectionStatus"]
if status in BAD_STATUSES:
exit_status += 1
for key in ["Timestamp", "ResponseMetadata"]:
response.pop(key, None)
write({stack.external_name: deserialize_json_properties(response)}, output_format)

exit(exit_status)


@drift_group.command(name="show", short_help="Shows stack drift on running stacks.")
@click.argument("path")
@click.pass_context
@catch_exceptions
def drift_show(ctx, path):
"""
Show stack drift on deployed stacks.
In the event that the stack does not exist, we return
a StackResourceDriftStatus of STACK_DOES_NOT_EXIST.
In the event that drift detection times out, we return
a StackResourceDriftStatus of TIMED_OUT.
The timeout is set at 5 minutes, a value that cannot be configured.
"""
context = SceptreContext(
command_path=path,
project_path=ctx.obj.get("project_path"),
user_variables=ctx.obj.get("user_variables"),
options=ctx.obj.get("options"),
output_format=ctx.obj.get("output_format"),
ignore_dependencies=ctx.obj.get("ignore_dependencies")
)

plan = SceptrePlan(context)
responses = plan.drift_show()

output_format = "json" if context.output_format == "json" else "yaml"

exit_status = 0
for stack, (status, response) in responses.items():
if status in BAD_STATUSES:
exit_status += 1
response.pop("ResponseMetadata", None)
write({stack.external_name: deserialize_json_properties(response)}, output_format)

exit(exit_status)
40 changes: 35 additions & 5 deletions sceptre/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,27 +113,34 @@ def _generate_json(stream):


def _generate_yaml(stream):
kwargs = {
"default_flow_style": False,
"explicit_start": True
}

if isinstance(stream, list):
items = []
for item in stream:
try:
if isinstance(item, dict):
items.append(
yaml.safe_dump(item, default_flow_style=False, explicit_start=True)
yaml.safe_dump(item, **kwargs)
)
else:
items.append(
yaml.safe_dump(
yaml.load(item, Loader=CfnYamlLoader),
default_flow_style=False, explicit_start=True
yaml.load(item, Loader=CfnYamlLoader), **kwargs
)
)
except Exception:
print("An error occured whilst writing the YAML object.")
return yaml.safe_dump(
[yaml.load(item, Loader=CfnYamlLoader) for item in items],
default_flow_style=False, explicit_start=True
[yaml.load(item, Loader=CfnYamlLoader) for item in items], **kwargs
)

elif isinstance(stream, dict):
return yaml.dump(stream, **kwargs)

else:
try:
return yaml.safe_loads(stream)
Expand Down Expand Up @@ -354,6 +361,29 @@ def simplify_change_set_description(response):
return formatted_response


def deserialize_json_properties(value):
if isinstance(value, str):
is_json = (
(value.startswith("{") and value.endswith("}"))
or
(value.startswith("[") and value.endswith("]"))
)
if is_json:
return json.loads(value)
return value
if isinstance(value, dict):
return {
key: deserialize_json_properties(val)
for key, val in value.items()
}
if isinstance(value, list):
return [
deserialize_json_properties(item)
for item in value
]
return value


class ColouredFormatter(logging.Formatter):
"""
ColouredFormatter add colours to all stack statuses that appear in log
Expand Down

0 comments on commit 520b0fe

Please sign in to comment.