From 8090f5dd3a48545f7bf56b7a9b3ad3adba4f0592 Mon Sep 17 00:00:00 2001 From: Cole Bosmann Date: Thu, 28 Sep 2017 19:31:29 -0700 Subject: [PATCH] [Resolve #201] Add describe-env-dependencies command to expose the stack dependencies within an environment This commit also allows users to output their dependencies using the DOT language, which can be used to graph the dependencies using programs like Graphwiz. --- docs/docs/cli.md | 8 +++++++ sceptre/cli.py | 46 +++++++++++++++++++++++++++++++++++++++ sceptre/environment.py | 10 ++++----- tests/test_cli.py | 42 +++++++++++++++++++++++++++++++++++ tests/test_environment.py | 10 ++++----- 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/docs/docs/cli.md b/docs/docs/cli.md index 717afa272..ba0b00f22 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -36,6 +36,7 @@ $ sceptre delete-stack $ sceptre describe-change-set $ sceptre describe-env $ sceptre describe-env-resources +$ sceptre describe-env-dependencies $ sceptre describe-stack-outputs $ sceptre describe-stack-resources $ sceptre diff @@ -78,3 +79,10 @@ Note that Sceptre prepends the string `SCEPTRE_` to the name of the environment $ env | grep SCEPTRE SCEPTRE_= ``` + +## Visualizing Environment Dependencies + +The `describe-env-dependencies` command can produce output in the DOT language. DOT can easily be converted to an image by making use of the dot command provided by GraphViz: +```shell +$ sceptre describe-env-dependencies ENVIRONMENT --dot | dot -Tpng > dependencies.png +``` \ No newline at end of file diff --git a/sceptre/cli.py b/sceptre/cli.py index 34beeef35..8e918516a 100644 --- a/sceptre/cli.py +++ b/sceptre/cli.py @@ -562,6 +562,52 @@ def describe_env(ctx, environment): write(responses, ctx.obj["output_format"], ctx.obj["no_colour"]) +@cli.command(name="describe-env-dependencies") +@environment_options +@click.option( + "--dot", is_flag=True, help="Format output in the graphwiz dot language." +) +@click.option("--invert", is_flag=True, help="Invert the dependency graph.") +@click.pass_context +@catch_exceptions +def describe_env_dependencies(ctx, environment, dot, invert): + """ + Describes ENVIRONMENTs stack dependencies + """ + env = get_env(ctx.obj["sceptre_dir"], environment, ctx.obj["options"]) + + if not invert: + dependencies = env.get_launch_dependencies(environment) + else: + dependencies = env.get_delete_dependencies() + + if dot: + dot_repr = _generate_dot_representation(dependencies) + write(dot_repr, "str") + else: + write(dependencies, ctx.obj["output_format"]) + + +def _generate_dot_representation(dependencies): + """ + Converts a environment dependencies into DOT language. + + :param dependencies: dictionary of stacks with their list of dependencies + :type dependencies: dict + :returns: Dot formatted dependency graph + :rtype: str + """ + lines = [] + for stack, stack_dependencies in dependencies.items(): + lines.append("\"{0}\";".format(stack)) + for dependency in stack_dependencies: + lines.append("\"{0}\" -> \"{1}\";".format(dependency, stack)) + + blob = "\n".join(lines) + return "digraph stack_dependencies {{{dependencies}}}"\ + .format(dependencies=blob) + + @cli.command(name="set-stack-policy") @stack_options @click.option("--policy-file") diff --git a/sceptre/environment.py b/sceptre/environment.py index 7044447ad..8a33f823e 100644 --- a/sceptre/environment.py +++ b/sceptre/environment.py @@ -122,7 +122,7 @@ def launch(self): self.logger.debug("Launching environment '%s'", self.path) threading_events = self._get_threading_events() stack_statuses = self._get_initial_statuses() - launch_dependencies = self._get_launch_dependencies(self.path) + launch_dependencies = self.get_launch_dependencies(self.path) self._check_for_circular_dependencies(launch_dependencies) self._build( @@ -139,7 +139,7 @@ def delete(self): self.logger.debug("Deleting environment '%s'", self.path) threading_events = self._get_threading_events() stack_statuses = self._get_initial_statuses() - delete_dependencies = self._get_delete_dependencies() + delete_dependencies = self.get_delete_dependencies() self._check_for_circular_dependencies(delete_dependencies) self._build( @@ -289,7 +289,7 @@ def _get_initial_statuses(self): } @recurse_into_sub_environments - def _get_launch_dependencies(self, top_level_environment_path): + def get_launch_dependencies(self, top_level_environment_path): """ Returns a dict of each stack's launch dependencies. @@ -313,7 +313,7 @@ def _get_launch_dependencies(self, top_level_environment_path): } return launch_dependencies - def _get_delete_dependencies(self): + def get_delete_dependencies(self): """ Returns a dict of each stack's delete dependencies. @@ -321,7 +321,7 @@ def _get_delete_dependencies(self): while deleting, keyed by that stack's name. :rtype: dict """ - launch_dependencies = self._get_launch_dependencies(self.path) + launch_dependencies = self.get_launch_dependencies(self.path) delete_dependencies = { stack_name: [] for stack_name in launch_dependencies } diff --git a/tests/test_cli.py b/tests/test_cli.py index 63cd66fea..b0e40ba12 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -486,6 +486,48 @@ def test_describe_env(self, mock_get_env): result = self.runner.invoke(cli, ["describe-env", "dev"]) assert result.output == "stack: status\n\n" + @patch("sceptre.cli.get_env") + def test_describe_env_dependencies(self, mock_get_env): + mock_Environment = Mock() + mock_Environment.get_launch_dependencies.return_value = { + "dev/vpc": [] + } + mock_get_env.return_value = mock_Environment + + result = self.runner.invoke(cli, ["describe-env-dependencies", "dev"]) + assert result.output == "dev/vpc: []\n\n" + + @patch("sceptre.cli.get_env") + def test_describe_env_dependencies_with_invert(self, mock_get_env): + mock_Environment = Mock() + mock_Environment.get_delete_dependencies.return_value = { + "dev/vpc": [] + } + mock_get_env.return_value = mock_Environment + + result = self.runner.invoke( + cli, ["describe-env-dependencies", "dev", "--invert"] + ) + assert result.output == "dev/vpc: []\n\n" + + @patch("sceptre.cli.get_env") + def test_describe_env_dependencies_with_dot(self, mock_get_env): + mock_Environment = Mock() + mock_Environment.get_launch_dependencies.return_value = { + "dev/vpc": [], + "dev/networking": ["dev/vpc"] + } + mock_get_env.return_value = mock_Environment + + result = self.runner.invoke( + cli, ["describe-env-dependencies", "dev", "--dot"] + ) + assert "digraph stack_dependencies {" in result.output + assert "\"dev/vpc\";" in result.output + assert "\"dev/networking\";" in result.output + assert "\"dev/vpc\" -> \"dev/networking\";" in result.output + assert "}" in result.output + @patch("sceptre.cli.os.getcwd") @patch("sceptre.cli.get_env") def test_set_stack_policy_with_file_flag( diff --git a/tests/test_environment.py b/tests/test_environment.py index 7b2d5a9e1..1b2a2c3fd 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -133,7 +133,7 @@ def test_is_leaf_with_non_leaf_dir(self): @patch("sceptre.environment.Environment._build") @patch("sceptre.environment.Environment._check_for_circular_dependencies") - @patch("sceptre.environment.Environment._get_launch_dependencies") + @patch("sceptre.environment.Environment.get_launch_dependencies") @patch("sceptre.environment.Environment._get_initial_statuses") @patch("sceptre.environment.Environment._get_threading_events") def test_launch_calls_build_with_correct_args( @@ -163,7 +163,7 @@ def test_launch_succeeds_with_empty_env(self): @patch("sceptre.environment.Environment._build") @patch("sceptre.environment.Environment._check_for_circular_dependencies") - @patch("sceptre.environment.Environment._get_delete_dependencies") + @patch("sceptre.environment.Environment.get_delete_dependencies") @patch("sceptre.environment.Environment._get_initial_statuses") @patch("sceptre.environment.Environment._get_threading_events") def test_delete_calls_build_with_correct_args( @@ -379,7 +379,7 @@ def test_get_launch_dependencies(self): self.environment.stacks = {"mock_stack": mock_stack} - response = self.environment._get_launch_dependencies("dev") + response = self.environment.get_launch_dependencies("dev") # Note that "prod/sg" is filtered out, because it's not under the # top level environment path "dev". @@ -387,7 +387,7 @@ def test_get_launch_dependencies(self): "dev/mock_stack": ["dev/vpc", "dev/subnets"] } - @patch("sceptre.environment.Environment._get_launch_dependencies") + @patch("sceptre.environment.Environment.get_launch_dependencies") def test_get_delete_dependencies(self, mock_get_launch_dependencies): mock_get_launch_dependencies.return_value = { "dev/mock_stack_1": [], @@ -395,7 +395,7 @@ def test_get_delete_dependencies(self, mock_get_launch_dependencies): "dev/mock_stack_3": ["dev/mock_stack_1", "dev/mock_stack_2"], } - dependencies = self.environment._get_delete_dependencies() + dependencies = self.environment.get_delete_dependencies() assert dependencies == { "dev/mock_stack_1": ["dev/mock_stack_3"], "dev/mock_stack_2": ["dev/mock_stack_3"],