Skip to content

Commit

Permalink
Implement diff command
Browse files Browse the repository at this point in the history
  • Loading branch information
flomotlik committed Mar 27, 2017
1 parent 6617821 commit 60252b6
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 5 deletions.
1 change: 1 addition & 0 deletions build-requirements.txt
Expand Up @@ -7,5 +7,6 @@ pytest
path.py
pytest-cov
mock
pytest-mock
setuptools
pip
6 changes: 6 additions & 0 deletions formica/aws_base.py
@@ -0,0 +1,6 @@
class AWSBase(object):
def __init__(self, session):
self.session = session

def cf_client(self):
return self.session.client('cloudformation')
15 changes: 12 additions & 3 deletions formica/cli.py
@@ -1,13 +1,13 @@
#!/usr/bin/env python

import logging

import click
import logging
from texttable import Texttable

from formica import CHANGE_SET_FORMAT
from formica.aws import AWS
from formica.change_set import ChangeSet
from formica.diff import Diff
from formica.helper import aws_exceptions, session_wrapper
from formica.stack_waiter import StackWaiter
from .loader import Loader
Expand Down Expand Up @@ -174,7 +174,7 @@ def remove(stack):


@main.command()
@stack('The stack see the resources for.')
@stack('The stack to see the resources for.')
@aws_exceptions
@aws_options
def resources(stack):
Expand All @@ -195,3 +195,12 @@ def resources(stack):
])

click.echo(table.draw() + "\n")


@main.command()
@stack('The stack to diff with.')
@aws_exceptions
@aws_options
def diff(stack):
"""Print a diff between the local and deployed template"""
Diff(AWS.current_session()).run(stack)
81 changes: 81 additions & 0 deletions formica/diff.py
@@ -0,0 +1,81 @@
import collections
import re
from deepdiff import DeepDiff
from texttable import Texttable

from formica.aws_base import AWSBase
from formica.loader import Loader
import click

try:
basestring
except NameError:
basestring = str


class Change():
def __init__(self, path, before, after, type):
self.path = path
self.before = before
self.after = after
self.type = type


def convert(data):
if isinstance(data, basestring):
return str(data)
elif isinstance(data, collections.Mapping):
return dict(map(convert, data.items()))
elif isinstance(data, collections.Iterable):
return type(data)(map(convert, data))
else:
return data


class Diff(AWSBase):
def __init__(self, session):
super(Diff, self).__init__(session)

def run(self, stack):
client = self.cf_client()

result = client.get_template(
StackName=stack,
)

loader = Loader()
loader.load()

changes = DeepDiff(convert(result['TemplateBody']), convert(loader.template_dictionary()), ignore_order=False,
report_repetition=True,
verbose_level=2, view='tree')

table = Texttable(max_width=200)
table.add_rows([['Path', 'From', 'To', 'Change Type']])
print_diff = False

processed_changes = self.__collect_changes(changes)

for change in processed_changes:
print_diff = True
path = re.findall("\['?(\w+)'?\]", change.path)
table.add_row(
[
' > '.join(path),
change.before,
change.after,
change.type.title().replace('_', ' ')
]
)

if print_diff:
click.echo(table.draw() + "\n")
else:
click.echo('No Changes found')

