From 60252b640bccae94a990a46eb962f0e79f45d07f Mon Sep 17 00:00:00 2001 From: Florian Motlik Date: Fri, 24 Mar 2017 08:25:27 +0100 Subject: [PATCH] Implement diff command --- build-requirements.txt | 1 + formica/aws_base.py | 6 ++ formica/cli.py | 15 ++++- formica/diff.py | 81 +++++++++++++++++++++++ formica/loader.py | 2 +- setup.py | 3 +- tests/integration/test_basic.py | 8 +++ tests/unit/test_diff.py | 112 ++++++++++++++++++++++++++++++++ 8 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 formica/aws_base.py create mode 100644 formica/diff.py create mode 100644 tests/unit/test_diff.py diff --git a/build-requirements.txt b/build-requirements.txt index 6c13238..d5bad72 100644 --- a/build-requirements.txt +++ b/build-requirements.txt @@ -7,5 +7,6 @@ pytest path.py pytest-cov mock +pytest-mock setuptools pip \ No newline at end of file diff --git a/formica/aws_base.py b/formica/aws_base.py new file mode 100644 index 0000000..a1300f7 --- /dev/null +++ b/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') diff --git a/formica/cli.py b/formica/cli.py index c74a3b5..443d6a3 100755 --- a/formica/cli.py +++ b/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 @@ -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): @@ -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) diff --git a/formica/diff.py b/formica/diff.py new file mode 100644 index 0000000..e2b41fe --- /dev/null +++ b/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) diff --git a/formica/loader.py b/formica/loader.py index e84c781..bc7d3da 100644 --- a/formica/loader.py +++ b/formica/loader.py @@ -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, diff --git a/setup.py b/setup.py index 71581ea..9e1e157 100644 --- a/setup.py +++ b/setup.py @@ -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', diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index 87a0439..3dfa3ae 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -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 diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py new file mode 100644 index 0000000..5df4860 --- /dev/null +++ b/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)