├── .gitignore ├── README.md ├── cfn_graph ├── __init__.py ├── changeset.py ├── cli.py └── exceptions.py ├── requirements.txt ├── setup.py └── version.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | .static_storage/ 58 | .media/ 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFormation Graph 2 | 3 | ## Installation 4 | Requires python3 5 | 6 | - `python setup.py install` 7 | 8 | ## Usage 9 | You probably want to install dot / graphviz to turn the output into images. 10 | 11 | ### Change Sets 12 | ```bash 13 | # With the AWS CLI 14 | aws cloudformation describe-change-set --change-set-name $cs_name | cfn-graph | dot -Tpng > output.png 15 | 16 | # When copying from the console 17 | echo $copied_from_console | cfn-graph --wrap changeset --console | dot -Tpng > output.png 18 | ``` 19 | 20 | -------------------------------------------------------------------------------- /cfn_graph/__init__.py: -------------------------------------------------------------------------------- 1 | def capitalize_keys(o): 2 | """TODO: should move to utils""" 3 | 4 | if isinstance(o, dict): 5 | not_string = [k for k in o if not isinstance(k, str)] 6 | if not_string: 7 | print(not_string) 8 | raise AttributeError('Keys should be strings for capitalization to work') 9 | # Recurse over dicts 10 | return {_cap_first(k): capitalize_keys(v) for k, v in o.items()} 11 | if isinstance(o, list): 12 | # recurse over lists: 13 | return [capitalize_keys(v) for v in o] 14 | # stop 15 | return o 16 | 17 | 18 | def _cap_first(s:str) -> str: 19 | if len(s) <=1 : 20 | return s.upper() 21 | return s[0].upper() + s[1:] 22 | -------------------------------------------------------------------------------- /cfn_graph/changeset.py: -------------------------------------------------------------------------------- 1 | from graphviz import Digraph 2 | 3 | from .exceptions import UnknownChangeTypeException, UnknownChangeSourceException, UnknownTargetAttributeException 4 | 5 | 6 | class ChangeSetGraph(object): 7 | action_colors = { 8 | 'Add': 'green', 9 | 'Modify': 'orange', 10 | 'Remove': 'red', 11 | } 12 | replacement_colors = { 13 | None: 'grey', 14 | 'False': 'green', 15 | 'Conditional': 'orange', 16 | 'True': 'red', 17 | } 18 | node_shape = 'box' 19 | node_style = 'filled' 20 | 21 | evaluation_style = { 22 | 'Static': 'solid', 23 | 'Dynamic': 'dashed', 24 | } 25 | 26 | requires_recreation_color = { 27 | 'Never': 'green', 28 | 'Conditionally': 'orange', 29 | 'Always': 'red', 30 | } 31 | 32 | def __init__(self, input_dict: dict, include_type=False, include_id=False): 33 | self.changes = input_dict['Changes'] 34 | self.include_type = include_type 35 | self.include_id = include_id 36 | self._graph = Digraph() 37 | 38 | def graph(self) -> Digraph: 39 | for change in self.changes: 40 | if change['Type'] == 'Resource': 41 | self._resource_change(change['ResourceChange']) 42 | else: 43 | raise UnknownChangeTypeException 44 | return self._graph 45 | 46 | def _resource_change(self, change: dict) -> None: 47 | name = change['LogicalResourceId'] 48 | 49 | label_elements = [name] 50 | if self.include_type: 51 | label_elements.append(change['ResourceType']) 52 | if self.include_id: 53 | x = change.get('PhysicalResourceId') # can be none or not present on Add 54 | if x: 55 | label_elements.append(x) 56 | 57 | attributes = { 58 | 'fillcolor': self.action_colors[change['Action']], 59 | 'color': self.replacement_colors[change.get('Replacement')], 60 | 'shape': self.node_shape, 61 | 'style': self.node_style, 62 | 'label': '\n'.join(label_elements) 63 | } 64 | 65 | self._graph.node(name, **attributes) 66 | for detail in change.get('Details', []): # only on Modify 67 | self._resource_change_detail(detail, name) 68 | 69 | def _resource_change_detail(self, detail: dict, node_name: str) -> None: 70 | # Target, CausingEntity 71 | 72 | change_source = detail['ChangeSource'] 73 | target = detail['Target'] 74 | 75 | if change_source == 'ResourceReference': 76 | from_node = detail['CausingEntity'] 77 | from_label = '' 78 | elif change_source == 'ParameterReference': 79 | from_node = detail['CausingEntity'] 80 | from_label = '' 81 | elif change_source == 'ResourceAttribute': 82 | from_node = detail['CausingEntity'].split('.')[0] 83 | from_label = detail['CausingEntity'].split('.')[1] 84 | elif change_source == 'DirectModification': 85 | from_node = '__Direct__' 86 | from_label = '' 87 | elif change_source == 'Automatic': # nested stacks 88 | from_node = detail['CausingEntity'] 89 | from_label = '__Automatic__' 90 | else: 91 | raise UnknownChangeSourceException() 92 | 93 | if target['Attribute'] == 'Properties': 94 | to_label = target['Name'] 95 | elif target['Attribute'] == 'Metadata': 96 | to_label = '_Metadata_' 97 | elif target['Attribute'] == 'Tags': 98 | to_label = '_Tags_' 99 | else: 100 | raise UnknownTargetAttributeException() 101 | 102 | attrbutes = { 103 | 'style': self.evaluation_style[detail['Evaluation']], 104 | 'color': self.requires_recreation_color[target['RequiresRecreation']], 105 | 'taillabel': from_label, 106 | 'headlabel': to_label, 107 | } 108 | 109 | self._graph.edge(from_node, node_name, **attrbutes) 110 | -------------------------------------------------------------------------------- /cfn_graph/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import sys 4 | 5 | from . import capitalize_keys 6 | from .changeset import ChangeSetGraph 7 | from .exceptions import UnknownInputException, UnknownWrapTypeException 8 | 9 | 10 | def main(): 11 | parser = argparse.ArgumentParser() 12 | parser.add_argument( 13 | '-c', '--console', 14 | help='input copied from the AWS console', 15 | action='store_true' 16 | ) 17 | parser.add_argument( 18 | '-w', '--wrap', 19 | help='Type to wrap input in, allowed values are "changeset', 20 | ) 21 | args = parser.parse_args() 22 | 23 | input_dict = json.load(sys.stdin) 24 | 25 | if args.console: 26 | # Console changes keys: 27 | input_dict = capitalize_keys(input_dict) 28 | 29 | if args.wrap: 30 | if args.wrap == 'changeset': 31 | input_dict = { 32 | 'ChangeSetName': 'cli-input', 33 | 'Changes': input_dict, 34 | } 35 | else: 36 | raise UnknownWrapTypeException() 37 | 38 | if 'ChangeSetName' in input_dict: 39 | graph = ChangeSetGraph(input_dict) 40 | else: 41 | raise UnknownInputException() 42 | 43 | print(graph.graph()) 44 | return 45 | -------------------------------------------------------------------------------- /cfn_graph/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnknownInputException(Exception): 2 | pass 3 | 4 | 5 | class UnknownChangeTypeException(Exception): 6 | pass 7 | 8 | 9 | class UnknownChangeSourceException(Exception): 10 | pass 11 | 12 | 13 | class UnknownTargetAttributeException(Exception): 14 | pass 15 | 16 | 17 | class UnknownWrapTypeException(Exception): 18 | pass 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | graphviz 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | def get_version(): 5 | with open('version.txt', 'r') as fh: 6 | return fh.read().strip() 7 | 8 | 9 | setup( 10 | name='cfn-graph', 11 | version=get_version(), 12 | description="Tool to turn CloudFormation related resources into graphs", 13 | author='Ben Bridts', 14 | author_email='ben.bridts@gmail.com', 15 | url='', # todo 16 | packages=['cfn_graph'], 17 | entry_points={ 18 | 'console_scripts': [ 19 | 'cfn-graph = cfn_graph.cli:main', 20 | ] 21 | }, 22 | install_requires=[ 23 | 'graphviz', 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | --------------------------------------------------------------------------------