├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── bin └── blast-radius ├── blastradius ├── __init__.py ├── graph.py ├── handlers │ ├── __init__.py │ ├── apply.py │ ├── dot.py │ ├── plan.py │ └── terraform.py ├── server │ ├── __init__.py │ ├── server.py │ ├── static │ │ ├── css │ │ │ ├── bootstrap.min.css │ │ │ ├── selectize.css │ │ │ └── style.css │ │ ├── example │ │ │ ├── demo-1 │ │ │ │ ├── demo-1.json │ │ │ │ └── demo-1.svg │ │ │ ├── demo-2 │ │ │ │ ├── demo-2.json │ │ │ │ └── demo-2.svg │ │ │ └── demo-3 │ │ │ │ ├── demo-3.json │ │ │ │ └── demo-3.svg │ │ └── js │ │ │ ├── blast-radius.js │ │ │ ├── bootstrap.min.js │ │ │ ├── categories.js │ │ │ ├── categories.json │ │ │ ├── d3-tip.js │ │ │ ├── d3.v4.js │ │ │ ├── d3.v4.min.js │ │ │ ├── fontawesome-all.min.js │ │ │ ├── jquery.slim.min.js │ │ │ ├── selectize.js │ │ │ └── svg-pan-zoom.js │ └── templates │ │ ├── error.html │ │ └── index.html └── util.py ├── doc ├── blast-radius-demo.svg ├── blastradius-interactive.png └── embedded.md ├── docker-entrypoint.sh ├── examples └── docker-compose.yml ├── requirements.txt ├── setup.py └── utilities └── providers ├── provider-category-json.py └── requirements.txt /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | aliases: 3 | - &setup_remote_docker 4 | setup_remote_docker: 5 | version: 18.09.3 6 | docker_layer_caching: true 7 | - &docker_login 8 | run: echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_LOGIN" --password-stdin 9 | executors: 10 | buildpack: 11 | environment: 12 | IMAGE_NAME: $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME 13 | TF_VERSION: 0.12.12 14 | docker: 15 | - image: circleci/buildpack-deps 16 | jobs: 17 | build: 18 | executor: buildpack 19 | steps: 20 | - checkout 21 | - *setup_remote_docker 22 | - run: docker build --build-arg TF_VERSION=$TF_VERSION -t $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME . 23 | - run: docker save -o image.tar $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME 24 | - persist_to_workspace: 25 | root: . 26 | paths: 27 | - ./image.tar 28 | release: 29 | executor: buildpack 30 | steps: 31 | - attach_workspace: 32 | at: /tmp/workspace 33 | - *setup_remote_docker 34 | - *docker_login 35 | - run: docker load -i /tmp/workspace/image.tar 36 | - run: docker tag $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME:$TF_VERSION 37 | - run: |- 38 | docker push $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME:latest 39 | docker push $CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME:$TF_VERSION 40 | 41 | workflows: 42 | version: 2 43 | builder: 44 | jobs: 45 | - build 46 | - release: 47 | requires: 48 | - build 49 | filters: 50 | branches: 51 | only: master 52 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | doc 3 | providers/ 4 | LICENSE 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG TF_VERSION=0.12.12 2 | ARG PYTHON_VERSION=3.7 3 | 4 | FROM hashicorp/terraform:$TF_VERSION AS terraform 5 | 6 | FROM python:$PYTHON_VERSION-alpine 7 | RUN pip install -U pip ply \ 8 | && apk add --update --no-cache graphviz ttf-freefont 9 | 10 | COPY --from=terraform /bin/terraform /bin/terraform 11 | COPY ./docker-entrypoint.sh /bin/docker-entrypoint.sh 12 | RUN chmod +x /bin/docker-entrypoint.sh 13 | 14 | WORKDIR /src 15 | COPY . . 16 | RUN pip install -e . 17 | 18 | WORKDIR /data 19 | 20 | ENTRYPOINT ["/bin/docker-entrypoint.sh"] 21 | CMD ["blast-radius", "--serve"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Patrick McMurchie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include * 2 | include blastradius/server/static/css/* 3 | include blastradius/server/static/js/* 4 | include blastradius/server/example/demo-1/* 5 | include blastradius/server/example/demo-2/* 6 | include blastradius/server/example/demo-3/* 7 | include blastradius/server/templates/* 8 | include blastradius/doc/* 9 | include blastradius/utilities/providers/* 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # make clean 3 | # make 4 | # make publish 5 | # 6 | # TODO: add tests. 7 | # 8 | 9 | CATEGORIES_JSON = ./blastradius/server/static/js/categories.json 10 | CATEGORIES_JS = ./blastradius/server/static/js/categories.js 11 | 12 | .PHONY: clean 13 | clean: 14 | -find . -type d -name __pycache__ -exec rm -r {} \+ 15 | -rm $(CATEGORIES_JSON) 16 | -rm $(CATEGORIES_JS) 17 | 18 | # build pypi package 19 | .PHONY: dist 20 | dist: 21 | -python3 setup.py sdist 22 | 23 | # build docker image 24 | .PHONY: docker 25 | docker: 26 | -docker build -t 28mm/blast-radius . 27 | 28 | # push pypi and docker images to public repos 29 | .PHONY: publish 30 | publish: 31 | -twine upload dist/* 32 | -docker push 28mm/blast-radius:latest 33 | 34 | # rebuild categories.js from upstream docs 35 | .PHONY: categories 36 | categories: $(CATEGORIES_JS) 37 | 38 | $(CATEGORIES_JSON): 39 | -./utilities/providers/provider-category-json.py > $(CATEGORIES_JSON).new && mv $(CATEGORIES_JSON).new $(CATEGORIES_JSON) 40 | 41 | $(CATEGORIES_JS): $(CATEGORIES_JSON) 42 | -sed -e '1s/{/resource_groups \= {/' $(CATEGORIES_JSON) > $(CATEGORIES_JS).new && mv $(CATEGORIES_JS).new $(CATEGORIES_JS) 43 | 44 | # probably best to clean 1st 45 | all: categories dist docker -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blast Radius 2 | 3 | [![CircleCI](https://circleci.com/gh/28mm/blast-radius/tree/master.svg?style=svg)](https://circleci.com/gh/28mm/blast-radius/tree/master) 4 | [![PyPI version](https://badge.fury.io/py/BlastRadius.svg)](https://badge.fury.io/py/BlastRadius) 5 | 6 | [terraform]: https://www.terraform.io/ 7 | [examples]: https://28mm.github.io/blast-radius-docs/ 8 | 9 | _Blast Radius_ is a tool for reasoning about [Terraform][] dependency graphs 10 | with interactive visualizations. 11 | 12 | Use _Blast Radius_ to: 13 | 14 | * __Learn__ about *Terraform* or one of its providers through real [examples][] 15 | * __Document__ your infrastructure 16 | * __Reason__ about relationships between resources and evaluate changes to them 17 | * __Interact__ with the diagram below (and many others) [in the docs][examples] 18 | 19 | ![screenshot](doc/blastradius-interactive.png) 20 | 21 | ## Prerequisites 22 | 23 | * [Graphviz](https://www.graphviz.org/) 24 | * [Python](https://www.python.org/) 3.7 or newer 25 | 26 | > __Note:__ For macOS you can `brew install graphviz` 27 | 28 | ## Quickstart 29 | 30 | The fastest way to get up and running with *Blast Radius* is to install it with 31 | `pip` to your pre-existing environment: 32 | 33 | ```sh 34 | pip install blastradius 35 | ``` 36 | 37 | Once installed just point *Blast Radius* at any initialized *Terraform* 38 | directory: 39 | 40 | ```sh 41 | blast-radius --serve /path/to/terraform/directory 42 | ``` 43 | 44 | And you will shortly be rewarded with a browser link http://127.0.0.1:5000/. 45 | 46 | ## Docker 47 | 48 | [privileges]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities 49 | [overlayfs]: https://wiki.archlinux.org/index.php/Overlay_filesystem 50 | 51 | To launch *Blast Radius* for a local directory by manually running: 52 | 53 | ```sh 54 | docker run --rm -it -p 5000:5000 \ 55 | -v $(pwd):/data:ro \ 56 | --security-opt apparmor:unconfined \ 57 | --cap-add=SYS_ADMIN \ 58 | 28mm/blast-radius 59 | ``` 60 | 61 | A slightly more customized variant of this is also available as an example 62 | [docker-compose.yml](./examples/docker-compose.yml) usecase for Workspaces. 63 | 64 | ### Docker configurations 65 | 66 | *Terraform* module links are saved as _absolute_ paths in relative to the 67 | project root (note `.terraform/modules/`). Given these paths will vary 68 | betwen Docker and the host, we mount the volume as read-only, assuring we don't 69 | ever interfere with your real environment. 70 | 71 | However, in order for *Blast Radius* to actually work with *Terraform*, it needs 72 | to be initialized. To accomplish this, the container creates an [overlayfs][] 73 | that exists within the container, overlaying your own, so that it can operate 74 | independently. To do this, certain runtime privileges are required -- 75 | specifically `--cap-add=SYS_ADMIN`. 76 | 77 | For more information on how this works and what it means for your host, check 78 | out the [runtime privileges][privileges] documentation. 79 | 80 | #### Docker & Subdirectories 81 | 82 | If you organized your *Terraform* project using stacks and modules, 83 | *Blast Radius* must be called from the project root and reference them as 84 | subdirectories -- don't forget to prefix `--serve`! 85 | 86 | For example, let's create a Terraform `project` with the following: 87 | 88 | ```txt 89 | $ tree -d 90 | `-- project/ 91 | |-- modules/ 92 | | |-- foo 93 | | |-- bar 94 | | `-- dead 95 | `-- stacks/ 96 | `-- beef/ 97 | `-- .terraform 98 | ``` 99 | 100 | It consists of 3 modules `foo`, `bar` and `dead`, followed by one `beef` stack. 101 | To apply *Blast Radius* to the `beef` stack, you would want to run the container 102 | with the following: 103 | 104 | ```sh 105 | $ cd project 106 | $ docker run --rm -it -p 5000:5000 \ 107 | -v $(pwd):/data:ro \ 108 | --security-opt apparmor:unconfined \ 109 | --cap-add=SYS_ADMIN \ 110 | 28mm/blast-radius --serve stacks/beef 111 | ``` 112 | 113 | ## Embedded Figures 114 | 115 | You may wish to embed figures produced with *Blast Radius* in other documents. 116 | You will need the following: 117 | 118 | 1. An `svg` file and `json` document representing the graph and its layout. 119 | 2. `javascript` and `css` found in `.../blastradius/server/static` 120 | 3. A uniquely identified DOM element, where the `` should appear. 121 | 122 | You can read more details in the [documentation](doc/embedded.md) 123 | 124 | ## Implementation Details 125 | 126 | *Blast Radius* uses the [Graphviz][] package to layout graph diagrams, 127 | [PyHCL](https://github.com/virtuald/pyhcl) to parse [Terraform][] configuration, 128 | and [d3.js](https://d3js.org/) to implement interactive features and animations. 129 | 130 | ## Further Reading 131 | 132 | The development of *Blast Radius* is documented in a series of 133 | [blog](https://28mm.github.io) posts: 134 | 135 | * [part 1](https://28mm.github.io/notes/d3-terraform-graphs): motivations, d3 force-directed layouts vs. vanilla graphviz. 136 | * [part 2](https://28mm.github.io/notes/d3-terraform-graphs-2): d3-enhanced graphviz layouts, meaningful coloration, animations. 137 | * [part 3](https://28mm.github.io/notes/terraform-graphs-3): limiting horizontal sprawl, supporting modules. 138 | * [part 4](https://28mm.github.io/notes/d3-terraform-graphs-4): search, pan/zoom, prune-to-selection, docker. 139 | 140 | A catalog of example *Terraform* configurations, and their dependency graphs 141 | can be found [here](https://28mm.github.io/blast-radius-docs/). 142 | 143 | * [AWS two-tier architecture](https://28mm.github.io/blast-radius-docs/examples/terraform-provider-aws/two-tier/) 144 | * [AWS networking (featuring modules)](https://28mm.github.io/blast-radius-docs/examples/terraform-provider-aws/networking/) 145 | * [Google two-tier architecture](https://28mm.github.io/blast-radius-docs/examples/terraform-provider-google/two-tier/) 146 | * [Azure load-balancing with 2 vms](https://28mm.github.io/blast-radius-docs/examples/terraform-provider-azurem/2-vms-loadbalancer-lbrules/) 147 | 148 | These examples are drawn primarily from the `examples/` directory distributed 149 | with various *Terraform* providers, and aren't necessarily ideal. Additional 150 | examples, particularly demonstrations of best-practices, or of multi-cloud 151 | configurations strongly desired. 152 | -------------------------------------------------------------------------------- /bin/blast-radius: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """blast-radius: 4 | Quick hack to produce clarified terraform graph visualizations.""" 5 | 6 | # standard libraries 7 | import re 8 | import sys 9 | import argparse 10 | import os 11 | import itertools 12 | 13 | # 1st party libraries 14 | from blastradius.handlers.dot import DotGraph, Format, DotNode 15 | from blastradius.handlers.plan import Plan 16 | from blastradius.handlers.apply import Apply 17 | from blastradius.handlers.terraform import Terraform 18 | from blastradius.server.server import app 19 | 20 | def main(): 21 | 22 | parser = parser = argparse.ArgumentParser(description='blast-radius: Interactive Terraform Graph Visualizations') 23 | parser.add_argument('directory', type=str, help='terraform configuration directory', default=os.getcwd(), nargs='?') 24 | parser.add_argument('--port', type=int, help='specify a port other than the default 5000', default=os.getenv('BLAST_RADIUS_PORT',5000)) 25 | 26 | output_group = parser.add_mutually_exclusive_group() 27 | output_group.add_argument('--json', action='store_const', const=True, default=False, help='print a json representation of the Terraform graph') 28 | output_group.add_argument('--dot', action='store_const', const=True, default=False, help='print the graphviz/dot representation of the Terraform graph') 29 | output_group.add_argument('--svg', action='store_const', const=True, default=False, help='print the svg representation of the Terraform graph') 30 | output_group.add_argument('--serve', action='store_const', const=True, default=False, help='spins up a webserver with an interactive Terraform graph') 31 | 32 | parser.add_argument('--graph', type=str, help='`terraform graph` output (defaults to stdin)', default=sys.stdin) 33 | 34 | # options to limit, re-focus, and re-center presentation of larger graphs. 35 | constraint_group = parser.add_mutually_exclusive_group() 36 | constraint_group.add_argument('--module-depth', type=int, help='hide module details', required=False) 37 | constraint_group.add_argument('--focus', type=str, help='', required=False) 38 | constraint_group.add_argument('--center', type=str, help='', required=False) 39 | 40 | # TODO present changes, and animate `terraform apply' 41 | #parser.add_argument('--plan', type=str, help='terraform plan output', default=None) 42 | #parser.add_argument('--state', type=str, help='tfstate file', default=None) 43 | #parser.add_argument('--apply', type=str, help='terraform apply log', default=None) 44 | 45 | args = parser.parse_args() 46 | 47 | if args.serve: 48 | os.chdir(args.directory) 49 | app.run(host='0.0.0.0',port=args.port) 50 | sys.exit(0) 51 | 52 | elif args.json or args.dot or args.svg: 53 | if args.graph is sys.stdin: 54 | dot = DotGraph('', file_contents=sys.stdin.read()) 55 | else: 56 | dot = DotGraph(args.graph) 57 | 58 | # we might not want to show every node in the depedency graph 59 | # specifying --module-depth is an easy way to limit detail 60 | if 'module_depth' in args and args.module_depth != None: 61 | if args.module_depth < 0: 62 | parser.print_help() 63 | sys.exit(1) 64 | dot.set_module_depth(args.module_depth) 65 | 66 | if 'center' in args and args.center: 67 | c_node = dot.get_node_by_name(args.center) 68 | if not c_node: 69 | parser.print_help() 70 | sys.exit(1) 71 | dot.center(c_node) 72 | 73 | if 'focus' in args and args.focus: 74 | f_node = dot.get_node_by_name(args.focus) 75 | if not f_node: 76 | parser.print_help() 77 | sys.exit(1) 78 | dot.focus(f_node) 79 | 80 | if args.json: 81 | tf = Terraform(args.directory) 82 | for node in dot.nodes: 83 | node.definition = tf.get_def(node) 84 | 85 | if args.json: 86 | print(dot.json()) 87 | elif args.dot: 88 | print(dot.dot()) 89 | elif args.svg: 90 | print(dot.svg()) 91 | else: 92 | parser.print_help() 93 | 94 | 95 | if __name__ == '__main__': 96 | main() 97 | -------------------------------------------------------------------------------- /blastradius/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/28mm/blast-radius/a7ec4ef78141ab0d2a688c65112f799adb9622ba/blastradius/__init__.py -------------------------------------------------------------------------------- /blastradius/graph.py: -------------------------------------------------------------------------------- 1 | # standard libraries 2 | from abc import ABC, abstractmethod 3 | import itertools 4 | import subprocess 5 | from io import StringIO 6 | 7 | # 3rd party libraries 8 | import jinja2 9 | 10 | # 1st party libraries 11 | from blastradius.util import Counter 12 | 13 | class Graph: 14 | def __init__(self, nodes, edges): 15 | self.nodes = nodes 16 | self.edges = edges 17 | 18 | def __iter__(self): 19 | for key in {'nodes', 'edges'}: 20 | yield (key, getattr(self, key)) 21 | 22 | def dot(self): 23 | 'returns a dot/graphviz representation of the graph (a string)' 24 | return self.dot_template.render({ 'nodes': self.nodes, 'edges': self.edges }) 25 | 26 | def svg(self): 27 | 'returns an svg representation of the graph (via graphviz/dot)' 28 | dot_str = self.dot() 29 | completed = subprocess.run(['dot', '-Tsvg'], input=dot_str.encode('utf-8'), stdout=subprocess.PIPE) 30 | if completed.returncode != 0: 31 | raise 32 | return completed.stdout.decode('utf-8') 33 | 34 | def json(self): 35 | 'returns a json representation of the graph (a string)' 36 | return json.dumps({ 'nodes' : dict(nodes), 'edges' : dict(edges) }, indent=4, sort=True) 37 | 38 | @staticmethod 39 | def reset_counters(): 40 | Node.reset_counter() 41 | Edge.reset_counter() 42 | 43 | dot_template_str = """ 44 | digraph { 45 | compound = "true" 46 | newrank = "true" 47 | graph [fontname = "courier new",fontsize=8]; 48 | node [fontname = "courier new",fontsize=8]; 49 | edge [fontname = "courier new",fontsize=8]; 50 | subgraph "root" { 51 | {% for node in nodes %} 52 | "{{node.label}}" {% if node.fmt %} [{{node.fmt}}] {% endif %} 53 | {% endfor %} 54 | {% for edge in edges %} 55 | "{{edge.source}}" -> "{{edge.target}}" {% if edge.fmt %} [{{edge.fmt}}] {% endif %} 56 | {% endfor %} 57 | } 58 | } 59 | """ 60 | dot_template = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(dot_template_str) 61 | 62 | class Edge: 63 | 64 | # we need unique ids for each edge, for SVG output, 65 | # svg_id_counter provides this facility. 66 | svg_id_counter = Counter().next 67 | 68 | def __init__(self, source, target): 69 | self.source = source 70 | self.target = target 71 | self.svg_id = 'edge_' + str(Edge.svg_id_counter()) 72 | 73 | def __iter__(self): 74 | for key in {'source', 'target'}: 75 | yield (key, getattr(self, key)) 76 | 77 | @staticmethod 78 | def reset_counter(): 79 | Edge.svg_id_counter = Counter().next 80 | 81 | 82 | class Node(ABC): 83 | 84 | # we need unique ids for each node, for SVG output, 85 | # svg_id_counter provides this facility. 86 | svg_id_counter = Counter().next 87 | 88 | @staticmethod 89 | def reset_counter(): 90 | Node.svg_id_counter = Counter().next 91 | 92 | 93 | @abstractmethod 94 | def __init__(self): 95 | raise NotImplementedError 96 | 97 | @abstractmethod 98 | def __iter__(self): 99 | raise NotImplementedError -------------------------------------------------------------------------------- /blastradius/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/28mm/blast-radius/a7ec4ef78141ab0d2a688c65112f799adb9622ba/blastradius/handlers/__init__.py -------------------------------------------------------------------------------- /blastradius/handlers/apply.py: -------------------------------------------------------------------------------- 1 | # standard libraries 2 | import re 3 | import json 4 | 5 | # 1st party libraries 6 | from blastradius.graph import Graph, Node, Edge 7 | from blastradius.handlers.dot import DotNode 8 | from blastradius.util import Re 9 | 10 | class Apply(Graph): 11 | def __init__(self, filename): 12 | self.filename = filename 13 | self.contents = '' 14 | self.nodes = [] # we can populate this, 15 | self.edges = [] # but not this! 16 | 17 | ansi_escape = re.compile(r'\x1b[^m]*m') 18 | with open(filename, 'r') as f: 19 | self.contents = ansi_escape.sub('', f.read()) 20 | 21 | # example output: 22 | # 23 | # aws_vpc.default: Creation complete after 4s (ID: vpc-024f7a64) 24 | # ... 25 | # aws_key_pair.auth: Creating... 26 | # fingerprint: "" => "" 27 | # key_name: "" => "default-key" 28 | # ... 29 | # aws_instance.web: Still creating... (10s elapsed) 30 | # aws_instance.web: Still creating... (20s elapsed) 31 | # aws_instance.web (remote-exec): Connecting to remote host via SSH... 32 | # aws_instance.web (remote-exec): Host: 1.2.3.4 33 | # aws_instance.web (remote-exec): User: ubuntu 34 | # ... 35 | 36 | node_begin_re =r'(?P\S+)\:\s+Creating...' 37 | node_compl_re = r'(?P\S+)\:\s+Creation\s+complete\s+after\s+(?P\S+)\s+' 38 | node_still_re = r'(?P\S+)\:\s+Still\s+creating\.\.\.\s+\((?P\S+)\s+' 39 | 40 | for line in self.contents.splitlines(): 41 | 42 | r = Re() 43 | if r.match(node_begin_re, line): 44 | 45 | 46 | 47 | 48 | break 49 | 50 | 51 | 52 | 53 | print(self.contents) 54 | 55 | 56 | -------------------------------------------------------------------------------- /blastradius/handlers/dot.py: -------------------------------------------------------------------------------- 1 | # standard libraries 2 | import json 3 | import re 4 | import subprocess 5 | from collections import OrderedDict 6 | from collections import deque 7 | 8 | # 3rd party libraries 9 | import jinja2 10 | 11 | # 1st party libraries 12 | from blastradius.graph import Graph, Node, Edge 13 | from blastradius.util import OrderedSet 14 | 15 | class DotGraph(Graph): 16 | 17 | def __init__(self, filename, file_contents=None): 18 | self.filename = filename 19 | self.nodes = [] 20 | self.edges = [] 21 | self.clusters = OrderedDict() 22 | self.clusters['root'] = True # Used like an ordered Set. 23 | 24 | if file_contents: 25 | self.contents = file_contents 26 | else: 27 | with open(filename, 'r') as f: 28 | self.contents = f.read() 29 | 30 | # pretty naive way to put a parser together, considering graphviz/dot have a 31 | # bnf grammar. but gets the job done, for now. 32 | edge_fmt_re = re.compile(r'\s+\"(?P.*)\"\s+\-\>(?P.*)\s+\[(?P.*)\]') 33 | edge_re = re.compile(r'\s+\"(?P.*)\"\s+\-\>\s+\"(?P.*)\"') 34 | decl_re = re.compile(r'\s+\"(?P.*)\"\s+\[(?P.*)\]') 35 | 36 | # read node and edge declarations from an existing graphviz/dot file. 37 | for l in self.contents.splitlines(): 38 | for pat in [edge_fmt_re, edge_re, decl_re]: 39 | m = pat.match(l) 40 | if m: 41 | d = m.groupdict() 42 | fmt = Format(d['fmt']) if 'fmt' in d else Format('') 43 | if 'src' in m.groupdict(): 44 | e = DotEdge(d['src'], d['dst'], fmt=fmt) 45 | self.edges.append(e) 46 | elif 'node' in m.groupdict(): 47 | self.nodes.append(DotNode(d['node'], fmt=fmt)) 48 | break 49 | 50 | # terraform graph output doesn't always make explicit node declarations; 51 | # sometimes they're a side-effect of edge definitions. Capture them. 52 | for e in self.edges: 53 | if e.source not in [ n.label for n in self.nodes ]: 54 | self.nodes.append(DotNode(e.source)) 55 | if e.target not in [ n.label for n in self.nodes ]: 56 | self.nodes.append(DotNode(e.target)) 57 | 58 | self.stack('var') 59 | self.stack('output') 60 | 61 | # leftover nodes belong to the root subgraph. 62 | for n in self.nodes: 63 | n.cluster = 'root' if not n.cluster else n.cluster 64 | 65 | def get_node_by_name(self, label): 66 | '''return node by label (if exists) otherwise simple_name''' 67 | for n in self.nodes: 68 | if n.label == label: 69 | return n 70 | 71 | for n in self.nodes: 72 | if n.simple_name == label: 73 | return n 74 | 75 | return None 76 | 77 | # 78 | # Output functions (return strings). 79 | # 80 | 81 | def dot(self): 82 | 'returns a dot/graphviz representation of the graph (a string)' 83 | return self.dot_template.render({ 'nodes': self.nodes, 'edges': self.edges, 'clusters' : self.clusters, 'EdgeType' : EdgeType }) 84 | 85 | def json(self): 86 | edges = [ dict(e) for e in self.edges ] 87 | nodes = [ dict(n) for n in self.nodes ] 88 | return json.dumps({ 'nodes' : nodes, 'edges' : edges }, indent=4, sort_keys=True) 89 | 90 | # 91 | # A handful of graph manipulations. These are hampered by the decision 92 | # to not de-serialize the graphs (leaving them as lists of nodes and 93 | # edges). This code is garbage, but it mostly works. 94 | # 95 | 96 | def stack(self, node_type, threshold=2): 97 | '''if a group of nodes of type 'type' number as many as 'threshold', 98 | and share the same (single) parent and (single) child, then 99 | hide their dependencies, and create a chain of pseudo-dependencies 100 | so that they stack one above the next in the final diagram.''' 101 | new_edges = [] 102 | 103 | for n in self.nodes: 104 | if n.type != node_type: 105 | continue 106 | 107 | parents = [ e for e in self.edges if e.target == n.label ] 108 | children = [ e for e in self.edges if e.source == n.label ] 109 | 110 | if len(children) > 1 or len(parents) != 1: 111 | continue 112 | 113 | # setup the cluster. 114 | target = children[0].target if len(children) > 0 else '' 115 | n.cluster = 'cluster' + parents[0].source + '_' + node_type + '_' + target 116 | self.clusters[n.cluster] = True # <-- OrderedDict, used for its ordering. Pretend its a Set 117 | 118 | for cluster in [ cluster for cluster in self.clusters.keys() if re.match('.*_' + node_type + '_.*', cluster) ]: 119 | nodes = [ n for n in self.nodes if n.cluster == cluster ] 120 | prev = None 121 | last_edge = None 122 | 123 | if len(nodes) == 1: 124 | continue 125 | 126 | for n in nodes: 127 | 128 | # 1st iteration. 129 | if not prev: 130 | for e in self.edges: 131 | if e.source == n.label: 132 | e.edge_type = EdgeType.HIDDEN 133 | 134 | # subsequent iterations. 135 | else: 136 | last_edge = None 137 | for e in self.edges: 138 | if e.target == n.label: 139 | e.edge_type = EdgeType.HIDDEN 140 | if e.source == n.label: 141 | e.edge_type = EdgeType.HIDDEN 142 | last_edge = e 143 | new_edges.append(DotEdge(prev.label, n.label, fmt=Format('style=dashed,arrowhead=none'), edge_type=EdgeType.LAYOUT_SHOWN)) 144 | 145 | # each iteration. 146 | prev = n 147 | 148 | if last_edge: 149 | last_edge.edge_type = EdgeType.NORMAL 150 | 151 | self.edges = self.edges + new_edges 152 | 153 | def set_module_depth(self, depth): 154 | """ 155 | group resources belonging to modules into a single node, to simplify 156 | presentation. No claims made for this code. It's garbage! 157 | """ 158 | 159 | depth += 1 # account for [root] module 160 | 161 | def is_too_deep(modules): 162 | if len(modules) >= depth and modules[0] != 'root': 163 | return True 164 | 165 | def find_edge(edges, e): 166 | for edge in edges: 167 | if e.source == edge.source and e.target == edge.target and e.edge_type == edge.edge_type: 168 | return True 169 | return False 170 | 171 | # find DotNodes at too great a depth. 172 | too_deep = [ n for n in self.nodes if is_too_deep(n.modules) ] 173 | 174 | # generate ModuleNodes to stand-in for DotNodes at too great a depth. 175 | placeholders = [] 176 | for n in too_deep: 177 | match = False 178 | for p in placeholders: 179 | if p.is_standin(n.modules): 180 | match = True 181 | break 182 | if match == False: 183 | placeholders.append(ModuleNode(n.modules[:depth])) 184 | 185 | # create replacement edges 186 | new_edges = [] 187 | for e in self.edges: 188 | src_mods = DotNode._label_to_modules(e.source) 189 | tgt_mods = DotNode._label_to_modules(e.target) 190 | 191 | if is_too_deep(src_mods) and is_too_deep(tgt_mods): 192 | continue 193 | elif is_too_deep(src_mods): 194 | for p in placeholders: 195 | if p.is_standin(src_mods): 196 | replace = True 197 | for ne in new_edges: 198 | if ne.source == p.label and ne.target == e.target: 199 | replace = False 200 | break 201 | if replace: 202 | new_edges.append(DotEdge(p.label, e.target, fmt=Format(''))) 203 | break 204 | elif is_too_deep(tgt_mods): 205 | for p in placeholders: 206 | if p.is_standin(tgt_mods): 207 | replace = True 208 | for ne in new_edges: 209 | if ne.source == e.source and ne.target == p.label: 210 | replace = False 211 | break 212 | if replace: 213 | new_edges.append(DotEdge(e.source, p.label, fmt=Format(''))) 214 | break 215 | else: 216 | new_edges.append(e) 217 | 218 | # make sure we haven't got any duplicate edges. 219 | final_edges = [] 220 | for e in new_edges: 221 | if not find_edge(final_edges, e): 222 | final_edges.append(e) 223 | self.edges = final_edges 224 | 225 | # add placeholder nodes, remove nodes beyond specified module_depth. 226 | self.nodes = list(OrderedSet(placeholders) | (OrderedSet(self.nodes) - OrderedSet(too_deep))) 227 | 228 | 229 | def center(self, node): 230 | ''' 231 | prunes graph to include only (1) the given node, (2) its 232 | dependencies, and nodes that depend on it. 233 | ''' 234 | edges_by_source = {} 235 | for e in self.edges: 236 | if e.source in edges_by_source: 237 | edges_by_source[e.source].append(e) 238 | else: 239 | edges_by_source[e.source] = [ e ] 240 | 241 | edges_by_target = {} 242 | for e in self.edges: 243 | if e.target in edges_by_target: 244 | edges_by_target[e.target].append(e) 245 | else: 246 | edges_by_target[e.target] = [ e ] 247 | 248 | edges_to_save = OrderedSet() # edge objects 249 | nodes_to_save = OrderedSet() # label strings 250 | 251 | q = deque() 252 | if node.label in edges_by_source: 253 | q.append(node.label) 254 | nodes_to_save.add(node.label) 255 | while len(q) > 0: 256 | source = q.pop() 257 | if source in edges_by_source: 258 | for e in edges_by_source[source]: 259 | q.append(e.target) 260 | edges_to_save.add(e) 261 | nodes_to_save.add(e.target) 262 | 263 | q = deque() 264 | if node.label in edges_by_target: 265 | q.append(node.label) 266 | nodes_to_save.add(node.label) 267 | while len(q) > 0: 268 | target = q.pop() 269 | if target in edges_by_target: 270 | for e in edges_by_target[target]: 271 | q.append(e.source) 272 | edges_to_save.add(e) 273 | nodes_to_save.add(e.source) 274 | 275 | self.edges = list(edges_to_save) 276 | self.nodes = [ n for n in self.nodes if n.label in nodes_to_save ] 277 | 278 | def focus(self, node): 279 | ''' 280 | prunes graph to include only the given node and its dependencies. 281 | ''' 282 | edges_by_source = {} 283 | for e in self.edges: 284 | if e.source in edges_by_source: 285 | edges_by_source[e.source].append(e) 286 | else: 287 | edges_by_source[e.source] = [ e ] 288 | 289 | edges_to_save = OrderedSet() # edge objects 290 | nodes_to_save = OrderedSet() # label strings 291 | 292 | q = deque() 293 | if node.label in edges_by_source: 294 | q.append(node.label) 295 | nodes_to_save.add(node.label) 296 | while len(q) > 0: 297 | source = q.pop() 298 | if source in edges_by_source: 299 | for e in edges_by_source[source]: 300 | q.append(e.target) 301 | edges_to_save.add(e) 302 | nodes_to_save.add(e.target) 303 | 304 | self.edges = list(edges_to_save) 305 | self.nodes = [ n for n in self.nodes if n.label in nodes_to_save ] 306 | 307 | 308 | 309 | # 310 | # Formatting templates. 311 | # 312 | 313 | dot_template_str = """ 314 | digraph { 315 | compound = "true" 316 | graph [fontname = "courier new",fontsize=8]; 317 | node [fontname = "courier new",fontsize=8]; 318 | edge [fontname = "courier new",fontsize=8]; 319 | 320 | {# just the root module #} 321 | {% for cluster in clusters %} 322 | subgraph "{{cluster}}" { 323 | style=invis; 324 | {% for node in nodes %} 325 | {% if node.cluster == cluster and node.module == 'root' %} 326 | {% if node.type %} 327 | "{{node.label}}" [ shape=none, margin=0, id={{node.svg_id}} label=< 328 | 329 | 330 |
{{node.type}}
{{node.resource_name}}
>]; 331 | {% else %} 332 | "{{node.label}}" [{{node.fmt}}] 333 | {% endif %} 334 | {% endif %} 335 | {% endfor %} 336 | } 337 | {% endfor %} 338 | 339 | {# non-root modules #} 340 | {% for node in nodes %} 341 | {% if node.module != 'root' %} 342 | 343 | {% if node.collapsed %} 344 | "{{node.label}}" [ shape=none, margin=0, id={{node.svg_id}} label=< 345 | {% for module in node.modules %}{% endfor %} 346 | 347 | 348 |
(M) {{module}}
(collapsed)
...
>]; 349 | {% else %} 350 | "{{node.label}}" [ shape=none, margin=0, id={{node.svg_id}} label=< 351 | {% for module in node.modules %}{% endfor %} 352 | 353 | 354 |
(M) {{module}}
{{node.type}}
{{node.resource_name}}
>]; 355 | {% endif %} 356 | {% endif %} 357 | 358 | {% endfor %} 359 | 360 | {% for edge in edges %} 361 | {% if edge.edge_type == EdgeType.NORMAL %}"{{edge.source}}" -> "{{edge.target}}" {% if edge.fmt %} [{{edge.fmt}}] {% endif %}{% endif %} 362 | {% if edge.edge_type == EdgeType.LAYOUT_SHOWN %}"{{edge.source}}" -> "{{edge.target}}" {% if edge.fmt %} [{{edge.fmt}}] {% endif %}{% endif %} 363 | {% if edge.edge_type == EdgeType.LAYOUT_HIDDEN %}"{{edge.source}}" -> "{{edge.target}}" [style="invis"]{% endif %} 364 | {% endfor %} 365 | } 366 | """ 367 | dot_template = jinja2.Environment(loader=jinja2.BaseLoader()).from_string(dot_template_str) 368 | 369 | 370 | class Format: 371 | """ 372 | Naive parser for graphviz/dot formatting options. 373 | TBD: method to add/replace format options, rather than exposing self.fmt 374 | """ 375 | 376 | def __init__(self, s): 377 | self.fmt = {} 378 | 379 | if len(s) > 0: 380 | 381 | # doesn't handle '=' or ',' within keys/values, and includes quotation 382 | # marks, rather than stripping them... but sufficient for a subset of dotfiles 383 | # produced by terraform, hopefully. 384 | param_re = re.compile(r'\s*(?P.*)\s*\=(?P.*)') 385 | params = s.split(',') 386 | for p in params: 387 | m = param_re.match(p) 388 | if m: 389 | self.fmt[m.groupdict()['key']] = m.groupdict()['val'] 390 | else: 391 | print('Error processing format param: ' + 'p', file=sys.stderr) 392 | 393 | def add(self, **kwargs): 394 | self.fmt = {**self.fmt, **kwargs} 395 | 396 | def remove(self, key): 397 | if key in self.fmt: 398 | del self.fmt[key] 399 | 400 | def __str__(self): 401 | return ','.join([ key + '=' + val for key, val in self.fmt.items() ]) 402 | 403 | class DotNode(Node): 404 | 405 | def __init__(self, label, fmt=None): 406 | 407 | self.label = DotNode._label_fixup(label) 408 | self.fmt = fmt if fmt else Format('') # graphviz formatting. 409 | self.simple_name = re.sub(r'\[root\]\s+', '', self.label) # strip root module notation. 410 | self.type = DotNode._resource_type(self.label) # e.g. var, aws_instance, output... 411 | self.resource_name = DotNode._resource_name(self.label) # 412 | self.svg_id = 'node_' + str(Node.svg_id_counter()) # 413 | self.definition = {} # 414 | self.group = 20000 # for coloration. placeholder. replaced in javascript. 415 | self.module = DotNode._module(self.label) # for module groupings. 'root' or 'module.foo.module.bar' 416 | self.cluster = None # for stacked resources (usually var/output). 417 | self.collapsed = False 418 | 419 | self.fmt.add(id=self.svg_id, shape='box') 420 | 421 | 422 | self.modules = [ m for m in self.module.split('.') if m != 'module' ] 423 | 424 | def __iter__(self): 425 | for key in {'label', 'simple_name', 'type', 'resource_name', 'group', 'svg_id', 'definition', 'cluster', 'module', 'modules'}: 426 | yield (key, getattr(self, key)) 427 | 428 | # 429 | # static utilities mostly for converting "labels"--which uniquely identify 430 | # DotNodes--to other useful things like a list of parent modules, the isolated 431 | # resource name, the resource type, etc. 432 | # 433 | 434 | @staticmethod 435 | def _label_fixup(label): 436 | # fix the resources belonging to removed modules by naming them "removed." 437 | return re.sub(r'\s+\(removed\)', r'.removed (removed)', label) 438 | 439 | @staticmethod 440 | def _resource_type(label): 441 | m = re.match(r'(\[root\]\s+)*((?P\S+)\.)*(?P\S+)\.\S+', label) 442 | return m.groupdict()['type'] if m else '' 443 | 444 | @staticmethod 445 | def _resource_name(label): 446 | m = re.match(r'(\[root\]\s+)*(?P\S+)\.(?P\S+)', label) 447 | return m.groupdict()['name'] if m else '' 448 | 449 | @staticmethod 450 | def _module(label): 451 | try: 452 | if not re.match(r'(\[root\]\s+)*module\..*', label): 453 | return 'root' 454 | m = re.match(r'(\[root\]\s+)*(?P\S+)\.(?P\S+)\.\S+', label) 455 | return m.groupdict()['module'] 456 | except: 457 | raise Exception("None: ", label) 458 | 459 | @staticmethod 460 | def _label_to_modules(label): 461 | return [ m for m in DotNode._module(label).split('.') if m != 'module' ] 462 | 463 | 464 | class ModuleNode(DotNode): 465 | ''' 466 | Stands in for multiple DotNodes at the same module depth... 467 | ''' 468 | def __init__(self, modules): 469 | self.label = '[root] ' + 'module.' + '.module.'.join(modules) + '.collapsed.etc' 470 | self.fmt = Format('') 471 | self.simple_name = re.sub(r'\[root\]\s+', '', self.label) # strip root module notation. 472 | self.type = DotNode._resource_type(self.label) 473 | self.resource_name = DotNode._resource_name(self.label) 474 | self.svg_id = 'node_' + str(Node.svg_id_counter()) 475 | self.definition = {} 476 | self.group = 20000 # for coloration. placeholder. replaced in javascript. 477 | self.module = DotNode._module(self.label) # for module groupings. 'root' or 'module.foo.module.bar' 478 | self.cluster = None # for stacked resources (usually var/output). 479 | self.modules = [ m for m in self.module.split('.') if m != 'module' ] 480 | self.collapsed = True 481 | 482 | self.fmt.add(id=self.svg_id, shape='box') 483 | 484 | def is_standin(self, modules): 485 | 'should this ModuleNode standin for the provided DotNode?' 486 | if len(modules) < len(self.modules): 487 | return False 488 | for i in range(len(self.modules)): 489 | if self.modules[i] != modules[i]: 490 | return False 491 | return True 492 | 493 | 494 | class EdgeType: 495 | ''' 496 | Sometimes we want to hide edges, and sometimes we want to add 497 | edges in order to influence layout. 498 | ''' 499 | NORMAL = 1 # what we talk about when we're talking about edges. 500 | HIDDEN = 2 # these are normal edges, but aren't drawn. 501 | LAYOUT_SHOWN = 3 # these edges are drawn, but aren't "real" edges 502 | LAYOUT_HIDDEN = 4 # these edges are not drawn, aren't "real" edges, but inform layout. 503 | 504 | def __init__(self): 505 | pass 506 | 507 | 508 | class DotEdge(Edge): 509 | ''' 510 | Distinguished from a Regular Edge, by its Dot language format string. 511 | ''' 512 | def __init__(self, source, target, fmt=None, edge_type=EdgeType.NORMAL): 513 | self.source = DotNode._label_fixup(source) 514 | self.target = DotNode._label_fixup(target) 515 | self.svg_id = 'edge_' + str(Edge.svg_id_counter()) 516 | self.fmt = fmt 517 | self.edge_type = edge_type 518 | 519 | self.fmt.add(id=self.svg_id) 520 | 521 | def __iter__(self): 522 | for key in {'source', 'target', 'svg_id', 'edge_type'}: 523 | yield (key, getattr(self, key)) 524 | 525 | 526 | -------------------------------------------------------------------------------- /blastradius/handlers/plan.py: -------------------------------------------------------------------------------- 1 | # standard libraries 2 | import re 3 | import json 4 | 5 | # 1st party libraries 6 | from blastradius.graph import Graph, Node, Edge 7 | from blastradius.handlers.dot import DotNode 8 | 9 | class Plan(Graph): 10 | def __init__(self, filename): 11 | self.filename = filename 12 | self.contents = '' 13 | self.nodes = [] # we can populate this, 14 | self.edges = [] # but not this! 15 | 16 | ansi_escape = re.compile(r'\x1b[^m]*m') 17 | with open(filename, 'r') as f: 18 | self.contents = ansi_escape.sub('', f.read()) 19 | 20 | node_re = re.compile(r'\s+(?P(\+|\-))\s+(?P\S+)') 21 | attr_re = re.compile(r'\s+(?P\S+)\:\s+(?P.*)') 22 | 23 | action = None 24 | name = None 25 | definition = {} 26 | for line in self.contents.splitlines(): 27 | for p in [ node_re, attr_re ]: 28 | m = p.match(line) 29 | if m: 30 | d = m.groupdict() 31 | if 'action' in d: 32 | if action: 33 | self.nodes.append(PlanNode(action, name, definition)) 34 | action = d['action'] 35 | name = d['name'] 36 | definition = {} 37 | elif 'key' in d: 38 | definition[d['key']] = d['value'] 39 | break 40 | 41 | print(json.dumps([ dict(n) for n in self.nodes], indent=4)) 42 | 43 | class PlanNode(Node): 44 | def __init__(self, action, name, definition): 45 | self.action = action 46 | self.simple_name = name 47 | self.definition = definition 48 | self.type = DotNode._resource_type(self.simple_name) 49 | self.resource_name = DotNode._resource_name(self.simple_name) 50 | self.svg_id = 'node_' + str(Node.svg_id_counter()) 51 | 52 | def __iter__(self): 53 | for key in ['action', 'simple_name', 'definition', 'type', 'resource_name', 'svg_id']: 54 | yield (key, getattr(self, key)) 55 | -------------------------------------------------------------------------------- /blastradius/handlers/terraform.py: -------------------------------------------------------------------------------- 1 | # standard libraries 2 | from glob import iglob 3 | import io 4 | import os 5 | import re 6 | 7 | # 3rd party libraries 8 | import hcl # hashicorp configuration language (.tf) 9 | 10 | class Terraform: 11 | """Finds terraform/hcl files (*.tf) in CWD or a supplied directory, parses 12 | them with pyhcl, and exposes the configuration via self.config.""" 13 | 14 | def __init__(self, directory=None, settings=None): 15 | self.settings = settings if settings else {} 16 | 17 | # handle the root module first... 18 | self.directory = directory if directory else os.getcwd() 19 | #print(self.directory) 20 | self.config_str = '' 21 | iterator = iglob( self.directory + '/*.tf') 22 | for fname in iterator: 23 | with open(fname, 'r', encoding='utf-8') as f: 24 | self.config_str += f.read() + ' ' 25 | config_io = io.StringIO(self.config_str) 26 | self.config = hcl.load(config_io) 27 | 28 | # then any submodules it may contain, skipping any remote modules for 29 | # the time being. 30 | self.modules = {} 31 | if 'module' in self.config: 32 | for name, mod in self.config['module'].items(): 33 | if 'source' not in mod: 34 | continue 35 | source = mod['source'] 36 | # '//' used to refer to a subdirectory in a git repo 37 | if re.match(r'.*\/\/.*', source): 38 | continue 39 | # '@' should only appear in ssh urls 40 | elif re.match(r'.*\@.*', source): 41 | continue 42 | # 'github.com' special behavior. 43 | elif re.match(r'github\.com.*', source): 44 | continue 45 | # points to new TFE module registry 46 | elif re.match(r'app\.terraform\.io', source): 47 | continue 48 | # bitbucket public and private repos 49 | elif re.match(r'bitbucket\.org.*', source): 50 | continue 51 | # git::https or git::ssh sources 52 | elif re.match(r'^git::', source): 53 | continue 54 | # git:// sources 55 | elif re.match(r'^git:\/\/', source): 56 | continue 57 | # Generic Mercurial repos 58 | elif re.match(r'^hg::', source): 59 | continue 60 | # Public Terraform Module Registry 61 | elif re.match(r'^[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+', source): 62 | continue 63 | # AWS S3 buckets 64 | elif re.match(r's3.*\.amazonaws\.com', source): 65 | continue 66 | # fixme path join. eek. 67 | self.modules[name] = Terraform(directory=self.directory+'/'+source, settings=mod) 68 | 69 | 70 | def get_def(self, node, module_depth=0): 71 | 72 | # FIXME 'data' resources (incorrectly) handled as modules, necessitating 73 | # the try/except block here. 74 | if len(node.modules) > module_depth and node.modules[0] != 'root': 75 | try: 76 | tf = self.modules[ node.modules[module_depth] ] 77 | return tf.get_def(node, module_depth=module_depth+1) 78 | except: 79 | return '' 80 | 81 | try: 82 | # non resource types 83 | types = { 'var' : lambda x: self.config['variable'][x.resource_name], 84 | 'provider' : lambda x: self.config['provider'][x.resource_name], 85 | 'output' : lambda x: self.config['output'][x.resource_name], 86 | 'data' : lambda x: self.config['data'][x.resource_name], 87 | 'meta' : lambda x: '', 88 | 'provisioner' : lambda x: '', 89 | '' : lambda x: '' } 90 | if node.type in types: 91 | return types[node.type](node) 92 | 93 | # resources are a little different _many_ possible types, 94 | # nested within the 'resource' field. 95 | else: 96 | return self.config['resource'][node.type][node.resource_name] 97 | except: 98 | return '' 99 | -------------------------------------------------------------------------------- /blastradius/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/28mm/blast-radius/a7ec4ef78141ab0d2a688c65112f799adb9622ba/blastradius/server/__init__.py -------------------------------------------------------------------------------- /blastradius/server/server.py: -------------------------------------------------------------------------------- 1 | # standard libraries 2 | import os 3 | import subprocess 4 | import itertools 5 | import json 6 | 7 | # 3rd-party libraries 8 | from flask import Flask 9 | from flask import render_template 10 | from flask import request 11 | import jinja2 12 | 13 | # 1st-party libraries 14 | from blastradius.handlers.dot import DotGraph, Format, DotNode 15 | from blastradius.handlers.terraform import Terraform 16 | from blastradius.util import which 17 | from blastradius.graph import Node, Edge, Counter, Graph 18 | 19 | app = Flask(__name__) 20 | 21 | @app.route('/') 22 | def index(): 23 | # we need terraform, graphviz, and an init-ed terraform project. 24 | if not which('terraform') and not which('terraform.exe'): 25 | return render_template('error.html') 26 | elif not which('dot') and not which('dot.exe'): 27 | return render_template('error.html') 28 | elif not os.path.exists('.terraform'): 29 | return render_template('error.html') 30 | else: 31 | return render_template('index.html', help=get_help()) 32 | 33 | @app.route('/graph.svg') 34 | def graph_svg(): 35 | Graph.reset_counters() 36 | dot = DotGraph('', file_contents=run_tf_graph()) 37 | 38 | module_depth = request.args.get('module_depth', default=None, type=int) 39 | refocus = request.args.get('refocus', default=None, type=str) 40 | 41 | if module_depth is not None and module_depth >= 0: 42 | dot.set_module_depth(module_depth) 43 | 44 | if refocus is not None: 45 | node = dot.get_node_by_name(refocus) 46 | if node: 47 | dot.center(node) 48 | 49 | return dot.svg() 50 | 51 | 52 | @app.route('/graph.json') 53 | def graph_json(): 54 | Graph.reset_counters() 55 | dot = DotGraph('', file_contents=run_tf_graph()) 56 | module_depth = request.args.get('module_depth', default=None, type=int) 57 | refocus = request.args.get('refocus', default=None, type=str) 58 | if module_depth is not None and module_depth >= 0: 59 | dot.set_module_depth(module_depth) 60 | 61 | tf = Terraform(os.getcwd()) 62 | for node in dot.nodes: 63 | node.definition = tf.get_def(node) 64 | 65 | if refocus is not None: 66 | node = dot.get_node_by_name(refocus) 67 | if node: 68 | dot.center(node) 69 | 70 | return dot.json() 71 | 72 | def run_tf_graph(): 73 | completed = subprocess.run(['terraform', 'graph'], stdout=subprocess.PIPE) 74 | if completed.returncode != 0: 75 | raise Exception('Execution error', completed.stderr) 76 | return completed.stdout.decode('utf-8') 77 | 78 | def get_help(): 79 | return { 'tf_version' : get_terraform_version(), 80 | 'tf_exe' : get_terraform_exe(), 81 | 'cwd' : os.getcwd() } 82 | 83 | def get_terraform_version(): 84 | completed = subprocess.run(['terraform', '--version'], stdout=subprocess.PIPE) 85 | if completed.returncode != 0: 86 | raise 87 | return completed.stdout.decode('utf-8').splitlines()[0].split(' ')[-1] 88 | 89 | def get_terraform_exe(): 90 | return which('terraform') 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /blastradius/server/static/css/selectize.css: -------------------------------------------------------------------------------- 1 | /** 2 | * selectize.css (v0.12.4) 3 | * Copyright (c) 2013–2015 Brian Reavis & contributors 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this 6 | * file except in compliance with the License. You may obtain a copy of the License at: 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under 10 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 11 | * ANY KIND, either express or implied. See the License for the specific language 12 | * governing permissions and limitations under the License. 13 | * 14 | * @author Brian Reavis 15 | */ 16 | 17 | .selectize-control.plugin-drag_drop.multi > .selectize-input > div.ui-sortable-placeholder { 18 | visibility: visible !important; 19 | background: #f2f2f2 !important; 20 | background: rgba(0, 0, 0, 0.06) !important; 21 | border: 0 none !important; 22 | -webkit-box-shadow: inset 0 0 12px 4px #ffffff; 23 | box-shadow: inset 0 0 12px 4px #ffffff; 24 | } 25 | .selectize-control.plugin-drag_drop .ui-sortable-placeholder::after { 26 | content: '!'; 27 | visibility: hidden; 28 | } 29 | .selectize-control.plugin-drag_drop .ui-sortable-helper { 30 | -webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 31 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 32 | } 33 | .selectize-dropdown-header { 34 | position: relative; 35 | padding: 5px 8px; 36 | border-bottom: 1px solid #d0d0d0; 37 | background: #f8f8f8; 38 | -webkit-border-radius: 3px 3px 0 0; 39 | -moz-border-radius: 3px 3px 0 0; 40 | border-radius: 3px 3px 0 0; 41 | } 42 | .selectize-dropdown-header-close { 43 | position: absolute; 44 | right: 8px; 45 | top: 50%; 46 | color: #303030; 47 | opacity: 0.4; 48 | margin-top: -12px; 49 | line-height: 20px; 50 | font-size: 20px !important; 51 | } 52 | .selectize-dropdown-header-close:hover { 53 | color: #000000; 54 | } 55 | .selectize-dropdown.plugin-optgroup_columns .optgroup { 56 | border-right: 1px solid #f2f2f2; 57 | border-top: 0 none; 58 | float: left; 59 | -webkit-box-sizing: border-box; 60 | -moz-box-sizing: border-box; 61 | box-sizing: border-box; 62 | } 63 | .selectize-dropdown.plugin-optgroup_columns .optgroup:last-child { 64 | border-right: 0 none; 65 | } 66 | .selectize-dropdown.plugin-optgroup_columns .optgroup:before { 67 | display: none; 68 | } 69 | .selectize-dropdown.plugin-optgroup_columns .optgroup-header { 70 | border-top: 0 none; 71 | } 72 | .selectize-control.plugin-remove_button [data-value] { 73 | position: relative; 74 | padding-right: 24px !important; 75 | } 76 | .selectize-control.plugin-remove_button [data-value] .remove { 77 | z-index: 1; 78 | /* fixes ie bug (see #392) */ 79 | position: absolute; 80 | top: 0; 81 | right: 0; 82 | bottom: 0; 83 | width: 17px; 84 | text-align: center; 85 | font-weight: bold; 86 | font-size: 12px; 87 | color: inherit; 88 | text-decoration: none; 89 | vertical-align: middle; 90 | display: inline-block; 91 | padding: 2px 0 0 0; 92 | border-left: 1px solid #d0d0d0; 93 | -webkit-border-radius: 0 2px 2px 0; 94 | -moz-border-radius: 0 2px 2px 0; 95 | border-radius: 0 2px 2px 0; 96 | -webkit-box-sizing: border-box; 97 | -moz-box-sizing: border-box; 98 | box-sizing: border-box; 99 | } 100 | .selectize-control.plugin-remove_button [data-value] .remove:hover { 101 | background: rgba(0, 0, 0, 0.05); 102 | } 103 | .selectize-control.plugin-remove_button [data-value].active .remove { 104 | border-left-color: #cacaca; 105 | } 106 | .selectize-control.plugin-remove_button .disabled [data-value] .remove:hover { 107 | background: none; 108 | } 109 | .selectize-control.plugin-remove_button .disabled [data-value] .remove { 110 | border-left-color: #ffffff; 111 | } 112 | .selectize-control.plugin-remove_button .remove-single { 113 | position: absolute; 114 | right: 28px; 115 | top: 6px; 116 | font-size: 23px; 117 | } 118 | .selectize-control { 119 | position: relative; 120 | } 121 | .selectize-dropdown, 122 | .selectize-input, 123 | .selectize-input input { 124 | color: #303030; 125 | font-family: inherit; 126 | font-size: 13px; 127 | line-height: 18px; 128 | -webkit-font-smoothing: inherit; 129 | } 130 | .selectize-input, 131 | .selectize-control.single .selectize-input.input-active { 132 | background: #ffffff; 133 | cursor: text; 134 | display: inline-block; 135 | } 136 | .selectize-input { 137 | border: 1px solid #d0d0d0; 138 | padding: 8px 8px; 139 | display: inline-block; 140 | width: 100%; 141 | overflow: hidden; 142 | position: relative; 143 | z-index: 1; 144 | -webkit-box-sizing: border-box; 145 | -moz-box-sizing: border-box; 146 | box-sizing: border-box; 147 | -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1); 148 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1); 149 | -webkit-border-radius: 3px; 150 | -moz-border-radius: 3px; 151 | border-radius: 3px; 152 | } 153 | .selectize-control.multi .selectize-input.has-items { 154 | padding: 6px 8px 3px; 155 | } 156 | .selectize-input.full { 157 | background-color: #ffffff; 158 | } 159 | .selectize-input.disabled, 160 | .selectize-input.disabled * { 161 | cursor: default !important; 162 | } 163 | .selectize-input.focus { 164 | -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); 165 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); 166 | } 167 | .selectize-input.dropdown-active { 168 | -webkit-border-radius: 3px 3px 0 0; 169 | -moz-border-radius: 3px 3px 0 0; 170 | border-radius: 3px 3px 0 0; 171 | } 172 | .selectize-input > * { 173 | vertical-align: baseline; 174 | display: -moz-inline-stack; 175 | display: inline-block; 176 | zoom: 1; 177 | *display: inline; 178 | } 179 | .selectize-control.multi .selectize-input > div { 180 | cursor: pointer; 181 | margin: 0 3px 3px 0; 182 | padding: 2px 6px; 183 | background: #f2f2f2; 184 | color: #303030; 185 | border: 0 solid #d0d0d0; 186 | } 187 | .selectize-control.multi .selectize-input > div.active { 188 | background: #e8e8e8; 189 | color: #303030; 190 | border: 0 solid #cacaca; 191 | } 192 | .selectize-control.multi .selectize-input.disabled > div, 193 | .selectize-control.multi .selectize-input.disabled > div.active { 194 | color: #7d7d7d; 195 | background: #ffffff; 196 | border: 0 solid #ffffff; 197 | } 198 | .selectize-input > input { 199 | display: inline-block !important; 200 | padding: 0 !important; 201 | min-height: 0 !important; 202 | max-height: none !important; 203 | max-width: 100% !important; 204 | margin: 0 2px 0 0 !important; 205 | text-indent: 0 !important; 206 | border: 0 none !important; 207 | background: none !important; 208 | line-height: inherit !important; 209 | -webkit-user-select: auto !important; 210 | -webkit-box-shadow: none !important; 211 | box-shadow: none !important; 212 | } 213 | .selectize-input > input::-ms-clear { 214 | display: none; 215 | } 216 | .selectize-input > input:focus { 217 | outline: none !important; 218 | } 219 | .selectize-input::after { 220 | content: ' '; 221 | display: block; 222 | clear: left; 223 | } 224 | .selectize-input.dropdown-active::before { 225 | content: ' '; 226 | display: block; 227 | position: absolute; 228 | background: #f0f0f0; 229 | height: 1px; 230 | bottom: 0; 231 | left: 0; 232 | right: 0; 233 | } 234 | .selectize-dropdown { 235 | position: absolute; 236 | z-index: 10; 237 | border: 1px solid #d0d0d0; 238 | background: #ffffff; 239 | margin: -1px 0 0 0; 240 | border-top: 0 none; 241 | -webkit-box-sizing: border-box; 242 | -moz-box-sizing: border-box; 243 | box-sizing: border-box; 244 | -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 245 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 246 | -webkit-border-radius: 0 0 3px 3px; 247 | -moz-border-radius: 0 0 3px 3px; 248 | border-radius: 0 0 3px 3px; 249 | } 250 | .selectize-dropdown [data-selectable] { 251 | cursor: pointer; 252 | overflow: hidden; 253 | } 254 | .selectize-dropdown [data-selectable] .highlight { 255 | background: rgba(125, 168, 208, 0.2); 256 | -webkit-border-radius: 1px; 257 | -moz-border-radius: 1px; 258 | border-radius: 1px; 259 | } 260 | .selectize-dropdown [data-selectable], 261 | .selectize-dropdown .optgroup-header { 262 | padding: 5px 8px; 263 | } 264 | .selectize-dropdown .optgroup:first-child .optgroup-header { 265 | border-top: 0 none; 266 | } 267 | .selectize-dropdown .optgroup-header { 268 | color: #303030; 269 | background: #ffffff; 270 | cursor: default; 271 | } 272 | .selectize-dropdown .active { 273 | background-color: #f5fafd; 274 | color: #495c68; 275 | } 276 | .selectize-dropdown .active.create { 277 | color: #495c68; 278 | } 279 | .selectize-dropdown .create { 280 | color: rgba(48, 48, 48, 0.5); 281 | } 282 | .selectize-dropdown-content { 283 | overflow-y: auto; 284 | overflow-x: hidden; 285 | max-height: 200px; 286 | -webkit-overflow-scrolling: touch; 287 | } 288 | .selectize-control.single .selectize-input, 289 | .selectize-control.single .selectize-input input { 290 | cursor: pointer; 291 | } 292 | .selectize-control.single .selectize-input.input-active, 293 | .selectize-control.single .selectize-input.input-active input { 294 | cursor: text; 295 | } 296 | .selectize-control.single .selectize-input:after { 297 | content: ' '; 298 | display: block; 299 | position: absolute; 300 | top: 50%; 301 | right: 15px; 302 | margin-top: -3px; 303 | width: 0; 304 | height: 0; 305 | border-style: solid; 306 | border-width: 5px 5px 0 5px; 307 | border-color: #808080 transparent transparent transparent; 308 | } 309 | .selectize-control.single .selectize-input.dropdown-active:after { 310 | margin-top: -4px; 311 | border-width: 0 5px 5px 5px; 312 | border-color: transparent transparent #808080 transparent; 313 | } 314 | .selectize-control.rtl.single .selectize-input:after { 315 | left: 15px; 316 | right: auto; 317 | } 318 | .selectize-control.rtl .selectize-input > input { 319 | margin: 0 4px 0 -2px !important; 320 | } 321 | .selectize-control .selectize-input.disabled { 322 | opacity: 0.5; 323 | background-color: #fafafa; 324 | } 325 | -------------------------------------------------------------------------------- /blastradius/server/static/css/style.css: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4, p { 2 | font-family: monospace; 3 | } 4 | 5 | p.explain { 6 | font-family: monospace; 7 | white-space: pre; 8 | } 9 | 10 | h3.explain { 11 | font-family: monospace; 12 | } 13 | 14 | path.link { 15 | fill: none; 16 | stroke: #666; 17 | stroke-width: 1.5px; 18 | } 19 | 20 | circle { 21 | stroke: #fff; 22 | stroke-width: 1.5px; 23 | } 24 | 25 | text { 26 | fill: #000; 27 | font: 10px sans-serif; 28 | pointer-events: none; 29 | } 30 | 31 | div.container { 32 | margin: 30px; 33 | /*overflow: scroll;*/ 34 | } 35 | 36 | div.graph { 37 | width: 100%; 38 | height: 100%; 39 | } 40 | 41 | div.graph svg { 42 | width: 100%; 43 | height: 100%; 44 | } 45 | 46 | .diagmenu { 47 | width: 500px; 48 | } 49 | 50 | .diagmenu .dropdown-item { 51 | font-family: 'courier new'; 52 | /*font-size: 14px;*/ 53 | } 54 | 55 | .dropdown-menu div label { 56 | font-family: 'courier new'; 57 | } 58 | 59 | /* tooltip stuff */ 60 | .d3-tip { 61 | line-height: 1.4; 62 | font-weight: normal; 63 | font-family: 'courier new', 'monaco', fixed-width; 64 | font-size: 14px; 65 | padding: 6px; 66 | background: rgba(0, 0, 0, 0.8); 67 | color: #fff; 68 | border-radius: 2px; 69 | z-index: 91; 70 | min-width: 300px; 71 | /*max-width: 600px;*/ 72 | } 73 | 74 | .d3-tip p { 75 | font-weight: normal; 76 | line-height: 1.2; 77 | font-family: 'courier new', 'monaco', fixed-width; 78 | font-size: 11px; 79 | } 80 | 81 | div.title { 82 | width: 100%; 83 | display: block; 84 | } 85 | 86 | span.title { 87 | display: inline-block; 88 | font-size: 14px; 89 | line-height: 17px; 90 | color: white; 91 | margin: 1px; 92 | } 93 | 94 | span.sbox-listing { 95 | display: inline-block; 96 | font-size: 12px; 97 | font-family: "Courier New"; 98 | line-height: 18px; 99 | color: black; 100 | margin: 1px; 101 | padding: 2px; 102 | } 103 | 104 | span.dep { 105 | line-height: 15px; 106 | font-size: 12px; 107 | color: white; 108 | display: inline-block; 109 | margin: 1px; 110 | } 111 | 112 | /* Creates a small triangle extender for the tooltip */ 113 | .d3-tip:after { 114 | box-sizing: border-box; 115 | display: inline; 116 | font-size: 12px; 117 | width: 100%; 118 | line-height: 1; 119 | color: rgba(0, 0, 0, 0.8); 120 | content: "\25BC"; 121 | position: absolute; 122 | text-align: center; 123 | z-index: 91; 124 | } 125 | 126 | /* Style northward tooltips differently */ 127 | .d3-tip.n:after { 128 | margin: -1px 0 0 0; 129 | top: 100%; 130 | left: 0; 131 | z-index: 91; 132 | } 133 | 134 | g.node text { 135 | font-family: 'consolas', 'monaco', 'fixed-width'; 136 | font-size: 8px; 137 | } 138 | -------------------------------------------------------------------------------- /blastradius/server/static/example/demo-1/demo-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "simple_name": "aws_iam_role.default", 5 | "definition": {}, 6 | "group": 20000, 7 | "svg_id": "node_0", 8 | "label": "[root] aws_iam_role.default", 9 | "type": "aws_iam_role", 10 | "resource_name": "default", 11 | "def": { 12 | "name": "terraform_lambda_alexa_example", 13 | "assume_role_policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Action\": \"sts:AssumeRole\",\n \"Principal\": {\n \"Service\": \"lambda.amazonaws.com\"\n },\n \"Effect\": \"Allow\",\n \"Sid\": \"\"\n }\n ]\n}" 14 | } 15 | }, 16 | { 17 | "simple_name": "aws_iam_role_policy.default", 18 | "definition": {}, 19 | "group": 20000, 20 | "svg_id": "node_1", 21 | "label": "[root] aws_iam_role_policy.default", 22 | "type": "aws_iam_role_policy", 23 | "resource_name": "default", 24 | "def": { 25 | "name": "terraform_lambda_alexa_example", 26 | "role": "${aws_iam_role.default.id}", 27 | "policy": "{\n \"Version\": \"2012-10-17\",\n \"Statement\": [\n {\n \"Effect\": \"Allow\",\n \"Action\": [\n \"logs:CreateLogGroup\",\n \"logs:CreateLogStream\",\n \"logs:PutLogEvents\"\n ],\n \"Resource\": \"*\"\n }\n ]\n}" 28 | } 29 | }, 30 | { 31 | "simple_name": "aws_lambda_function.default", 32 | "definition": {}, 33 | "group": 20000, 34 | "svg_id": "node_2", 35 | "label": "[root] aws_lambda_function.default", 36 | "type": "aws_lambda_function", 37 | "resource_name": "default", 38 | "def": { 39 | "filename": "lambda_function.zip", 40 | "source_code_hash": "${base64sha256(file(\"lambda_function.zip\"))}", 41 | "function_name": "terraform_lambda_alexa_example", 42 | "role": "${aws_iam_role.default.arn}", 43 | "handler": "lambda_function.lambda_handler", 44 | "runtime": "python2.7" 45 | } 46 | }, 47 | { 48 | "simple_name": "aws_lambda_permission.default", 49 | "definition": {}, 50 | "group": 20000, 51 | "svg_id": "node_3", 52 | "label": "[root] aws_lambda_permission.default", 53 | "type": "aws_lambda_permission", 54 | "resource_name": "default", 55 | "def": { 56 | "statement_id": "AllowExecutionFromAlexa", 57 | "action": "lambda:InvokeFunction", 58 | "function_name": "${aws_lambda_function.default.function_name}", 59 | "principal": "alexa-appkit.amazon.com" 60 | } 61 | }, 62 | { 63 | "simple_name": "provider.aws", 64 | "definition": {}, 65 | "group": 20000, 66 | "svg_id": "node_4", 67 | "label": "[root] provider.aws", 68 | "type": "provider", 69 | "resource_name": "aws", 70 | "def": [] 71 | }, 72 | { 73 | "simple_name": "meta.count-boundary (count boundary fixup)", 74 | "definition": {}, 75 | "group": 20000, 76 | "svg_id": "node_5", 77 | "label": "[root] meta.count-boundary (count boundary fixup)", 78 | "type": "meta", 79 | "resource_name": "count-boundary", 80 | "def": [] 81 | }, 82 | { 83 | "simple_name": "output.aws_lambda_function_arn", 84 | "definition": {}, 85 | "group": 20000, 86 | "svg_id": "node_6", 87 | "label": "[root] output.aws_lambda_function_arn", 88 | "type": "output", 89 | "resource_name": "aws_lambda_function_arn", 90 | "def": [] 91 | }, 92 | { 93 | "simple_name": "provider.aws (close)", 94 | "definition": {}, 95 | "group": 20000, 96 | "svg_id": "node_7", 97 | "label": "[root] provider.aws (close)", 98 | "type": "provider", 99 | "resource_name": "aws", 100 | "def": [] 101 | }, 102 | { 103 | "simple_name": "var.aws_region", 104 | "definition": {}, 105 | "group": 20000, 106 | "svg_id": "node_8", 107 | "label": "[root] var.aws_region", 108 | "type": "var", 109 | "resource_name": "aws_region", 110 | "def": { 111 | "description": "The AWS region to create things in.", 112 | "default": "us-east-1" 113 | } 114 | }, 115 | { 116 | "simple_name": "root", 117 | "definition": {}, 118 | "group": 20000, 119 | "svg_id": "node_9", 120 | "label": "[root] root", 121 | "type": "", 122 | "resource_name": "", 123 | "def": [] 124 | } 125 | ], 126 | "edges": [ 127 | { 128 | "target": "[root] provider.aws", 129 | "svg_id": "edge_0", 130 | "source": "[root] aws_iam_role.default" 131 | }, 132 | { 133 | "target": "[root] aws_iam_role.default", 134 | "svg_id": "edge_1", 135 | "source": "[root] aws_iam_role_policy.default" 136 | }, 137 | { 138 | "target": "[root] aws_iam_role.default", 139 | "svg_id": "edge_2", 140 | "source": "[root] aws_lambda_function.default" 141 | }, 142 | { 143 | "target": "[root] aws_lambda_function.default", 144 | "svg_id": "edge_3", 145 | "source": "[root] aws_lambda_permission.default" 146 | }, 147 | { 148 | "target": "[root] aws_iam_role_policy.default", 149 | "svg_id": "edge_4", 150 | "source": "[root] meta.count-boundary (count boundary fixup)" 151 | }, 152 | { 153 | "target": "[root] aws_lambda_permission.default", 154 | "svg_id": "edge_5", 155 | "source": "[root] meta.count-boundary (count boundary fixup)" 156 | }, 157 | { 158 | "target": "[root] output.aws_lambda_function_arn", 159 | "svg_id": "edge_6", 160 | "source": "[root] meta.count-boundary (count boundary fixup)" 161 | }, 162 | { 163 | "target": "[root] aws_lambda_function.default", 164 | "svg_id": "edge_7", 165 | "source": "[root] output.aws_lambda_function_arn" 166 | }, 167 | { 168 | "target": "[root] aws_iam_role_policy.default", 169 | "svg_id": "edge_8", 170 | "source": "[root] provider.aws (close)" 171 | }, 172 | { 173 | "target": "[root] aws_lambda_permission.default", 174 | "svg_id": "edge_9", 175 | "source": "[root] provider.aws (close)" 176 | }, 177 | { 178 | "target": "[root] var.aws_region", 179 | "svg_id": "edge_10", 180 | "source": "[root] provider.aws" 181 | }, 182 | { 183 | "target": "[root] meta.count-boundary (count boundary fixup)", 184 | "svg_id": "edge_11", 185 | "source": "[root] root" 186 | }, 187 | { 188 | "target": "[root] provider.aws (close)", 189 | "svg_id": "edge_12", 190 | "source": "[root] root" 191 | } 192 | ] 193 | } 194 | -------------------------------------------------------------------------------- /blastradius/server/static/example/demo-1/demo-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 14 | [root] aws_iam_role.default 15 | 16 | aws_iam_role 17 | 18 | default 19 | 20 | 21 | 22 | [root] provider.aws 23 | 24 | provider 25 | 26 | aws 27 | 28 | 29 | 30 | [root] aws_iam_role.default->[root] provider.aws 31 | 32 | 33 | 34 | 35 | 36 | [root] aws_iam_role_policy.default 37 | 38 | aws_iam_role_policy 39 | 40 | default 41 | 42 | 43 | 44 | [root] aws_iam_role_policy.default->[root] aws_iam_role.default 45 | 46 | 47 | 48 | 49 | 50 | [root] aws_lambda_function.default 51 | 52 | aws_lambda_function 53 | 54 | default 55 | 56 | 57 | 58 | [root] aws_lambda_function.default->[root] aws_iam_role.default 59 | 60 | 61 | 62 | 63 | 64 | [root] aws_lambda_permission.default 65 | 66 | aws_lambda_permission 67 | 68 | default 69 | 70 | 71 | 72 | [root] aws_lambda_permission.default->[root] aws_lambda_function.default 73 | 74 | 75 | 76 | 77 | 78 | [root] var.aws_region 79 | 80 | var 81 | 82 | aws_region 83 | 84 | 85 | 86 | [root] provider.aws->[root] var.aws_region 87 | 88 | 89 | 90 | 91 | 92 | [root] meta.count-boundary (count boundary fixup) 93 | 94 | meta 95 | 96 | count-boundary 97 | 98 | 99 | 100 | [root] meta.count-boundary (count boundary fixup)->[root] aws_iam_role_policy.default 101 | 102 | 103 | 104 | 105 | 106 | [root] meta.count-boundary (count boundary fixup)->[root] aws_lambda_permission.default 107 | 108 | 109 | 110 | 111 | 112 | [root] output.aws_lambda_function_arn 113 | 114 | output 115 | 116 | aws_lambda_function_arn 117 | 118 | 119 | 120 | [root] meta.count-boundary (count boundary fixup)->[root] output.aws_lambda_function_arn 121 | 122 | 123 | 124 | 125 | 126 | [root] output.aws_lambda_function_arn->[root] aws_lambda_function.default 127 | 128 | 129 | 130 | 131 | 132 | [root] provider.aws (close) 133 | 134 | provider 135 | 136 | aws 137 | 138 | 139 | 140 | [root] provider.aws (close)->[root] aws_iam_role_policy.default 141 | 142 | 143 | 144 | 145 | 146 | [root] provider.aws (close)->[root] aws_lambda_permission.default 147 | 148 | 149 | 150 | 151 | 152 | [root] root 153 | 154 | [root] root 155 | 156 | 157 | 158 | [root] root->[root] meta.count-boundary (count boundary fixup) 159 | 160 | 161 | 162 | 163 | 164 | [root] root->[root] provider.aws (close) 165 | 166 | 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /blastradius/server/static/example/demo-2/demo-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "svg_id": "node_0", 5 | "type": "digitalocean_domain", 6 | "simple_name": "digitalocean_domain.mywebserver", 7 | "resource_name": "mywebserver", 8 | "definition": {}, 9 | "group": 20000, 10 | "label": "[root] digitalocean_domain.mywebserver", 11 | "def": { 12 | "name": "www.mywebserver.com", 13 | "ip_address": "${digitalocean_droplet.mywebserver.ipv4_address}" 14 | } 15 | }, 16 | { 17 | "svg_id": "node_1", 18 | "type": "digitalocean_droplet", 19 | "simple_name": "digitalocean_droplet.mywebserver", 20 | "resource_name": "mywebserver", 21 | "definition": {}, 22 | "group": 20000, 23 | "label": "[root] digitalocean_droplet.mywebserver", 24 | "def": { 25 | "ssh_keys": [ 26 | 12345678 27 | ], 28 | "image": "${var.ubuntu}", 29 | "region": "${var.do_ams3}", 30 | "size": "512mb", 31 | "private_networking": true, 32 | "backups": true, 33 | "ipv6": true, 34 | "name": "mywebserver-ams3", 35 | "provisioner": { 36 | "remote-exec": { 37 | "inline": [ 38 | "export PATH=$PATH:/usr/bin", 39 | "sudo apt-get update", 40 | "sudo apt-get -y install nginx" 41 | ], 42 | "connection": { 43 | "type": "ssh", 44 | "private_key": "${file(\"~/.ssh/id_rsa\")}", 45 | "user": "root", 46 | "timeout": "2m" 47 | } 48 | } 49 | } 50 | } 51 | }, 52 | { 53 | "svg_id": "node_2", 54 | "type": "digitalocean_record", 55 | "simple_name": "digitalocean_record.mywebserver", 56 | "resource_name": "mywebserver", 57 | "definition": {}, 58 | "group": 20000, 59 | "label": "[root] digitalocean_record.mywebserver", 60 | "def": { 61 | "domain": "${digitalocean_domain.mywebserver.name}", 62 | "type": "A", 63 | "name": "mywebserver", 64 | "value": "${digitalocean_droplet.mywebserver.ipv4_address}" 65 | } 66 | }, 67 | { 68 | "svg_id": "node_3", 69 | "type": "provider", 70 | "simple_name": "provider.digitalocean", 71 | "resource_name": "digitalocean", 72 | "definition": {}, 73 | "group": 20000, 74 | "label": "[root] provider.digitalocean", 75 | "def": [] 76 | }, 77 | { 78 | "svg_id": "node_4", 79 | "type": "var", 80 | "simple_name": "var.do_ams3", 81 | "resource_name": "do_ams3", 82 | "definition": {}, 83 | "group": 20000, 84 | "label": "[root] var.do_ams3", 85 | "def": { 86 | "description": "Digital Ocean Amsterdam Data Center 3", 87 | "default": "ams3" 88 | } 89 | }, 90 | { 91 | "svg_id": "node_5", 92 | "type": "var", 93 | "simple_name": "var.ubuntu", 94 | "resource_name": "ubuntu", 95 | "definition": {}, 96 | "group": 20000, 97 | "label": "[root] var.ubuntu", 98 | "def": { 99 | "description": "Default LTS", 100 | "default": "ubuntu-16-04-x64" 101 | } 102 | }, 103 | { 104 | "svg_id": "node_6", 105 | "type": "meta", 106 | "simple_name": "meta.count-boundary (count boundary fixup)", 107 | "resource_name": "count-boundary", 108 | "definition": {}, 109 | "group": 20000, 110 | "label": "[root] meta.count-boundary (count boundary fixup)", 111 | "def": [] 112 | }, 113 | { 114 | "svg_id": "node_7", 115 | "type": "output", 116 | "simple_name": "output.Name", 117 | "resource_name": "Name", 118 | "definition": {}, 119 | "group": 20000, 120 | "label": "[root] output.Name", 121 | "def": [] 122 | }, 123 | { 124 | "svg_id": "node_8", 125 | "type": "output", 126 | "simple_name": "output.Public ip", 127 | "resource_name": "Public", 128 | "definition": {}, 129 | "group": 20000, 130 | "label": "[root] output.Public ip", 131 | "def": [] 132 | }, 133 | { 134 | "svg_id": "node_9", 135 | "type": "var", 136 | "simple_name": "var.centos", 137 | "resource_name": "centos", 138 | "definition": {}, 139 | "group": 20000, 140 | "label": "[root] var.centos", 141 | "def": { 142 | "description": "Default Centos", 143 | "default": "centos-72-x64" 144 | } 145 | }, 146 | { 147 | "svg_id": "node_10", 148 | "type": "var", 149 | "simple_name": "var.coreos", 150 | "resource_name": "coreos", 151 | "definition": {}, 152 | "group": 20000, 153 | "label": "[root] var.coreos", 154 | "def": { 155 | "description": "Defaut Coreos", 156 | "default": "coreos-899.17.0" 157 | } 158 | }, 159 | { 160 | "svg_id": "node_11", 161 | "type": "var", 162 | "simple_name": "var.do_ams2", 163 | "resource_name": "do_ams2", 164 | "definition": {}, 165 | "group": 20000, 166 | "label": "[root] var.do_ams2", 167 | "def": { 168 | "description": "Digital Ocean Amsterdam Data Center 2", 169 | "default": "ams2" 170 | } 171 | }, 172 | { 173 | "svg_id": "node_12", 174 | "type": "var", 175 | "simple_name": "var.do_blr1", 176 | "resource_name": "do_blr1", 177 | "definition": {}, 178 | "group": 20000, 179 | "label": "[root] var.do_blr1", 180 | "def": { 181 | "description": "Digital Ocean Bangalore Data Center 1", 182 | "default": "blr1" 183 | } 184 | }, 185 | { 186 | "svg_id": "node_13", 187 | "type": "var", 188 | "simple_name": "var.do_fra1", 189 | "resource_name": "do_fra1", 190 | "definition": {}, 191 | "group": 20000, 192 | "label": "[root] var.do_fra1", 193 | "def": { 194 | "description": "Digital Ocean Frankfurt Data Center 1", 195 | "default": "fra1" 196 | } 197 | }, 198 | { 199 | "svg_id": "node_14", 200 | "type": "var", 201 | "simple_name": "var.do_lon1", 202 | "resource_name": "do_lon1", 203 | "definition": {}, 204 | "group": 20000, 205 | "label": "[root] var.do_lon1", 206 | "def": { 207 | "description": "Digital Ocean London Data Center 1", 208 | "default": "lon1" 209 | } 210 | }, 211 | { 212 | "svg_id": "node_15", 213 | "type": "var", 214 | "simple_name": "var.do_nyc1", 215 | "resource_name": "do_nyc1", 216 | "definition": {}, 217 | "group": 20000, 218 | "label": "[root] var.do_nyc1", 219 | "def": { 220 | "description": "Digital Ocean New York Data Center 1", 221 | "default": "nyc1" 222 | } 223 | }, 224 | { 225 | "svg_id": "node_16", 226 | "type": "var", 227 | "simple_name": "var.do_nyc2", 228 | "resource_name": "do_nyc2", 229 | "definition": {}, 230 | "group": 20000, 231 | "label": "[root] var.do_nyc2", 232 | "def": { 233 | "description": "Digital Ocean New York Data Center 2", 234 | "default": "nyc2" 235 | } 236 | }, 237 | { 238 | "svg_id": "node_17", 239 | "type": "var", 240 | "simple_name": "var.do_nyc3", 241 | "resource_name": "do_nyc3", 242 | "definition": {}, 243 | "group": 20000, 244 | "label": "[root] var.do_nyc3", 245 | "def": { 246 | "description": "Digital Ocean New York Data Center 3", 247 | "default": "nyc3" 248 | } 249 | }, 250 | { 251 | "svg_id": "node_18", 252 | "type": "var", 253 | "simple_name": "var.do_sfo1", 254 | "resource_name": "do_sfo1", 255 | "definition": {}, 256 | "group": 20000, 257 | "label": "[root] var.do_sfo1", 258 | "def": { 259 | "description": "Digital Ocean San Francisco Data Center 1", 260 | "default": "sfo1" 261 | } 262 | }, 263 | { 264 | "svg_id": "node_19", 265 | "type": "var", 266 | "simple_name": "var.do_sgp1", 267 | "resource_name": "do_sgp1", 268 | "definition": {}, 269 | "group": 20000, 270 | "label": "[root] var.do_sgp1", 271 | "def": { 272 | "description": "Digital Ocean Singapore Data Center 1", 273 | "default": "sgp1" 274 | } 275 | }, 276 | { 277 | "svg_id": "node_20", 278 | "type": "var", 279 | "simple_name": "var.do_tor1", 280 | "resource_name": "do_tor1", 281 | "definition": {}, 282 | "group": 20000, 283 | "label": "[root] var.do_tor1", 284 | "def": { 285 | "description": "Digital Ocean Toronto Datacenter 1", 286 | "default": "tor1" 287 | } 288 | }, 289 | { 290 | "svg_id": "node_21", 291 | "type": "provider", 292 | "simple_name": "provider.digitalocean (close)", 293 | "resource_name": "digitalocean", 294 | "definition": {}, 295 | "group": 20000, 296 | "label": "[root] provider.digitalocean (close)", 297 | "def": [] 298 | }, 299 | { 300 | "svg_id": "node_22", 301 | "type": "provisioner", 302 | "simple_name": "provisioner.remote-exec (close)", 303 | "resource_name": "remote-exec", 304 | "definition": {}, 305 | "group": 20000, 306 | "label": "[root] provisioner.remote-exec (close)", 307 | "def": [] 308 | }, 309 | { 310 | "svg_id": "node_23", 311 | "type": "", 312 | "simple_name": "root", 313 | "resource_name": "", 314 | "definition": {}, 315 | "group": 20000, 316 | "label": "[root] root", 317 | "def": [] 318 | } 319 | ], 320 | "edges": [ 321 | { 322 | "svg_id": "edge_0", 323 | "source": "[root] digitalocean_domain.mywebserver", 324 | "target": "[root] digitalocean_droplet.mywebserver" 325 | }, 326 | { 327 | "svg_id": "edge_1", 328 | "source": "[root] digitalocean_droplet.mywebserver", 329 | "target": "[root] provider.digitalocean" 330 | }, 331 | { 332 | "svg_id": "edge_2", 333 | "source": "[root] digitalocean_droplet.mywebserver", 334 | "target": "[root] var.do_ams3" 335 | }, 336 | { 337 | "svg_id": "edge_3", 338 | "source": "[root] digitalocean_droplet.mywebserver", 339 | "target": "[root] var.ubuntu" 340 | }, 341 | { 342 | "svg_id": "edge_4", 343 | "source": "[root] digitalocean_record.mywebserver", 344 | "target": "[root] digitalocean_domain.mywebserver" 345 | }, 346 | { 347 | "svg_id": "edge_5", 348 | "source": "[root] meta.count-boundary (count boundary fixup)", 349 | "target": "[root] digitalocean_record.mywebserver" 350 | }, 351 | { 352 | "svg_id": "edge_6", 353 | "source": "[root] meta.count-boundary (count boundary fixup)", 354 | "target": "[root] output.Name" 355 | }, 356 | { 357 | "svg_id": "edge_7", 358 | "source": "[root] meta.count-boundary (count boundary fixup)", 359 | "target": "[root] output.Public ip" 360 | }, 361 | { 362 | "svg_id": "edge_8", 363 | "source": "[root] meta.count-boundary (count boundary fixup)", 364 | "target": "[root] var.centos" 365 | }, 366 | { 367 | "svg_id": "edge_9", 368 | "source": "[root] meta.count-boundary (count boundary fixup)", 369 | "target": "[root] var.coreos" 370 | }, 371 | { 372 | "svg_id": "edge_10", 373 | "source": "[root] meta.count-boundary (count boundary fixup)", 374 | "target": "[root] var.do_ams2" 375 | }, 376 | { 377 | "svg_id": "edge_11", 378 | "source": "[root] meta.count-boundary (count boundary fixup)", 379 | "target": "[root] var.do_blr1" 380 | }, 381 | { 382 | "svg_id": "edge_12", 383 | "source": "[root] meta.count-boundary (count boundary fixup)", 384 | "target": "[root] var.do_fra1" 385 | }, 386 | { 387 | "svg_id": "edge_13", 388 | "source": "[root] meta.count-boundary (count boundary fixup)", 389 | "target": "[root] var.do_lon1" 390 | }, 391 | { 392 | "svg_id": "edge_14", 393 | "source": "[root] meta.count-boundary (count boundary fixup)", 394 | "target": "[root] var.do_nyc1" 395 | }, 396 | { 397 | "svg_id": "edge_15", 398 | "source": "[root] meta.count-boundary (count boundary fixup)", 399 | "target": "[root] var.do_nyc2" 400 | }, 401 | { 402 | "svg_id": "edge_16", 403 | "source": "[root] meta.count-boundary (count boundary fixup)", 404 | "target": "[root] var.do_nyc3" 405 | }, 406 | { 407 | "svg_id": "edge_17", 408 | "source": "[root] meta.count-boundary (count boundary fixup)", 409 | "target": "[root] var.do_sfo1" 410 | }, 411 | { 412 | "svg_id": "edge_18", 413 | "source": "[root] meta.count-boundary (count boundary fixup)", 414 | "target": "[root] var.do_sgp1" 415 | }, 416 | { 417 | "svg_id": "edge_19", 418 | "source": "[root] meta.count-boundary (count boundary fixup)", 419 | "target": "[root] var.do_tor1" 420 | }, 421 | { 422 | "svg_id": "edge_20", 423 | "source": "[root] output.Name", 424 | "target": "[root] digitalocean_droplet.mywebserver" 425 | }, 426 | { 427 | "svg_id": "edge_21", 428 | "source": "[root] output.Public ip", 429 | "target": "[root] digitalocean_droplet.mywebserver" 430 | }, 431 | { 432 | "svg_id": "edge_22", 433 | "source": "[root] provider.digitalocean (close)", 434 | "target": "[root] digitalocean_record.mywebserver" 435 | }, 436 | { 437 | "svg_id": "edge_23", 438 | "source": "[root] provisioner.remote-exec (close)", 439 | "target": "[root] digitalocean_droplet.mywebserver" 440 | }, 441 | { 442 | "svg_id": "edge_24", 443 | "source": "[root] root", 444 | "target": "[root] meta.count-boundary (count boundary fixup)" 445 | }, 446 | { 447 | "svg_id": "edge_25", 448 | "source": "[root] root", 449 | "target": "[root] provider.digitalocean (close)" 450 | }, 451 | { 452 | "svg_id": "edge_26", 453 | "source": "[root] root", 454 | "target": "[root] provisioner.remote-exec (close)" 455 | } 456 | ] 457 | } 458 | -------------------------------------------------------------------------------- /blastradius/server/static/example/demo-2/demo-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 14 | [root] digitalocean_domain.mywebserver 15 | 16 | digitalocean_domain 17 | 18 | mywebserver 19 | 20 | 21 | 22 | [root] digitalocean_droplet.mywebserver 23 | 24 | digitalocean_droplet 25 | 26 | mywebserver 27 | 28 | 29 | 30 | [root] digitalocean_domain.mywebserver->[root] digitalocean_droplet.mywebserver 31 | 32 | 33 | 34 | 35 | 36 | [root] provider.digitalocean 37 | 38 | provider 39 | 40 | digitalocean 41 | 42 | 43 | 44 | [root] digitalocean_droplet.mywebserver->[root] provider.digitalocean 45 | 46 | 47 | 48 | 49 | 50 | [root] var.do_ams3 51 | 52 | var 53 | 54 | do_ams3 55 | 56 | 57 | 58 | [root] digitalocean_droplet.mywebserver->[root] var.do_ams3 59 | 60 | 61 | 62 | 63 | 64 | [root] var.ubuntu 65 | 66 | var 67 | 68 | ubuntu 69 | 70 | 71 | 72 | [root] digitalocean_droplet.mywebserver->[root] var.ubuntu 73 | 74 | 75 | 76 | 77 | 78 | [root] digitalocean_record.mywebserver 79 | 80 | digitalocean_record 81 | 82 | mywebserver 83 | 84 | 85 | 86 | [root] digitalocean_record.mywebserver->[root] digitalocean_domain.mywebserver 87 | 88 | 89 | 90 | 91 | 92 | [root] meta.count-boundary (count boundary fixup) 93 | 94 | meta 95 | 96 | count-boundary 97 | 98 | 99 | 100 | [root] meta.count-boundary (count boundary fixup)->[root] digitalocean_record.mywebserver 101 | 102 | 103 | 104 | 105 | 106 | [root] output.Name 107 | 108 | output 109 | 110 | Name 111 | 112 | 113 | 114 | [root] meta.count-boundary (count boundary fixup)->[root] output.Name 115 | 116 | 117 | 118 | 119 | 120 | [root] output.Public ip 121 | 122 | output 123 | 124 | Public 125 | 126 | 127 | 128 | [root] meta.count-boundary (count boundary fixup)->[root] output.Public ip 129 | 130 | 131 | 132 | 133 | 134 | [root] var.centos 135 | 136 | var 137 | 138 | centos 139 | 140 | 141 | 142 | [root] meta.count-boundary (count boundary fixup)->[root] var.centos 143 | 144 | 145 | 146 | 147 | 148 | [root] var.coreos 149 | 150 | var 151 | 152 | coreos 153 | 154 | 155 | 156 | [root] meta.count-boundary (count boundary fixup)->[root] var.coreos 157 | 158 | 159 | 160 | 161 | 162 | [root] var.do_ams2 163 | 164 | var 165 | 166 | do_ams2 167 | 168 | 169 | 170 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_ams2 171 | 172 | 173 | 174 | 175 | 176 | [root] var.do_blr1 177 | 178 | var 179 | 180 | do_blr1 181 | 182 | 183 | 184 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_blr1 185 | 186 | 187 | 188 | 189 | 190 | [root] var.do_fra1 191 | 192 | var 193 | 194 | do_fra1 195 | 196 | 197 | 198 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_fra1 199 | 200 | 201 | 202 | 203 | 204 | [root] var.do_lon1 205 | 206 | var 207 | 208 | do_lon1 209 | 210 | 211 | 212 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_lon1 213 | 214 | 215 | 216 | 217 | 218 | [root] var.do_nyc1 219 | 220 | var 221 | 222 | do_nyc1 223 | 224 | 225 | 226 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_nyc1 227 | 228 | 229 | 230 | 231 | 232 | [root] var.do_nyc2 233 | 234 | var 235 | 236 | do_nyc2 237 | 238 | 239 | 240 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_nyc2 241 | 242 | 243 | 244 | 245 | 246 | [root] var.do_nyc3 247 | 248 | var 249 | 250 | do_nyc3 251 | 252 | 253 | 254 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_nyc3 255 | 256 | 257 | 258 | 259 | 260 | [root] var.do_sfo1 261 | 262 | var 263 | 264 | do_sfo1 265 | 266 | 267 | 268 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_sfo1 269 | 270 | 271 | 272 | 273 | 274 | [root] var.do_sgp1 275 | 276 | var 277 | 278 | do_sgp1 279 | 280 | 281 | 282 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_sgp1 283 | 284 | 285 | 286 | 287 | 288 | [root] var.do_tor1 289 | 290 | var 291 | 292 | do_tor1 293 | 294 | 295 | 296 | [root] meta.count-boundary (count boundary fixup)->[root] var.do_tor1 297 | 298 | 299 | 300 | 301 | 302 | [root] output.Name->[root] digitalocean_droplet.mywebserver 303 | 304 | 305 | 306 | 307 | 308 | [root] output.Public ip->[root] digitalocean_droplet.mywebserver 309 | 310 | 311 | 312 | 313 | 314 | [root] provider.digitalocean (close) 315 | 316 | provider 317 | 318 | digitalocean 319 | 320 | 321 | 322 | [root] provider.digitalocean (close)->[root] digitalocean_record.mywebserver 323 | 324 | 325 | 326 | 327 | 328 | [root] provisioner.remote-exec (close) 329 | 330 | provisioner 331 | 332 | remote-exec 333 | 334 | 335 | 336 | [root] provisioner.remote-exec (close)->[root] digitalocean_droplet.mywebserver 337 | 338 | 339 | 340 | 341 | 342 | [root] root 343 | 344 | [root] root 345 | 346 | 347 | 348 | [root] root->[root] meta.count-boundary (count boundary fixup) 349 | 350 | 351 | 352 | 353 | 354 | [root] root->[root] provider.digitalocean (close) 355 | 356 | 357 | 358 | 359 | 360 | [root] root->[root] provisioner.remote-exec (close) 361 | 362 | 363 | 364 | 365 | 366 | -------------------------------------------------------------------------------- /blastradius/server/static/example/demo-3/demo-3.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "simple_name": "aws_elb.web", 5 | "definition": {}, 6 | "svg_id": "node_0", 7 | "group": 20000, 8 | "label": "[root] aws_elb.web", 9 | "resource_name": "web", 10 | "type": "aws_elb", 11 | "def": { 12 | "name": "terraform-example-elb", 13 | "subnets": [ 14 | "${aws_subnet.default.id}" 15 | ], 16 | "security_groups": [ 17 | "${aws_security_group.elb.id}" 18 | ], 19 | "instances": [ 20 | "${aws_instance.web.id}" 21 | ], 22 | "listener": { 23 | "instance_port": 80, 24 | "instance_protocol": "http", 25 | "lb_port": 80, 26 | "lb_protocol": "http" 27 | } 28 | } 29 | }, 30 | { 31 | "simple_name": "aws_instance.web", 32 | "definition": {}, 33 | "svg_id": "node_1", 34 | "group": 20000, 35 | "label": "[root] aws_instance.web", 36 | "resource_name": "web", 37 | "type": "aws_instance", 38 | "def": { 39 | "connection": { 40 | "user": "ubuntu" 41 | }, 42 | "instance_type": "t2.micro", 43 | "ami": "${lookup(var.aws_amis, var.aws_region)}", 44 | "key_name": "${aws_key_pair.auth.id}", 45 | "vpc_security_group_ids": [ 46 | "${aws_security_group.default.id}" 47 | ], 48 | "subnet_id": "${aws_subnet.default.id}", 49 | "provisioner": { 50 | "remote-exec": { 51 | "inline": [ 52 | "sudo apt-get -y update", 53 | "sudo apt-get -y install nginx", 54 | "sudo service nginx start" 55 | ] 56 | } 57 | } 58 | } 59 | }, 60 | { 61 | "simple_name": "aws_internet_gateway.default", 62 | "definition": {}, 63 | "svg_id": "node_2", 64 | "group": 20000, 65 | "label": "[root] aws_internet_gateway.default", 66 | "resource_name": "default", 67 | "type": "aws_internet_gateway", 68 | "def": { 69 | "vpc_id": "${aws_vpc.default.id}" 70 | } 71 | }, 72 | { 73 | "simple_name": "aws_key_pair.auth", 74 | "definition": {}, 75 | "svg_id": "node_3", 76 | "group": 20000, 77 | "label": "[root] aws_key_pair.auth", 78 | "resource_name": "auth", 79 | "type": "aws_key_pair", 80 | "def": { 81 | "key_name": "${var.key_name}", 82 | "public_key": "${file(var.public_key_path)}" 83 | } 84 | }, 85 | { 86 | "simple_name": "aws_route.internet_access", 87 | "definition": {}, 88 | "svg_id": "node_4", 89 | "group": 20000, 90 | "label": "[root] aws_route.internet_access", 91 | "resource_name": "internet_access", 92 | "type": "aws_route", 93 | "def": { 94 | "route_table_id": "${aws_vpc.default.main_route_table_id}", 95 | "destination_cidr_block": "0.0.0.0/0", 96 | "gateway_id": "${aws_internet_gateway.default.id}" 97 | } 98 | }, 99 | { 100 | "simple_name": "aws_security_group.default", 101 | "definition": {}, 102 | "svg_id": "node_5", 103 | "group": 20000, 104 | "label": "[root] aws_security_group.default", 105 | "resource_name": "default", 106 | "type": "aws_security_group", 107 | "def": { 108 | "name": "terraform_example", 109 | "description": "Used in the terraform", 110 | "vpc_id": "${aws_vpc.default.id}", 111 | "ingress": [ 112 | { 113 | "from_port": 22, 114 | "to_port": 22, 115 | "protocol": "tcp", 116 | "cidr_blocks": [ 117 | "0.0.0.0/0" 118 | ] 119 | }, 120 | { 121 | "from_port": 80, 122 | "to_port": 80, 123 | "protocol": "tcp", 124 | "cidr_blocks": [ 125 | "10.0.0.0/16" 126 | ] 127 | } 128 | ], 129 | "egress": { 130 | "from_port": 0, 131 | "to_port": 0, 132 | "protocol": "-1", 133 | "cidr_blocks": [ 134 | "0.0.0.0/0" 135 | ] 136 | } 137 | } 138 | }, 139 | { 140 | "simple_name": "aws_security_group.elb", 141 | "definition": {}, 142 | "svg_id": "node_6", 143 | "group": 20000, 144 | "label": "[root] aws_security_group.elb", 145 | "resource_name": "elb", 146 | "type": "aws_security_group", 147 | "def": { 148 | "name": "terraform_example_elb", 149 | "description": "Used in the terraform", 150 | "vpc_id": "${aws_vpc.default.id}", 151 | "ingress": { 152 | "from_port": 80, 153 | "to_port": 80, 154 | "protocol": "tcp", 155 | "cidr_blocks": [ 156 | "0.0.0.0/0" 157 | ] 158 | }, 159 | "egress": { 160 | "from_port": 0, 161 | "to_port": 0, 162 | "protocol": "-1", 163 | "cidr_blocks": [ 164 | "0.0.0.0/0" 165 | ] 166 | } 167 | } 168 | }, 169 | { 170 | "simple_name": "aws_subnet.default", 171 | "definition": {}, 172 | "svg_id": "node_7", 173 | "group": 20000, 174 | "label": "[root] aws_subnet.default", 175 | "resource_name": "default", 176 | "type": "aws_subnet", 177 | "def": { 178 | "vpc_id": "${aws_vpc.default.id}", 179 | "cidr_block": "10.0.1.0/24", 180 | "map_public_ip_on_launch": true 181 | } 182 | }, 183 | { 184 | "simple_name": "aws_vpc.default", 185 | "definition": {}, 186 | "svg_id": "node_8", 187 | "group": 20000, 188 | "label": "[root] aws_vpc.default", 189 | "resource_name": "default", 190 | "type": "aws_vpc", 191 | "def": { 192 | "cidr_block": "10.0.0.0/16" 193 | } 194 | }, 195 | { 196 | "simple_name": "provider.aws", 197 | "definition": {}, 198 | "svg_id": "node_9", 199 | "group": 20000, 200 | "label": "[root] provider.aws", 201 | "resource_name": "aws", 202 | "type": "provider", 203 | "def": [] 204 | }, 205 | { 206 | "simple_name": "var.aws_amis", 207 | "definition": {}, 208 | "svg_id": "node_10", 209 | "group": 20000, 210 | "label": "[root] var.aws_amis", 211 | "resource_name": "aws_amis", 212 | "type": "var", 213 | "def": { 214 | "default": { 215 | "eu-west-1": "ami-674cbc1e", 216 | "us-east-1": "ami-1d4e7a66", 217 | "us-west-1": "ami-969ab1f6", 218 | "us-west-2": "ami-8803e0f0" 219 | } 220 | } 221 | }, 222 | { 223 | "simple_name": "var.key_name", 224 | "definition": {}, 225 | "svg_id": "node_11", 226 | "group": 20000, 227 | "label": "[root] var.key_name", 228 | "resource_name": "key_name", 229 | "type": "var", 230 | "def": { 231 | "description": "Desired name of AWS key pair" 232 | } 233 | }, 234 | { 235 | "simple_name": "var.public_key_path", 236 | "definition": {}, 237 | "svg_id": "node_12", 238 | "group": 20000, 239 | "label": "[root] var.public_key_path", 240 | "resource_name": "public_key_path", 241 | "type": "var", 242 | "def": { 243 | "description": "Path to the SSH public key to be used for authentication.\nEnsure this keypair is added to your local SSH agent so provisioners can\nconnect.\n\nExample: ~/.ssh/terraform.pub" 244 | } 245 | }, 246 | { 247 | "simple_name": "meta.count-boundary (count boundary fixup)", 248 | "definition": {}, 249 | "svg_id": "node_13", 250 | "group": 20000, 251 | "label": "[root] meta.count-boundary (count boundary fixup)", 252 | "resource_name": "count-boundary", 253 | "type": "meta", 254 | "def": [] 255 | }, 256 | { 257 | "simple_name": "output.address", 258 | "definition": {}, 259 | "svg_id": "node_14", 260 | "group": 20000, 261 | "label": "[root] output.address", 262 | "resource_name": "address", 263 | "type": "output", 264 | "def": [] 265 | }, 266 | { 267 | "simple_name": "provider.aws (close)", 268 | "definition": {}, 269 | "svg_id": "node_15", 270 | "group": 20000, 271 | "label": "[root] provider.aws (close)", 272 | "resource_name": "aws", 273 | "type": "provider", 274 | "def": [] 275 | }, 276 | { 277 | "simple_name": "var.aws_region", 278 | "definition": {}, 279 | "svg_id": "node_16", 280 | "group": 20000, 281 | "label": "[root] var.aws_region", 282 | "resource_name": "aws_region", 283 | "type": "var", 284 | "def": { 285 | "description": "AWS region to launch servers.", 286 | "default": "us-west-2" 287 | } 288 | }, 289 | { 290 | "simple_name": "provisioner.remote-exec (close)", 291 | "definition": {}, 292 | "svg_id": "node_17", 293 | "group": 20000, 294 | "label": "[root] provisioner.remote-exec (close)", 295 | "resource_name": "remote-exec", 296 | "type": "provisioner", 297 | "def": [] 298 | }, 299 | { 300 | "simple_name": "root", 301 | "definition": {}, 302 | "svg_id": "node_18", 303 | "group": 20000, 304 | "label": "[root] root", 305 | "resource_name": "", 306 | "type": "", 307 | "def": [] 308 | } 309 | ], 310 | "edges": [ 311 | { 312 | "target": "[root] aws_instance.web", 313 | "source": "[root] aws_elb.web", 314 | "svg_id": "edge_0" 315 | }, 316 | { 317 | "target": "[root] aws_security_group.elb", 318 | "source": "[root] aws_elb.web", 319 | "svg_id": "edge_1" 320 | }, 321 | { 322 | "target": "[root] aws_key_pair.auth", 323 | "source": "[root] aws_instance.web", 324 | "svg_id": "edge_2" 325 | }, 326 | { 327 | "target": "[root] aws_security_group.default", 328 | "source": "[root] aws_instance.web", 329 | "svg_id": "edge_3" 330 | }, 331 | { 332 | "target": "[root] aws_subnet.default", 333 | "source": "[root] aws_instance.web", 334 | "svg_id": "edge_4" 335 | }, 336 | { 337 | "target": "[root] var.aws_amis", 338 | "source": "[root] aws_instance.web", 339 | "svg_id": "edge_5" 340 | }, 341 | { 342 | "target": "[root] aws_vpc.default", 343 | "source": "[root] aws_internet_gateway.default", 344 | "svg_id": "edge_6" 345 | }, 346 | { 347 | "target": "[root] provider.aws", 348 | "source": "[root] aws_key_pair.auth", 349 | "svg_id": "edge_7" 350 | }, 351 | { 352 | "target": "[root] var.key_name", 353 | "source": "[root] aws_key_pair.auth", 354 | "svg_id": "edge_8" 355 | }, 356 | { 357 | "target": "[root] var.public_key_path", 358 | "source": "[root] aws_key_pair.auth", 359 | "svg_id": "edge_9" 360 | }, 361 | { 362 | "target": "[root] aws_internet_gateway.default", 363 | "source": "[root] aws_route.internet_access", 364 | "svg_id": "edge_10" 365 | }, 366 | { 367 | "target": "[root] aws_vpc.default", 368 | "source": "[root] aws_security_group.default", 369 | "svg_id": "edge_11" 370 | }, 371 | { 372 | "target": "[root] aws_vpc.default", 373 | "source": "[root] aws_security_group.elb", 374 | "svg_id": "edge_12" 375 | }, 376 | { 377 | "target": "[root] aws_vpc.default", 378 | "source": "[root] aws_subnet.default", 379 | "svg_id": "edge_13" 380 | }, 381 | { 382 | "target": "[root] provider.aws", 383 | "source": "[root] aws_vpc.default", 384 | "svg_id": "edge_14" 385 | }, 386 | { 387 | "target": "[root] aws_route.internet_access", 388 | "source": "[root] meta.count-boundary (count boundary fixup)", 389 | "svg_id": "edge_15" 390 | }, 391 | { 392 | "target": "[root] output.address", 393 | "source": "[root] meta.count-boundary (count boundary fixup)", 394 | "svg_id": "edge_16" 395 | }, 396 | { 397 | "target": "[root] aws_elb.web", 398 | "source": "[root] output.address", 399 | "svg_id": "edge_17" 400 | }, 401 | { 402 | "target": "[root] aws_elb.web", 403 | "source": "[root] provider.aws (close)", 404 | "svg_id": "edge_18" 405 | }, 406 | { 407 | "target": "[root] aws_route.internet_access", 408 | "source": "[root] provider.aws (close)", 409 | "svg_id": "edge_19" 410 | }, 411 | { 412 | "target": "[root] var.aws_region", 413 | "source": "[root] provider.aws", 414 | "svg_id": "edge_20" 415 | }, 416 | { 417 | "target": "[root] aws_instance.web", 418 | "source": "[root] provisioner.remote-exec (close)", 419 | "svg_id": "edge_21" 420 | }, 421 | { 422 | "target": "[root] meta.count-boundary (count boundary fixup)", 423 | "source": "[root] root", 424 | "svg_id": "edge_22" 425 | }, 426 | { 427 | "target": "[root] provider.aws (close)", 428 | "source": "[root] root", 429 | "svg_id": "edge_23" 430 | }, 431 | { 432 | "target": "[root] provisioner.remote-exec (close)", 433 | "source": "[root] root", 434 | "svg_id": "edge_24" 435 | } 436 | ] 437 | } 438 | -------------------------------------------------------------------------------- /blastradius/server/static/example/demo-3/demo-3.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 14 | [root] aws_elb.web 15 | 16 | aws_elb 17 | 18 | web 19 | 20 | 21 | 22 | [root] aws_instance.web 23 | 24 | aws_instance 25 | 26 | web 27 | 28 | 29 | 30 | [root] aws_elb.web->[root] aws_instance.web 31 | 32 | 33 | 34 | 35 | 36 | [root] aws_security_group.elb 37 | 38 | aws_security_group 39 | 40 | elb 41 | 42 | 43 | 44 | [root] aws_elb.web->[root] aws_security_group.elb 45 | 46 | 47 | 48 | 49 | 50 | [root] aws_key_pair.auth 51 | 52 | aws_key_pair 53 | 54 | auth 55 | 56 | 57 | 58 | [root] aws_instance.web->[root] aws_key_pair.auth 59 | 60 | 61 | 62 | 63 | 64 | [root] aws_security_group.default 65 | 66 | aws_security_group 67 | 68 | default 69 | 70 | 71 | 72 | [root] aws_instance.web->[root] aws_security_group.default 73 | 74 | 75 | 76 | 77 | 78 | [root] aws_subnet.default 79 | 80 | aws_subnet 81 | 82 | default 83 | 84 | 85 | 86 | [root] aws_instance.web->[root] aws_subnet.default 87 | 88 | 89 | 90 | 91 | 92 | [root] var.aws_amis 93 | 94 | var 95 | 96 | aws_amis 97 | 98 | 99 | 100 | [root] aws_instance.web->[root] var.aws_amis 101 | 102 | 103 | 104 | 105 | 106 | [root] aws_internet_gateway.default 107 | 108 | aws_internet_gateway 109 | 110 | default 111 | 112 | 113 | 114 | [root] aws_vpc.default 115 | 116 | aws_vpc 117 | 118 | default 119 | 120 | 121 | 122 | [root] aws_internet_gateway.default->[root] aws_vpc.default 123 | 124 | 125 | 126 | 127 | 128 | [root] provider.aws 129 | 130 | provider 131 | 132 | aws 133 | 134 | 135 | 136 | [root] aws_key_pair.auth->[root] provider.aws 137 | 138 | 139 | 140 | 141 | 142 | [root] var.key_name 143 | 144 | var 145 | 146 | key_name 147 | 148 | 149 | 150 | [root] aws_key_pair.auth->[root] var.key_name 151 | 152 | 153 | 154 | 155 | 156 | [root] var.public_key_path 157 | 158 | var 159 | 160 | public_key_path 161 | 162 | 163 | 164 | [root] aws_key_pair.auth->[root] var.public_key_path 165 | 166 | 167 | 168 | 169 | 170 | [root] aws_route.internet_access 171 | 172 | aws_route 173 | 174 | internet_access 175 | 176 | 177 | 178 | [root] aws_route.internet_access->[root] aws_internet_gateway.default 179 | 180 | 181 | 182 | 183 | 184 | [root] aws_security_group.default->[root] aws_vpc.default 185 | 186 | 187 | 188 | 189 | 190 | [root] aws_security_group.elb->[root] aws_vpc.default 191 | 192 | 193 | 194 | 195 | 196 | [root] aws_subnet.default->[root] aws_vpc.default 197 | 198 | 199 | 200 | 201 | 202 | [root] aws_vpc.default->[root] provider.aws 203 | 204 | 205 | 206 | 207 | 208 | [root] var.aws_region 209 | 210 | var 211 | 212 | aws_region 213 | 214 | 215 | 216 | [root] provider.aws->[root] var.aws_region 217 | 218 | 219 | 220 | 221 | 222 | [root] meta.count-boundary (count boundary fixup) 223 | 224 | meta 225 | 226 | count-boundary 227 | 228 | 229 | 230 | [root] meta.count-boundary (count boundary fixup)->[root] aws_route.internet_access 231 | 232 | 233 | 234 | 235 | 236 | [root] output.address 237 | 238 | output 239 | 240 | address 241 | 242 | 243 | 244 | [root] meta.count-boundary (count boundary fixup)->[root] output.address 245 | 246 | 247 | 248 | 249 | 250 | [root] output.address->[root] aws_elb.web 251 | 252 | 253 | 254 | 255 | 256 | [root] provider.aws (close) 257 | 258 | provider 259 | 260 | aws 261 | 262 | 263 | 264 | [root] provider.aws (close)->[root] aws_elb.web 265 | 266 | 267 | 268 | 269 | 270 | [root] provider.aws (close)->[root] aws_route.internet_access 271 | 272 | 273 | 274 | 275 | 276 | [root] provisioner.remote-exec (close) 277 | 278 | provisioner 279 | 280 | remote-exec 281 | 282 | 283 | 284 | [root] provisioner.remote-exec (close)->[root] aws_instance.web 285 | 286 | 287 | 288 | 289 | 290 | [root] root 291 | 292 | [root] root 293 | 294 | 295 | 296 | [root] root->[root] meta.count-boundary (count boundary fixup) 297 | 298 | 299 | 300 | 301 | 302 | [root] root->[root] provider.aws (close) 303 | 304 | 305 | 306 | 307 | 308 | [root] root->[root] provisioner.remote-exec (close) 309 | 310 | 311 | 312 | 313 | 314 | -------------------------------------------------------------------------------- /blastradius/server/static/js/blast-radius.js: -------------------------------------------------------------------------------- 1 | // 2 | // terraform-graph.js 3 | // 4 | 5 | // enumerate the various kinds of edges that Blast Radius understands. 6 | // only NORMAL and LAYOUT_SHOWN will show up in the , but all four 7 | // will likely appear in the json representation. 8 | var edge_types = { 9 | NORMAL : 1, // what we talk about when we're talking about edges. 10 | HIDDEN : 2, // these are normal edges, but aren't drawn. 11 | LAYOUT_SHOWN : 3, // these edges are drawn, but aren't "real" edges 12 | LAYOUT_HIDDEN : 4, // these edges are not drawn, aren't "real" edges, but inform layout. 13 | } 14 | 15 | // Sometimes we have escaped newlines (\n) in json strings. we want
instead. 16 | // FIXME: much better line wrapping is probably possible. 17 | var replacer = function (key, value) { 18 | if (typeof value == 'string') { 19 | return value.replace(/\n/g, '
'); 20 | } 21 | return value; 22 | } 23 | 24 | build_uri = function(url, params) { 25 | url += '?' 26 | for (var key in params) 27 | url += key + '=' + params[key] + '&'; 28 | return url.slice(0,-1); 29 | } 30 | 31 | var to_list = function(obj) { 32 | var lst = []; 33 | for (var k in obj) 34 | lst.push(obj[k]); 35 | return lst; 36 | } 37 | 38 | var Queue = function() { 39 | this._oldestIndex = 1; 40 | this._newestIndex = 1; 41 | this._storage = {}; 42 | } 43 | 44 | Queue.prototype.size = function() { 45 | return this._newestIndex - this._oldestIndex; 46 | }; 47 | 48 | Queue.prototype.enqueue = function(data) { 49 | this._storage[this._newestIndex] = data; 50 | this._newestIndex++; 51 | }; 52 | 53 | Queue.prototype.dequeue = function() { 54 | var oldestIndex = this._oldestIndex, 55 | newestIndex = this._newestIndex, 56 | deletedData; 57 | 58 | if (oldestIndex !== newestIndex) { 59 | deletedData = this._storage[oldestIndex]; 60 | delete this._storage[oldestIndex]; 61 | this._oldestIndex++; 62 | 63 | return deletedData; 64 | } 65 | }; 66 | 67 | // Takes a unique selector, e.g. "#demo-1", and 68 | // appends svg xml from svg_url, and takes graph 69 | // info from json_url to highlight/annotate it. 70 | blastradius = function (selector, svg_url, json_url, br_state) { 71 | 72 | // TODO: remove scale. 73 | scale = null 74 | 75 | // mainly for d3-tips 76 | class_selector = '.' + selector.slice(1,selector.length); 77 | 78 | 79 | // we should have an object to keep track of state with, but if we 80 | // don't, just fudge one. 81 | if (! br_state) { 82 | var br_state = {}; 83 | } 84 | 85 | // if we haven't already got an entry in br_state to manage our 86 | // state with, go ahead and create one. 87 | if (! br_state[selector]) { 88 | br_state[selector] = {}; 89 | } 90 | 91 | var state = br_state[selector]; 92 | var container = d3.select(selector); 93 | 94 | // color assignments (resource_type : rgb) are stateful. If we use a new palette 95 | // every time the a subgraph is selected, the color assignments would differ and 96 | // become confusing. 97 | var color = (state['color'] ? state['color'] : d3.scaleOrdinal(d3['schemeCategory20'])); 98 | state['color'] = color; 99 | 100 | //console.log(state); 101 | 102 | // 1st pull down the svg, and append it to the DOM as a child 103 | // of our selector. If added as , we wouldn't 104 | // be able to manipulate x.svg with d3.js, or other DOM fns. 105 | d3.xml(svg_url, function (error, xml) { 106 | 107 | container.node() 108 | .appendChild(document.importNode(xml.documentElement, true)); 109 | 110 | // remove s in svg; graphviz leaves these here and they 111 | // trigger useless tooltips. 112 | d3.select(selector).selectAll('title').remove(); 113 | 114 | // remove any d3-tips we've left lying around 115 | d3.selectAll(class_selector + '-d3-tip').remove(); 116 | 117 | // make sure the svg uses 100% of the viewport, so that pan/zoom works 118 | // as expected and there's no clipping. 119 | d3.select(selector + ' svg').attr('width', '100%').attr('height', '100%'); 120 | 121 | // Obtain the graph description. Doing this within the 122 | // d3.xml success callback, to guaruntee the svg/xml 123 | // has loaded. 124 | d3.json(json_url, function (error, data) { 125 | var edges = data.edges; 126 | var svg_nodes = []; 127 | var nodes = {}; 128 | data.nodes.forEach(function (node) { 129 | if (!(node.type in resource_groups)) 130 | console.log(node.type) 131 | if (node.label == '[root] root') { // FIXME: w/ tf 0.11.2, resource_name not set by server. 132 | node.resource_name = 'root'; 133 | } 134 | node.group = (node.type in resource_groups) ? resource_groups[node.type] : -1; 135 | nodes[node['label']] = node; 136 | svg_nodes.push(node); 137 | }); 138 | 139 | // convenient to access edges by their source. 140 | var edges_by_source = {} 141 | for (var i in edges) { 142 | if(edges[i].source in edges_by_source) 143 | edges_by_source[edges[i].source].push(edges[i]); 144 | else 145 | edges_by_source[edges[i].source] = [edges[i]]; 146 | } 147 | 148 | // convenient access to edges by their target. 149 | var edges_by_target = {} 150 | for (var i in edges) { 151 | if(edges[i].target in edges_by_target) 152 | edges_by_target[edges[i].target].push(edges[i]); 153 | else 154 | edges_by_target[edges[i].target] = [edges[i]]; 155 | } 156 | 157 | var svg = container.select('svg'); 158 | if (scale != null) { 159 | svg.attr('height', scale).attr('width', scale); 160 | } 161 | 162 | var render_tooltip = function(d) { 163 | var title_cbox = document.querySelector(selector + '-tooltip-title'); 164 | var json_cbox = document.querySelector(selector + '-tooltip-json'); 165 | var deps_cbox = document.querySelector(selector + '-tooltip-deps'); 166 | 167 | if ((! title_cbox) || (! json_cbox) || (! deps_cbox)) 168 | return title_html(d) + (d.definition.length == 0 ? '' : "<p class='explain'>" + JSON.stringify(d.definition, replacer, 2) + "</p><br>" + child_html(d)); 169 | 170 | var ttip = ''; 171 | if (title_cbox.checked) 172 | ttip += title_html(d); 173 | if (json_cbox.checked) 174 | ttip += (d.definition.length == 0 ? '' : "<p class='explain'>" + JSON.stringify(d.definition, replacer, 2) + "</p><br>"); 175 | if (deps_cbox.checked) 176 | ttip += child_html(d); 177 | return ttip; 178 | } 179 | 180 | // setup tooltips 181 | var tip = d3.tip() 182 | .attr('class', class_selector.slice(1, class_selector.length) + '-d3-tip d3-tip') 183 | .offset([-10, 0]) 184 | .html(render_tooltip); 185 | svg.call(tip); 186 | 187 | // returns <div> element representinga node's title and module namespace. 188 | var title_html = function(d) { 189 | var node = d; 190 | var title = [ '<div class="header">'] 191 | if (node.modules.length <= 1 && node.modules[0] == 'root') { 192 | title[title.length] = '<span class="title" style="background:' + color(node.group) + ';">' + node.type + '</span>'; 193 | title[title.length] = '<span class="title" style="background:' + color(node.group) + ';">' + node.resource_name + '</span>'; 194 | } 195 | else { 196 | for (var i in node.modules) { 197 | title[title.length] = '<span class="title" style="background: ' + color('(M) ' + node.modules[i]) + ';">' + node.modules[i] + '</span>'; 198 | } 199 | title[title.length] = '<span class="title" style="background:' + color(node.group) + ';">' + node.type + '</span>'; 200 | title[title.length] = '<span class="title" style="background:' + color(node.group) + ';">' + node.resource_name + '</span>'; 201 | } 202 | title[title.length] = '</div>' 203 | return title.join(''); 204 | } 205 | 206 | // returns <div> element representing node's title and module namespace. 207 | // intended for use in an interactive searchbox. 208 | var searchbox_listing = function(d) { 209 | var node = d; 210 | var title = [ '<div class="sbox-listings">'] 211 | if (node.modules.length <= 1 && node.modules[0] == 'root') { 212 | if (node.type) 213 | title[title.length] = '<span class="sbox-listing" style="background:' + color(node.group) + ';">' + node.type + '</span>'; 214 | title[title.length] = '<span class="sbox-listing" style="background:' + color(node.group) + ';">' + node.resource_name + '</span>'; 215 | } 216 | else { 217 | for (var i in node.modules) { 218 | title[title.length] = '<span class="sbox-listing" style="background: ' + color('(M) ' + node.modules[i]) + ';">' + node.modules[i] + '</span>'; 219 | } 220 | title[title.length] = '<span class="sbox-listing" style="background:' + color(node.group) + ';">' + node.type + '</span>'; 221 | title[title.length] = '<span class="sbox-listing" style="background:' + color(node.group) + ';">' + node.resource_name + '</span>'; 222 | } 223 | title[title.length] = '</div>' 224 | return title.join(''); 225 | } 226 | 227 | // returns <span> elements representing a node's direct children 228 | var child_html = function(d) { 229 | var children = []; 230 | var edges = edges_by_source[d.label]; 231 | //console.log(edges); 232 | for (i in edges) { 233 | edge = edges[i]; 234 | if (edge.edge_type == edge_types.NORMAL || edge.edge_type == edge_types.HIDDEN) { 235 | var node = nodes[edge.target]; 236 | if (node.modules.length <= 1 && node.modules[0] == 'root') { 237 | children[children.length] = '<span class="dep" style="background:' + color(node.group) + ';">' + node.type + '</span>'; 238 | children[children.length] = '<span class="dep" style="background:' + color(node.group) + ';">' + node.resource_name + '</span></br>'; 239 | } 240 | else { 241 | for (var i in node.modules) { 242 | children[children.length] = '<span class="dep" style="background: ' + color('(M) ' + node.modules[i]) + ';">' + node.modules[i] + '</span>'; 243 | } 244 | children[children.length] = '<span class="dep" style="background:' + color(node.group) + ';">' + node.type + '</span>'; 245 | children[children.length] = '<span class="dep" style="background:' + color(node.group) + ';">' + node.resource_name + '</span></br>'; 246 | } 247 | 248 | } 249 | } 250 | return children.join(''); 251 | } 252 | 253 | var get_downstream_nodes = function (node) { 254 | var children = {}; 255 | children[node.label] = node; 256 | var visit_queue = new Queue(); 257 | visit_queue.enqueue(node); 258 | while (visit_queue.size() > 0 ) { 259 | var cur_node = visit_queue.dequeue(); 260 | var edges = edges_by_source[cur_node.label]; 261 | for (var i in edges) { 262 | if (edges[i].target in children) 263 | continue; 264 | var n = nodes[edges[i].target]; 265 | children[n.label] = n; 266 | visit_queue.enqueue(n); 267 | } 268 | } 269 | return to_list(children); 270 | } 271 | 272 | var get_upstream_nodes = function (node) { 273 | var parents = {}; 274 | parents[node.label] = node; 275 | var visit_queue = new Queue(); 276 | visit_queue.enqueue(node); 277 | while (visit_queue.size() > 0) { 278 | var cur_node = visit_queue.dequeue(); 279 | var edges = edges_by_target[cur_node.label]; 280 | for (var i in edges) { 281 | if (edges[i].source in parents) 282 | continue; 283 | var n = nodes[edges[i].source]; 284 | parents[n.label] = n; 285 | visit_queue.enqueue(n); 286 | } 287 | } 288 | return to_list(parents); 289 | } 290 | 291 | var get_downstream_edges = function(node) { 292 | var ret_edges = new Set(); 293 | var children = new Set(); 294 | var visit_queue = new Queue(); 295 | 296 | visit_queue.enqueue(node); 297 | while (visit_queue.size() > 0) { 298 | var cur_node = visit_queue.dequeue(); 299 | var edges = edges_by_source[cur_node.label]; 300 | for (var i in edges) { 301 | e = edges[i]; 302 | if (e in ret_edges || e.edge_type == edge_types.HIDDEN || e.edge_type == edge_types.LAYOUT_HIDDEN) 303 | continue; 304 | var n = nodes[edges[i].target]; 305 | ret_edges.add(e); 306 | children.add(n); 307 | visit_queue.enqueue(n); 308 | } 309 | } 310 | return Array.from(ret_edges); 311 | } 312 | 313 | var get_upstream_edges = function(node) { 314 | var ret_edges = new Set(); 315 | var parents = new Set(); 316 | var visit_queue = new Queue(); 317 | 318 | visit_queue.enqueue(node); 319 | while (visit_queue.size() > 0) { 320 | var cur_node = visit_queue.dequeue(); 321 | var edges = edges_by_target[cur_node.label]; 322 | for (var i in edges) { 323 | e = edges[i]; 324 | if (e in ret_edges || e.edge_type == edge_types.HIDDEN || e.edge_type == edge_types.LAYOUT_HIDDEN) 325 | continue; 326 | var n = nodes[edges[i].source]; 327 | ret_edges.add(e); 328 | parents.add(n); 329 | visit_queue.enqueue(n); 330 | } 331 | } 332 | return Array.from(ret_edges); 333 | } 334 | 335 | // 336 | // mouse event handling 337 | // 338 | // * 1st click (and mouseover): highlight downstream connections, only + tooltip 339 | // * 2nd click: highlight upstream and downstream connections + no tooltip 340 | // * 3rd click: return to normal (no-selection/highlights) 341 | // 342 | 343 | var click_count = 0; 344 | var sticky_node = null; 345 | 346 | // FIXME: these x,y,z-s pad out parameters I haven't looked up, 347 | // FIXME: but don't seem to be necessary for display 348 | var node_mousedown = function(d, x, y, z, no_tip_p) { 349 | if (sticky_node == d && click_count == 1) { 350 | tip.hide(d); 351 | highlight(d, true, true); 352 | click_count += 1; 353 | } 354 | else if (sticky_node == d && click_count == 2) { 355 | unhighlight(d); 356 | tip.hide(d); 357 | sticky_node = null; 358 | click_count = 0; 359 | } 360 | else { 361 | if (sticky_node) { 362 | unhighlight(sticky_node); 363 | tip.hide(sticky_node); 364 | } 365 | sticky_node = d; 366 | click_count = 1; 367 | highlight(d, true, false); 368 | if (no_tip_p === undefined) { 369 | tip.show(d) 370 | .direction(tipdir(d)) 371 | .offset(tipoff(d)); 372 | } 373 | } 374 | } 375 | 376 | var node_mouseleave = function(d) { 377 | tip.hide(d); 378 | } 379 | 380 | var node_mouseenter = function(d) { 381 | tip.show(d) 382 | .direction(tipdir(d)) 383 | .offset(tipoff(d)); 384 | } 385 | 386 | var node_mouseover = function(d) { 387 | if (! sticky_node) 388 | highlight(d, true, false); 389 | } 390 | 391 | var node_mouseout = function(d) { 392 | if (sticky_node == d) { 393 | return; 394 | } 395 | else if (! sticky_node) { 396 | unhighlight(d); 397 | } 398 | else { 399 | if (click_count == 2) 400 | highlight(sticky_node, true, true); 401 | else 402 | highlight(sticky_node, true, false); 403 | } 404 | 405 | } 406 | 407 | var tipdir = function(d) { 408 | return 'n'; 409 | } 410 | 411 | var tipoff = function(d) { 412 | return [-10, 0]; 413 | } 414 | 415 | var highlight = function (d, downstream, upstream) { 416 | 417 | var highlight_nodes = []; 418 | var highlight_edges = []; 419 | 420 | if (downstream) { 421 | highlight_nodes = highlight_nodes.concat(get_downstream_nodes(d)); 422 | highlight_edges = highlight_edges.concat(get_downstream_edges(d)); 423 | } 424 | 425 | if (upstream) { 426 | highlight_nodes = highlight_nodes.concat(get_upstream_nodes(d)); 427 | highlight_edges = highlight_edges.concat(get_upstream_edges(d)); 428 | } 429 | 430 | svg.selectAll('g.node') 431 | .data(highlight_nodes, function (d) { return (d && d.svg_id) || d3.select(this).attr("id"); }) 432 | .attr('opacity', 1.0) 433 | .exit() 434 | .attr('opacity', 0.2); 435 | 436 | svg.selectAll('g.edge') 437 | .data(highlight_edges, function(d) { return d && d.svg_id || d3.select(this).attr("id"); }) 438 | .attr('opacity', 1.0) 439 | .exit() 440 | .attr('opacity', 0.0); 441 | } 442 | 443 | var unhighlight = function (d) { 444 | svg.selectAll('g.node') 445 | .attr('opacity', 1.0); 446 | svg.selectAll('g.edge') 447 | .attr('opacity', 1.0) 448 | 449 | } 450 | 451 | // colorize nodes, and add mouse candy. 452 | svg.selectAll('g.node') 453 | .data(svg_nodes, function (d) { 454 | return (d && d.svg_id) || d3.select(this).attr("id"); 455 | }) 456 | .on('mouseenter', node_mouseenter) 457 | .on('mouseleave', node_mouseleave) 458 | .on('mouseover', node_mouseover) 459 | .on('mouseout', node_mouseout) 460 | .on('mousedown', node_mousedown) 461 | .attr('fill', function (d) { return color(d.group); }) 462 | .select('polygon:nth-last-of-type(2)') 463 | .style('fill', (function (d) { 464 | if (d) 465 | return color(d.group); 466 | else 467 | return '#000'; 468 | })); 469 | 470 | // colorize modules 471 | svg.selectAll('polygon') 472 | .each(function(d, i) { 473 | if (d != undefined) 474 | return undefined; 475 | sibling = this.nextElementSibling; 476 | if (sibling) { 477 | if(sibling.innerHTML.match(/\(M\)/)) { 478 | this.setAttribute('fill', color(sibling.innerHTML)); 479 | } 480 | } 481 | }); 482 | 483 | // hack to make mouse events and coloration work on the root node again. 484 | var root = nodes['[root] root']; 485 | svg.selectAll('g.node#' + root.svg_id) 486 | .data(svg_nodes, function (d) { 487 | return (d && d.svg_id) || d3.select(this).attr("id"); 488 | }) 489 | .on('mouseover', node_mouseover) 490 | .on('mouseout', node_mouseout) 491 | .on('mousedown', node_mousedown) 492 | .select('polygon') 493 | .attr('fill', function (d) { return color(d.group); }) 494 | .style('fill', (function (d) { 495 | if (d) 496 | return color(d.group); 497 | else 498 | return '#000'; 499 | })); 500 | 501 | // stub, in case we want to do something with edges on init. 502 | svg.selectAll('g.edge') 503 | .data(edges, function(d) { return d && d.svg_id || d3.select(this).attr("id"); }); 504 | 505 | // blast-radius --serve mode stuff. check for a zoom-in button as a proxy 506 | // for whether other facilities will be available. 507 | if (d3.select(selector + '-zoom-in')) { 508 | var zin_btn = document.querySelector(selector + '-zoom-in'); 509 | var zout_btn = document.querySelector(selector + '-zoom-out'); 510 | var refocus_btn = document.querySelector(selector + '-refocus'); 511 | var download_btn = document.querySelector(selector + '-download') 512 | var svg_el = document.querySelector(selector + ' svg'); 513 | var panzoom = svgPanZoom(svg_el).disableDblClickZoom(); 514 | 515 | console.log('bang'); 516 | console.log(state); 517 | if (state['no_scroll_zoom'] == true) { 518 | console.log('bang'); 519 | panzoom.disableMouseWheelZoom(); 520 | } 521 | 522 | var handle_zin = function(ev){ 523 | ev.preventDefault(); 524 | panzoom.zoomIn(); 525 | } 526 | zin_btn.addEventListener('click', handle_zin); 527 | 528 | var handle_zout = function(ev){ 529 | ev.preventDefault(); 530 | panzoom.zoomOut(); 531 | } 532 | zout_btn.addEventListener('click', handle_zout); 533 | 534 | var handle_refocus = function() { 535 | if (sticky_node) { 536 | $(selector + ' svg').remove(); 537 | clear_listeners(); 538 | if (! state['params']) 539 | state.params = {} 540 | state.params.refocus = encodeURIComponent(sticky_node.label); 541 | 542 | svg_url = svg_url.split('?')[0]; 543 | json_url = json_url.split('?')[0]; 544 | 545 | blastradius(selector, build_uri(svg_url, state.params), build_uri(json_url, state.params), br_state); 546 | } 547 | } 548 | 549 | // this feature is disabled for embedded images on static sites... 550 | if (refocus_btn) { 551 | refocus_btn.addEventListener('click', handle_refocus); 552 | } 553 | 554 | var handle_download = function() { 555 | // svg extraction and download as data url borrowed from 556 | // http://bl.ocks.org/curran/7cf9967028259ea032e8 557 | var svg_el = document.querySelector(selector + ' svg') 558 | var svg_as_xml = (new XMLSerializer).serializeToString(svg_el); 559 | var svg_data_url = "data:image/svg+xml," + encodeURIComponent(svg_as_xml); 560 | var dl = document.createElement("a"); 561 | document.body.appendChild(dl); 562 | dl.setAttribute("href", svg_data_url); 563 | dl.setAttribute("download", "blast-radius.svg"); 564 | dl.click(); 565 | } 566 | download_btn.addEventListener('click', handle_download); 567 | 568 | var clear_listeners = function() { 569 | zin_btn.removeEventListener('click', handle_zin); 570 | zout_btn.removeEventListener('click', handle_zout); 571 | refocus_btn.removeEventListener('click', handle_refocus); 572 | download_btn.removeEventListener('click', handle_download); 573 | panzoom = null; 574 | 575 | // 576 | tip.hide(); 577 | } 578 | 579 | var render_searchbox_node = function(d) { 580 | return searchbox_listing(d); 581 | } 582 | 583 | var select_node = function(d) { 584 | if (d === undefined || d.length == 0) { 585 | return true; 586 | } 587 | // FIXME: these falses pad out parameters I haven't looked up, 588 | // FIXME: but don't seem to be necessary for display 589 | if (sticky_node) { 590 | unhighlight(sticky_node); 591 | sticky_node = null; 592 | } 593 | click_count = 0; 594 | node_mousedown(nodes[d], false, false, false, true); 595 | } 596 | 597 | if ( $(selector + '-search.selectized').length > 0 ) { 598 | $(selector + '-search').selectize()[0].selectize.clear(); 599 | $(selector + '-search').selectize()[0].selectize.clearOptions(); 600 | for (var i in svg_nodes) { 601 | //console.log(svg_nodes[i]); 602 | $(selector + '-search').selectize()[0].selectize.addOption(svg_nodes[i]); 603 | } 604 | if( state.params.refocus && state.params.refocus.length > 0 ) { 605 | var n = state.params.refocus; 606 | } 607 | 608 | // because of scoping, we need to change the onChange callback to the new version 609 | // of select_node(), and delete the old callback associations. 610 | $(selector + '-search').selectize()[0].selectize.settings.onChange = select_node; 611 | $(selector + '-search').selectize()[0].selectize.swapOnChange(); 612 | } 613 | else { 614 | $(selector + '-search').selectize({ 615 | valueField: 'label', 616 | searchField: ['label'], 617 | maxItems: 1, 618 | create: false, 619 | multiple: false, 620 | maximumSelectionSize: 1, 621 | onChange: select_node, 622 | render: { 623 | option: render_searchbox_node, 624 | item : render_searchbox_node 625 | }, 626 | options: svg_nodes 627 | }); 628 | } 629 | 630 | // without this, selecting an item with <enter> will submit the form 631 | // and force a page refresh. not the desired behavior. 632 | $(selector + '-search-form').submit(function(){return false;}); 633 | 634 | } // end if(interactive) 635 | }); // end json success callback 636 | }); // end svg scuccess callback 637 | 638 | } // end blastradius() 639 | 640 | -------------------------------------------------------------------------------- /blastradius/server/static/js/d3-tip.js: -------------------------------------------------------------------------------- 1 | // d3.tip 2 | // Copyright (c) 2013 Justin Palmer 3 | // ES6 / D3 v4 Adaption Copyright (c) 2016 Constantin Gavrilete 4 | // Removal of ES6 for D3 v4 Adaption Copyright (c) 2016 David Gotz 5 | // 6 | // Tooltips for d3.js SVG visualizations 7 | 8 | d3.functor = function functor(v) { 9 | return typeof v === "function" ? v : function() { 10 | return v; 11 | }; 12 | }; 13 | 14 | d3.tip = function() { 15 | 16 | var direction = d3_tip_direction, 17 | offset = d3_tip_offset, 18 | html = d3_tip_html, 19 | node = initNode(), 20 | svg = null, 21 | point = null, 22 | target = null 23 | 24 | function tip(vis) { 25 | svg = getSVGNode(vis) 26 | point = svg.createSVGPoint() 27 | document.body.appendChild(node) 28 | } 29 | 30 | // Public - show the tooltip on the screen 31 | // 32 | // Returns a tip 33 | tip.show = function() { 34 | var args = Array.prototype.slice.call(arguments) 35 | if(args[args.length - 1] instanceof SVGElement) target = args.pop() 36 | 37 | var content = html.apply(this, args), 38 | poffset = offset.apply(this, args), 39 | dir = direction.apply(this, args), 40 | nodel = getNodeEl(), 41 | i = directions.length, 42 | coords, 43 | scrollTop = document.documentElement.scrollTop || document.body.scrollTop, 44 | scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft 45 | 46 | nodel.html(content) 47 | .style('position', 'absolute') 48 | .style('opacity', 1) 49 | .style('pointer-events', 'all') 50 | 51 | while(i--) nodel.classed(directions[i], false) 52 | coords = direction_callbacks[dir].apply(this) 53 | nodel.classed(dir, true) 54 | .style('top', (coords.top + poffset[0]) + scrollTop + 'px') 55 | .style('left', (coords.left + poffset[1]) + scrollLeft + 'px') 56 | 57 | return tip 58 | } 59 | 60 | // Public - hide the tooltip 61 | // 62 | // Returns a tip 63 | tip.hide = function() { 64 | var nodel = getNodeEl() 65 | nodel 66 | .style('opacity', 0) 67 | .style('pointer-events', 'none') 68 | return tip 69 | } 70 | 71 | // Public: Proxy attr calls to the d3 tip container. Sets or gets attribute value. 72 | // 73 | // n - name of the attribute 74 | // v - value of the attribute 75 | // 76 | // Returns tip or attribute value 77 | tip.attr = function(n, v) { 78 | if (arguments.length < 2 && typeof n === 'string') { 79 | return getNodeEl().attr(n) 80 | } else { 81 | var args = Array.prototype.slice.call(arguments) 82 | d3.selection.prototype.attr.apply(getNodeEl(), args) 83 | } 84 | 85 | return tip 86 | } 87 | 88 | // Public: Proxy style calls to the d3 tip container. Sets or gets a style value. 89 | // 90 | // n - name of the property 91 | // v - value of the property 92 | // 93 | // Returns tip or style property value 94 | tip.style = function(n, v) { 95 | // debugger; 96 | if (arguments.length < 2 && typeof n === 'string') { 97 | return getNodeEl().style(n) 98 | } else { 99 | var args = Array.prototype.slice.call(arguments); 100 | if (args.length === 1) { 101 | var styles = args[0]; 102 | Object.keys(styles).forEach(function(key) { 103 | return d3.selection.prototype.style.apply(getNodeEl(), [key, styles[key]]); 104 | }); 105 | } 106 | } 107 | 108 | return tip 109 | } 110 | 111 | // Public: Set or get the direction of the tooltip 112 | // 113 | // v - One of n(north), s(south), e(east), or w(west), nw(northwest), 114 | // sw(southwest), ne(northeast) or se(southeast) 115 | // 116 | // Returns tip or direction 117 | tip.direction = function(v) { 118 | if (!arguments.length) return direction 119 | direction = v == null ? v : d3.functor(v) 120 | 121 | return tip 122 | } 123 | 124 | // Public: Sets or gets the offset of the tip 125 | // 126 | // v - Array of [x, y] offset 127 | // 128 | // Returns offset or 129 | tip.offset = function(v) { 130 | if (!arguments.length) return offset 131 | offset = v == null ? v : d3.functor(v) 132 | 133 | return tip 134 | } 135 | 136 | // Public: sets or gets the html value of the tooltip 137 | // 138 | // v - String value of the tip 139 | // 140 | // Returns html value or tip 141 | tip.html = function(v) { 142 | if (!arguments.length) return html 143 | html = v == null ? v : d3.functor(v) 144 | 145 | return tip 146 | } 147 | 148 | // Public: destroys the tooltip and removes it from the DOM 149 | // 150 | // Returns a tip 151 | tip.destroy = function() { 152 | if(node) { 153 | getNodeEl().remove(); 154 | node = null; 155 | } 156 | return tip; 157 | } 158 | 159 | function d3_tip_direction() { return 'n' } 160 | function d3_tip_offset() { return [0, 0] } 161 | function d3_tip_html() { return ' ' } 162 | 163 | var direction_callbacks = { 164 | n: direction_n, 165 | s: direction_s, 166 | e: direction_e, 167 | w: direction_w, 168 | nw: direction_nw, 169 | ne: direction_ne, 170 | sw: direction_sw, 171 | se: direction_se 172 | }; 173 | 174 | var directions = Object.keys(direction_callbacks); 175 | 176 | function direction_n() { 177 | var bbox = getScreenBBox() 178 | return { 179 | top: bbox.n.y - node.offsetHeight, 180 | left: bbox.n.x - node.offsetWidth / 2 181 | } 182 | } 183 | 184 | function direction_s() { 185 | var bbox = getScreenBBox() 186 | return { 187 | top: bbox.s.y, 188 | left: bbox.s.x - node.offsetWidth / 2 189 | } 190 | } 191 | 192 | function direction_e() { 193 | var bbox = getScreenBBox() 194 | return { 195 | top: bbox.e.y - node.offsetHeight / 2, 196 | left: bbox.e.x 197 | } 198 | } 199 | 200 | function direction_w() { 201 | var bbox = getScreenBBox() 202 | return { 203 | top: bbox.w.y - node.offsetHeight / 2, 204 | left: bbox.w.x - node.offsetWidth 205 | } 206 | } 207 | 208 | function direction_nw() { 209 | var bbox = getScreenBBox() 210 | return { 211 | top: bbox.nw.y - node.offsetHeight, 212 | left: bbox.nw.x - node.offsetWidth 213 | } 214 | } 215 | 216 | function direction_ne() { 217 | var bbox = getScreenBBox() 218 | return { 219 | top: bbox.ne.y - node.offsetHeight, 220 | left: bbox.ne.x 221 | } 222 | } 223 | 224 | function direction_sw() { 225 | var bbox = getScreenBBox() 226 | return { 227 | top: bbox.sw.y, 228 | left: bbox.sw.x - node.offsetWidth 229 | } 230 | } 231 | 232 | function direction_se() { 233 | var bbox = getScreenBBox() 234 | return { 235 | top: bbox.se.y, 236 | left: bbox.e.x 237 | } 238 | } 239 | 240 | function initNode() { 241 | var node = d3.select(document.createElement('div')) 242 | node 243 | .style('position', 'absolute') 244 | .style('top', 0) 245 | .style('opacity', 0) 246 | .style('pointer-events', 'none') 247 | .style('box-sizing', 'border-box') 248 | 249 | return node.node() 250 | } 251 | 252 | function getSVGNode(el) { 253 | el = el.node() 254 | if(el.tagName.toLowerCase() === 'svg') 255 | return el 256 | 257 | return el.ownerSVGElement 258 | } 259 | 260 | function getNodeEl() { 261 | if(node === null) { 262 | node = initNode(); 263 | // re-add node to DOM 264 | document.body.appendChild(node); 265 | }; 266 | return d3.select(node); 267 | } 268 | 269 | // Private - gets the screen coordinates of a shape 270 | // 271 | // Given a shape on the screen, will return an SVGPoint for the directions 272 | // n(north), s(south), e(east), w(west), ne(northeast), se(southeast), nw(northwest), 273 | // sw(southwest). 274 | // 275 | // +-+-+ 276 | // | | 277 | // + + 278 | // | | 279 | // +-+-+ 280 | // 281 | // Returns an Object {n, s, e, w, nw, sw, ne, se} 282 | function getScreenBBox() { 283 | var targetel = target || d3.event.target; 284 | 285 | while ('undefined' === typeof targetel.getScreenCTM && 'undefined' === targetel.parentNode) { 286 | targetel = targetel.parentNode; 287 | } 288 | 289 | var bbox = {}, 290 | matrix = targetel.getScreenCTM(), 291 | tbbox = targetel.getBBox(), 292 | width = tbbox.width, 293 | height = tbbox.height, 294 | x = tbbox.x, 295 | y = tbbox.y 296 | 297 | point.x = x 298 | point.y = y 299 | bbox.nw = point.matrixTransform(matrix) 300 | point.x += width 301 | bbox.ne = point.matrixTransform(matrix) 302 | point.y += height 303 | bbox.se = point.matrixTransform(matrix) 304 | point.x -= width 305 | bbox.sw = point.matrixTransform(matrix) 306 | point.y -= height / 2 307 | bbox.w = point.matrixTransform(matrix) 308 | point.x += width 309 | bbox.e = point.matrixTransform(matrix) 310 | point.x -= width / 2 311 | point.y -= height / 2 312 | bbox.n = point.matrixTransform(matrix) 313 | point.y += height 314 | bbox.s = point.matrixTransform(matrix) 315 | 316 | return bbox 317 | } 318 | 319 | return tip 320 | }; 321 | -------------------------------------------------------------------------------- /blastradius/server/templates/error.html: -------------------------------------------------------------------------------- 1 | <html> 2 | <head> 3 | <title>Terraform Graph Tools 4 | 5 | 6 | 7 | 8 | 9 |

Error.

10 |

Something has gone wrong. Please check the following:

11 |
    12 |
  • Is Graphviz installed?
  • 13 |
  • Is Terraform installed?
  • 14 |
  • Is this an init-ed Terraform project?
  • 15 |
16 | 17 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /blastradius/server/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Blast Radius (Terraform Graph Tools) 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | 31 | 32 | 33 | 135 | 136 | 137 |
138 | 140 | 141 |
142 | 143 | 144 | 145 | 152 | 153 | -------------------------------------------------------------------------------- /blastradius/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import collections 4 | 5 | class Re: 6 | '''A bit of a hack to simplify conditionals with 7 | multiple regexes. e.g. 8 | 9 | if (match = re.match(pattern, ...)) { 10 | match.groupdict(...)[key] 11 | } 12 | 13 | isn't possible, so instead: 14 | 15 | r = Re() 16 | if r.match(pattern1, string): 17 | r.last_match.groupdict()[key] ... 18 | elif r.match(pattern1, string) 19 | r.last_match.groupdict()[key] ... 20 | ''' 21 | def __init__(self): 22 | self.last_match = None 23 | 24 | def match(self,pattern,text): 25 | self.last_match = re.match(pattern,text) 26 | return self.last_match 27 | 28 | def search(self,pattern,text): 29 | self.last_match = re.search(pattern,text) 30 | return self.last_match 31 | 32 | def to_seconds(string): 33 | '''Parse Terraform time interval into an integer representing seconds.''' 34 | m = re.match(r'(?P\d*h)*(?P)\d*m)*(?P\d*s)*', string) 35 | if not m: 36 | return TypeError 37 | d = m.groupdict() 38 | 39 | def which(program): 40 | def is_exe(fpath): 41 | return os.path.isfile(fpath) and os.access(fpath, os.X_OK) 42 | 43 | fpath, fname = os.path.split(program) 44 | if fpath: 45 | if is_exe(program): 46 | return program 47 | else: 48 | for path in os.environ["PATH"].split(os.pathsep): 49 | path = path.strip('"') 50 | exe_file = os.path.join(path, program) 51 | if is_exe(exe_file): 52 | return exe_file 53 | 54 | return None 55 | 56 | class Counter: 57 | 58 | def __init__(self, start=-1): 59 | self.count = start 60 | 61 | def next(self): 62 | self.count += 1 63 | return self.count 64 | 65 | class OrderedSet(collections.MutableSet): 66 | '''ordered set implementation linked from StackOverflow 67 | http://code.activestate.com/recipes/576694/ 68 | https://stackoverflow.com/questions/1653970/does-python-have-an-ordered-set''' 69 | 70 | def __init__(self, iterable=None): 71 | self.end = end = [] 72 | end += [None, end, end] 73 | self.map = {} 74 | if iterable is not None: 75 | self |= iterable 76 | 77 | def __len__(self): 78 | return len(self.map) 79 | 80 | def __contains__(self, key): 81 | return key in self.map 82 | 83 | def add(self, key): 84 | if key not in self.map: 85 | end = self.end 86 | curr = end[1] 87 | curr[2] = end[1] = self.map[key] = [key, curr, end] 88 | 89 | def discard(self, key): 90 | if key in self.map: 91 | key, prev, next = self.map.pop(key) 92 | prev[2] = next 93 | next[1] = prev 94 | 95 | def __iter__(self): 96 | end = self.end 97 | curr = end[2] 98 | while curr is not end: 99 | yield curr[0] 100 | curr = curr[2] 101 | 102 | def __reversed__(self): 103 | end = self.end 104 | curr = end[1] 105 | while curr is not end: 106 | yield curr[0] 107 | curr = curr[1] 108 | 109 | def pop(self, last=True): 110 | if not self: 111 | raise KeyError('set is empty') 112 | key = self.end[1][0] if last else self.end[2][0] 113 | self.discard(key) 114 | return key 115 | 116 | def __repr__(self): 117 | if not self: 118 | return '%s()' % (self.__class__.__name__,) 119 | return '%s(%r)' % (self.__class__.__name__, list(self)) 120 | 121 | def __eq__(self, other): 122 | if isinstance(other, OrderedSet): 123 | return len(self) == len(other) and list(self) == list(other) 124 | return set(self) == set(other) 125 | 126 | def which(file_name): 127 | for path in os.environ["PATH"].split(os.pathsep): 128 | full_path = os.path.join(path, file_name) 129 | if os.path.exists(full_path) and os.access(full_path, os.X_OK): 130 | return full_path 131 | return None -------------------------------------------------------------------------------- /doc/blastradius-interactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/28mm/blast-radius/a7ec4ef78141ab0d2a688c65112f799adb9622ba/doc/blastradius-interactive.png -------------------------------------------------------------------------------- /doc/embedded.md: -------------------------------------------------------------------------------- 1 | # Embedded Figures 2 | 3 | You may wish to embed figures produced with *Blast Radius* in other documents. You will need the following: 4 | 5 | 1. an `svg` file and `json` document representing the graph and its layout. These are produced with *Blast Radius*, as follows 6 | 7 | ````bash 8 | [...]$ terraform graph | blast-radius --svg > graph.svg 9 | [...]$ terraform graph | blast-radius --json > graph.json 10 | ```` 11 | 12 | 2. `javascript` and `css`. You can find these in the `.../blastradius/server/static` directory. Copy these files to an appropriate location, and ammend the following includes to reflect those locations. 13 | 14 | ````html 15 | 16 | 17 | 18 | 19 | 20 | ```` 21 | 22 | 3. A uniquely identified DOM element, where the `` should appear, and a call to `blastradius(...)` somewhere after (usually at the end of the `` document. 23 | 24 | ````html 25 |
26 | 29 | ```` 30 | 31 | That's it. Ideas to simplify this process strongly desired. -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # If command starts with an option, prepend the blast-radius. 5 | if [ "${1}" != "blast-radius" ]; then 6 | if [ -n "${1}" ]; then 7 | set -- blast-radius "$@" 8 | fi 9 | fi 10 | 11 | # Assert CLI args are overwritten, otherwise set them to preferred defaults 12 | export TF_CLI_ARGS_get=${TF_CLI_ARGS_get:'-update'} 13 | export TF_CLI_ARGS_init=${TF_CLI_ARGS_init:'-input=false'} 14 | 15 | # Inside the container 16 | # Need to create the upper and work dirs inside a tmpfs. 17 | # Otherwise OverlayFS complains about AUFS folders. 18 | # Source: https://gist.github.com/detunized/7c8fc4c37b49c5475e68ef9574587eee 19 | mkdir -p /tmp/overlay && \ 20 | mount -t tmpfs tmpfs /tmp/overlay && \ 21 | mkdir -p /tmp/overlay/upper && \ 22 | mkdir -p /tmp/overlay/work && \ 23 | mkdir -p /data-rw && \ 24 | mount -t overlay overlay -o lowerdir=/data,upperdir=/tmp/overlay/upper,workdir=/tmp/overlay/work /data-rw 25 | 26 | # change to the overlayFS 27 | cd /data-rw 28 | 29 | # Is Terraform already initialized? Ensure modules are all downloaded. 30 | [ -d '.terraform' ] && terraform get 31 | 32 | # Reinitialize for some reason 33 | terraform init 34 | 35 | # it's possible that we're in a sub-directory. leave. 36 | cd /data-rw 37 | 38 | # Let's go! 39 | exec "$@" 40 | -------------------------------------------------------------------------------- /examples/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | 4 | blastradius: 5 | image: 28mm/blast-radius 6 | cap_add: 7 | - SYS_ADMIN 8 | security_opt: 9 | - apparmor:unconfined 10 | environment: 11 | - TF_CLI_CONFIG_FILE=/root/.terraformrc 12 | - TF_WORKSPACE=dev 13 | ports: 14 | - "5000:5000" 15 | volumes: 16 | - "$HOME/.terraformrc:/root/.terraformrc" 17 | - ".:/data:ro" 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools==41.0.1 2 | requests==2.22.0 3 | Jinja2==2.10.1 4 | Flask==1.0.3 5 | beautifulsoup4==4.7.1 6 | ply>=3.11 7 | pyhcl==0.3.12 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os import path 3 | 4 | here = path.abspath(path.dirname(__file__)) 5 | 6 | # Get the long description from the README file 7 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | # Implements parse_requirements as standalone functionality 11 | with open("requirements.txt") as f: 12 | reqs = [l.strip('\n') for l in f if l.strip('\n') and not l.startswith('#')] 13 | 14 | setup( 15 | name='blastradius', 16 | version='0.1.25', 17 | description='Interactive Terraform graph visualizations', 18 | long_description=open('README.md').read(), 19 | long_description_content_type='text/markdown', 20 | author='Patrick McMurchie', 21 | author_email='patrick.mcmurchie@gmail.com', 22 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 23 | scripts=['bin/blast-radius'], 24 | install_requires=reqs, 25 | ) 26 | -------------------------------------------------------------------------------- /utilities/providers/provider-category-json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # scrapes provider information from Hashicorp's online documentation 4 | # groups related resources together according to documentation structure, 5 | # and outputs json useful for categorical coloration... 6 | 7 | # e.g. 8 | # 9 | # { 'aws_vpc' : 1, 10 | # 'aws_subnet : 1, 11 | # 'aws_instance : 2, 12 | # ... } 13 | 14 | # TODO: only handles "Resources." "Data Sources" Sometimes overlap, sometimes 15 | # don't. So we're not catching all of them, or handling appropriately. 16 | 17 | import re 18 | import json 19 | import itertools 20 | 21 | import requests 22 | from bs4 import BeautifulSoup 23 | 24 | def main(): 25 | 26 | providers = {} 27 | resources = {} 28 | 29 | provider_re = re.compile(r'\/docs\/providers\/(?P\S+)\/index.html') 30 | resource_re = re.compile(r'\/docs\/providers\/(?P\S+)\/r/(?P\S+).html') 31 | 32 | # group_counter_fn() will increment the counter and return it. 33 | group_counter_fn = itertools.count().__next__ 34 | 35 | providers_url = 'https://www.terraform.io/docs/providers/index.html' 36 | provider_soup = BeautifulSoup(requests.get(providers_url).text, 'lxml') 37 | 38 | provider_links = { 'https://www.terraform.io' + tag['href'] \ 39 | for tag in provider_soup.findAll('a', {'href' : provider_re}) } 40 | 41 | # for each provider's documentation, do our best to extract 42 | # resource "groups"... 43 | for url in provider_links: 44 | soup = BeautifulSoup(requests.get(url).text, 'lxml') 45 | for a in soup.findAll('a', {'href' : '#'}): 46 | if re.match(r'Data\s+Sources', a.getText()): 47 | continue 48 | else: 49 | group = a.getText() 50 | group_counter = group_counter_fn() 51 | ul = a.find_next('ul') 52 | links = { a.getText() : group_counter for a in ul.findAll('a', {'href' : resource_re})} 53 | resources = { **resources, **links } 54 | 55 | # this persists as a bit of a hack, because we see these, but 56 | # they don't appear in HC docs in an easily scraped way. 57 | defaults = { 58 | '' : 10000, 59 | 'provider' : 10000, 60 | 'meta' : 10000, 61 | 'var' : 10001, 62 | 'output' : 10002 63 | } 64 | 65 | resources = { **resources, **defaults } 66 | 67 | print(json.dumps(resources, indent=4, sort_keys=True)) 68 | 69 | 70 | if __name__ == '__main__': 71 | main() 72 | -------------------------------------------------------------------------------- /utilities/providers/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | beautifulsoup4 3 | --------------------------------------------------------------------------------