diff --git a/docs/docs/cli.md b/docs/docs/cli.md index d59bb7f9c..76b725ea1 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 execute-change-set @@ -76,3 +77,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 3aa49cc9b..caac6fd44 100644 --- a/sceptre/cli.py +++ b/sceptre/cli.py @@ -545,6 +545,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 27e70cf24..fa902cd56 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( @@ -284,7 +284,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. @@ -308,7 +308,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. @@ -316,7 +316,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 9b29d59bc..3e6606577 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -470,6 +470,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 b9c366f15..b480bd995 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( @@ -158,7 +158,7 @@ def test_launch_calls_build_with_correct_args( @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( @@ -369,7 +369,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". @@ -377,7 +377,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": [], @@ -385,7 +385,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"],