def __collect_changes(self, changes):
results = []
for key, value in changes.items():
for change in list(value):
results.append(Change(path=change.path(), before=change.t1, after=change.t2, type=key))
return sorted(results, key=lambda x: x.path)
2 changes: 1 addition & 1 deletion formica/loader.py
Expand Up @@ -61,7 +61,7 @@ def __init__(self, path='.', file='*', variables=None):
self.cftemplate = {}
self.path = path
self.file = file
self.env = Environment(loader=FileSystemLoader(path))
self.env = Environment(loader=FileSystemLoader(path, followlinks=True))
self.env.filters.update({
'code_escape': code_escape,
'mandatory': mandatory,
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Expand Up @@ -38,7 +38,8 @@
],
keywords='cloudformation, aws, cloud',
packages=['formica'],
install_requires=['boto3==1.4.4', 'click==6.7', 'texttable==0.8.7', 'jinja2==2.9.5', 'pyyaml'],
install_requires=['boto3==1.4.4', 'click==6.7', 'texttable==0.8.7', 'jinja2==2.9.5', 'pyyaml==3.12',
'deepdiff==3.1.2'],
entry_points={
'console_scripts': [
'formica=formica.cli:main',
Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_basic.py
Expand Up @@ -36,6 +36,14 @@ def run_formica(*args):

f.write(json.dumps({'Resources': {'TestNameUpdate': {'Type': 'AWS::S3::Bucket'}}}))

# Diff the current stack
diff = run_formica('diff', *stack_args)
print(diff)
assert 'Resources > TestName' in diff
assert 'Dictionary Item Removed' in diff
assert 'Resources > TestNameUpdate' in diff
assert 'Dictionary Item Added' in diff

# Change Resources in existing stack
change = run_formica('change', *stack_args)
assert 'TestNameUpdate' in change
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/test_diff.py
@@ -0,0 +1,112 @@
import pytest
import re
from click.testing import CliRunner
from formica import cli

from formica.diff import Diff
from tests.unit.constants import STACK


@pytest.fixture
def client(mocker):
return mocker.Mock()


@pytest.fixture
def session(client, mocker):
mock = mocker.Mock()
mock.client.return_value = client
return mock


@pytest.fixture
def diff(session):
return Diff(session)


@pytest.fixture
def loader(mocker):
return mocker.patch('formica.diff.Loader')


def template_return(client, template):
client.get_template.return_value = {'TemplateBody': template}


def loader_return(loader, template):
loader.return_value.template_dictionary.return_value = template


def check_echo(click, args):
print(click.echo.call_args[0][0])
regex = '\s+\|\s+'.join(args)
assert re.search(regex, click.echo.call_args[0][0])


@pytest.fixture
def click(mocker):
return mocker.patch('formica.diff.click')


def test_unicode_string_no_diff(loader, client, diff, click):
loader_return(loader, {'Resources': u'1234'})
template_return(client, {'Resources': '1234'})
diff.run(STACK)
click.echo.assert_called_with('No Changes found')


def test_values_changed(loader, client, diff, click):
template_return(client, {'Resources': '1234'})
loader_return(loader, {'Resources': '5678'})
diff.run(STACK)
check_echo(click, ['Resources', '1234', '5678', 'Values Changed'])


def test_dictionary_item_added(loader, client, diff, click):
loader_return(loader, {'Resources': '5678'})
template_return(client, {})
diff.run(STACK)
check_echo(click, ['Resources', 'Not Present', '5678', 'Dictionary Item Added'])


def test_dictionary_item_removed(loader, client, diff, click):
loader_return(loader, {})
template_return(client, {'Resources': '5678'})
diff.run(STACK)
check_echo(click, ['Resources', '5678', 'Not Present', 'Dictionary Item Removed'])


def test_type_changed(loader, client, diff, click):
template_return(client, {'Resources': 'abcde'})
loader_return(loader, {'Resources': 5})
diff.run(STACK)
check_echo(click, ['Resources', 'abcde', '5', 'Type Changes'])


def test_iterable_item_added(loader, client, diff, click):
template_return(client, {'Resources': [1]})
loader_return(loader, {'Resources': [1, 2]})
diff.run(STACK)
check_echo(click, ['Resources > 1', 'Not Present', '2', 'Iterable Item Added'])


def test_iterable_item_removed(loader, client, diff, click):
template_return(client, {'Resources': [1, 2]})
loader_return(loader, {'Resources': [1]})
diff.run(STACK)
check_echo(click, ['Resources > 1', '2', 'Not Present', 'Iterable Item Removed'])


def test_diff_cli_call(mocker, session):
aws = mocker.patch('formica.cli.AWS')
aws.current_session.return_value = session
print(session)

diff = mocker.patch('formica.cli.Diff')

result = CliRunner().invoke(cli.diff, ['--stack', STACK])

assert result.exit_code == 0

diff.assert_called_with(session)
diff.return_value.run.assert_called_with(STACK)

0 comments on commit 60252b6

Please sign in to comment.