From a78733444337c7f2b0b4eb506a2605ea05c149a3 Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Wed, 2 Mar 2016 09:30:36 +1300 Subject: [PATCH] A resource list formatter for graphviz dot diagrams This change registers a "dot" formatter for the stack resource list command. It generates the text for a graphviz dot diagram which can be rendered as an image with this example usage: openstack stack resource list --format dot -n3 my_stack | dot -Tsvg -o my_stack.svg Nested resources are fully supported by creating nodes for top-level resources then linking stack resources to a subgraph representing the nested stack. Change-Id: I4b899287eaf818137d60cb278db2d86598aa6794 --- heatclient/common/resource_formatter.py | 150 +++++++++++++ heatclient/osc/v1/resource.py | 7 + .../tests/unit/test_resource_formatter.py | 100 +++++++++ heatclient/tests/unit/var/dot_test.json | 204 ++++++++++++++++++ setup.cfg | 8 + 5 files changed, 469 insertions(+) create mode 100644 heatclient/common/resource_formatter.py create mode 100644 heatclient/tests/unit/test_resource_formatter.py create mode 100644 heatclient/tests/unit/var/dot_test.json diff --git a/heatclient/common/resource_formatter.py b/heatclient/common/resource_formatter.py new file mode 100644 index 00000000..7a5ec7f2 --- /dev/null +++ b/heatclient/common/resource_formatter.py @@ -0,0 +1,150 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import collections +import hashlib + +from cliff.formatters import base + + +class ResourceDotInfo(object): + + def __init__(self, res): + self.resource = res + links = {l['rel']: l['href'] for l in res.links} + self.nested_dot_id = self.dot_id(links.get('nested'), 'stack') + self.stack_dot_id = self.dot_id(links.get('stack'), 'stack') + self.res_dot_id = self.dot_id(links.get('self')) + + @staticmethod + def dot_id(url, prefix=None): + """Build an id with a prefix and a truncated hash of the URL""" + if not url: + return None + if not prefix: + prefix = 'r' + hash_object = hashlib.sha256(url.encode('utf-8')) + return '%s_%s' % (prefix, hash_object.hexdigest()[:20]) + + +class ResourceDotFormatter(base.ListFormatter): + def add_argument_group(self, parser): + pass + + def emit_list(self, column_names, data, stdout, parsed_args): + writer = ResourceDotWriter(data, stdout) + writer.write() + + +class ResourceDotWriter(object): + + def __init__(self, data, stdout): + self.resources_by_stack = collections.defaultdict( + collections.OrderedDict) + self.resources_by_dot_id = collections.OrderedDict() + self.nested_stack_ids = [] + self.stdout = stdout + + for r in data: + rinfo = ResourceDotInfo(r) + if rinfo.stack_dot_id: + self.resources_by_stack[ + rinfo.stack_dot_id][r.resource_name] = rinfo + if rinfo.res_dot_id: + self.resources_by_dot_id[rinfo.res_dot_id] = rinfo + if rinfo.nested_dot_id: + self.nested_stack_ids.append(rinfo.nested_dot_id) + + def write(self): + stdout = self.stdout + + stdout.write('digraph G {\n') + stdout.write(' graph [\n' + ' fontsize=10 fontname="Verdana" ' + 'compound=true rankdir=LR\n' + ' ]\n') + + self.write_root_nodes() + self.write_subgraphs() + self.write_nested_stack_edges() + self.write_required_by_edges() + stdout.write('}\n') + + def write_root_nodes(self): + for stack_dot_id in set(self.resources_by_stack.keys()).difference( + self.nested_stack_ids): + resources = self.resources_by_stack[stack_dot_id] + self.write_nodes(resources, 2) + + def write_subgraphs(self): + for dot_id, rinfo in self.resources_by_dot_id.items(): + if rinfo.nested_dot_id: + resources = self.resources_by_stack[rinfo.nested_dot_id] + if resources: + self.write_subgraph(resources, rinfo) + + def write_nodes(self, resources, indent): + stdout = self.stdout + spaces = ' ' * indent + for rinfo in resources.values(): + r = rinfo.resource + dot_id = rinfo.res_dot_id + if r.resource_status.endswith('FAILED'): + style = 'style=filled color=red' + else: + style = '' + stdout.write('%s%s [label="%s\n%s" %s];\n' + % (spaces, dot_id, r.resource_name, + r.resource_type, style)) + stdout.write('\n') + + def write_subgraph(self, resources, nested_resource): + stdout = self.stdout + stack_dot_id = nested_resource.nested_dot_id + nested_name = nested_resource.resource.resource_name + stdout.write(' subgraph cluster_%s {\n' % stack_dot_id) + stdout.write(' label="%s";\n' % nested_name) + self.write_nodes(resources, 4) + stdout.write(' }\n\n') + + def write_required_by_edges(self): + stdout = self.stdout + for dot_id, rinfo in self.resources_by_dot_id.items(): + r = rinfo.resource + + required_by = r.required_by + stack_dot_id = rinfo.stack_dot_id + if not required_by or not stack_dot_id: + continue + + stack_resources = self.resources_by_stack.get(stack_dot_id, {}) + for req in required_by: + other_rinfo = stack_resources.get(req) + if other_rinfo: + stdout.write(' %s -> %s;\n' + % (rinfo.res_dot_id, other_rinfo.res_dot_id)) + stdout.write('\n') + + def write_nested_stack_edges(self): + stdout = self.stdout + for dot_id, rinfo in self.resources_by_dot_id.items(): + if rinfo.nested_dot_id: + nested_resources = self.resources_by_stack[rinfo.nested_dot_id] + if nested_resources: + first_resource = list(nested_resources.values())[0] + stdout.write( + ' %s -> %s [\n color=dimgray lhead=cluster_%s ' + 'arrowhead=none\n ];\n' + % (dot_id, first_resource.res_dot_id, + rinfo.nested_dot_id)) + stdout.write('\n') diff --git a/heatclient/osc/v1/resource.py b/heatclient/osc/v1/resource.py index 056fbb1f..54f9302f 100644 --- a/heatclient/osc/v1/resource.py +++ b/heatclient/osc/v1/resource.py @@ -78,6 +78,10 @@ class ResourceList(lister.Lister): log = logging.getLogger(__name__ + '.ResourceListStack') + @property + def formatter_namespace(self): + return 'heatclient.resource.formatter.list' + def get_parser(self, prog_name): parser = super(ResourceList, self).get_parser(prog_name) parser.add_argument( @@ -126,6 +130,9 @@ class ResourceList(lister.Lister): msg = _('Stack not found: %s') % parsed_args.stack raise exc.CommandError(msg) + if parsed_args.formatter == 'dot': + return [], resources + columns = ['physical_resource_id', 'resource_type', 'resource_status', 'updated_time'] diff --git a/heatclient/tests/unit/test_resource_formatter.py b/heatclient/tests/unit/test_resource_formatter.py new file mode 100644 index 00000000..e3a053d9 --- /dev/null +++ b/heatclient/tests/unit/test_resource_formatter.py @@ -0,0 +1,100 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import json +import os +import six + +from heatclient.common import resource_formatter +from heatclient.osc.v1 import resource +from heatclient.tests.unit.osc.v1 import fakes as orchestration_fakes +from heatclient.v1 import resources as v1_resources + + +TEST_VAR_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), + 'var')) + + +class TestStackResourceListDotFormat(orchestration_fakes.TestOrchestrationv1): + + response_path = os.path.join(TEST_VAR_DIR, 'dot_test.json') + + data = '''digraph G { + graph [ + fontsize=10 fontname="Verdana" compound=true rankdir=LR + ] + r_f34a35baf594b319a741 [label="rg1 +OS::Heat::ResourceGroup" ]; + r_121e343b017a6d246f36 [label="random2 +OS::Heat::RandomString" ]; + r_dbcae38ad41dc991751d [label="random1 +OS::Heat::RandomString" style=filled color=red]; + + subgraph cluster_stack_16437984473ec64a8e6c { + label="rg1"; + r_30e9aa76bc0d53310cde [label="1 +OS::Heat::ResourceGroup" ]; + r_63c05d424cb708f1599f [label="0 +OS::Heat::ResourceGroup" ]; + + } + + subgraph cluster_stack_fbfb461c8cc84b686c08 { + label="1"; + r_e2e5c36ae18e29d9c299 [label="1 +OS::Heat::RandomString" ]; + r_56c62630a0d655bce234 [label="0 +OS::Heat::RandomString" ]; + + } + + subgraph cluster_stack_d427657dfccc28a131a7 { + label="0"; + r_240756913e2e940387ff [label="1 +OS::Heat::RandomString" ]; + r_81c64c43d9131aceedbb [label="0 +OS::Heat::RandomString" ]; + + } + + r_f34a35baf594b319a741 -> r_30e9aa76bc0d53310cde [ + color=dimgray lhead=cluster_stack_16437984473ec64a8e6c arrowhead=none + ]; + r_30e9aa76bc0d53310cde -> r_e2e5c36ae18e29d9c299 [ + color=dimgray lhead=cluster_stack_fbfb461c8cc84b686c08 arrowhead=none + ]; + r_63c05d424cb708f1599f -> r_240756913e2e940387ff [ + color=dimgray lhead=cluster_stack_d427657dfccc28a131a7 arrowhead=none + ]; + + r_dbcae38ad41dc991751d -> r_121e343b017a6d246f36; + +} +''' + + def setUp(self): + super(TestStackResourceListDotFormat, self).setUp() + self.resource_client = self.app.client_manager.orchestration.resources + self.cmd = resource.ResourceList(self.app, None) + with open(self.response_path) as f: + response = json.load(f) + self.resources = [] + for r in response['resources']: + self.resources.append(v1_resources.Resource(None, r)) + + def test_resource_list(self): + out = six.StringIO() + formatter = resource_formatter.ResourceDotFormatter() + formatter.emit_list(None, self.resources, out, None) + + self.assertEqual(self.data, out.getvalue()) diff --git a/heatclient/tests/unit/var/dot_test.json b/heatclient/tests/unit/var/dot_test.json new file mode 100644 index 00000000..816d8f5c --- /dev/null +++ b/heatclient/tests/unit/var/dot_test.json @@ -0,0 +1,204 @@ +{ + "resources": [ + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test/cea9d5e4-03b9-42ed-9d2a-c7cea6cd75d7/resources/rg1", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test/cea9d5e4-03b9-42ed-9d2a-c7cea6cd75d7", + "rel": "stack" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53/07e7dd08-efe9-4f52-a5c0-9dc29d9ccd39", + "rel": "nested" + } + ], + "logical_resource_id": "rg1", + "physical_resource_id": "07e7dd08-efe9-4f52-a5c0-9dc29d9ccd39", + "required_by": [], + "resource_name": "rg1", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::ResourceGroup", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53/07e7dd08-efe9-4f52-a5c0-9dc29d9ccd39/resources/1", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53/07e7dd08-efe9-4f52-a5c0-9dc29d9ccd39", + "rel": "stack" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c/105a252b-17d7-4bda-b47c-ad33ef113ce2", + "rel": "nested" + } + ], + "logical_resource_id": "1", + "parent_resource": "rg1", + "physical_resource_id": "105a252b-17d7-4bda-b47c-ad33ef113ce2", + "required_by": [], + "resource_name": "1", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::ResourceGroup", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c/105a252b-17d7-4bda-b47c-ad33ef113ce2/resources/1", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c/105a252b-17d7-4bda-b47c-ad33ef113ce2", + "rel": "stack" + } + ], + "logical_resource_id": "1", + "parent_resource": "1", + "physical_resource_id": "dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c-1-uiq2mqi3wxvi", + "required_by": [], + "resource_name": "1", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::RandomString", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c/105a252b-17d7-4bda-b47c-ad33ef113ce2/resources/0", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c/105a252b-17d7-4bda-b47c-ad33ef113ce2", + "rel": "stack" + } + ], + "logical_resource_id": "0", + "parent_resource": "1", + "physical_resource_id": "dot_test-rg1-fhwpldmwgi53-1-omdjadmwtu2c-0-3x7zr6jblmev", + "required_by": [], + "resource_name": "0", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::RandomString", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53/07e7dd08-efe9-4f52-a5c0-9dc29d9ccd39/resources/0", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53/07e7dd08-efe9-4f52-a5c0-9dc29d9ccd39", + "rel": "stack" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij/62a26d5f-73d6-43f1-946d-0a7c1b93f761", + "rel": "nested" + } + ], + "logical_resource_id": "0", + "parent_resource": "rg1", + "physical_resource_id": "62a26d5f-73d6-43f1-946d-0a7c1b93f761", + "required_by": [], + "resource_name": "0", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::ResourceGroup", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij/62a26d5f-73d6-43f1-946d-0a7c1b93f761/resources/1", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij/62a26d5f-73d6-43f1-946d-0a7c1b93f761", + "rel": "stack" + } + ], + "logical_resource_id": "1", + "parent_resource": "0", + "physical_resource_id": "dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij-1-kww7wmvmoawr", + "required_by": [], + "resource_name": "1", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::RandomString", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij/62a26d5f-73d6-43f1-946d-0a7c1b93f761/resources/0", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij/62a26d5f-73d6-43f1-946d-0a7c1b93f761", + "rel": "stack" + } + ], + "logical_resource_id": "0", + "parent_resource": "0", + "physical_resource_id": "dot_test-rg1-fhwpldmwgi53-0-fbof4yqwgqij-0-4xpmeguv6zt4", + "required_by": [], + "resource_name": "0", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::RandomString", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test/cea9d5e4-03b9-42ed-9d2a-c7cea6cd75d7/resources/random2", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test/cea9d5e4-03b9-42ed-9d2a-c7cea6cd75d7", + "rel": "stack" + } + ], + "logical_resource_id": "random2", + "physical_resource_id": "dot_test-random2-23dvgoy3niw2", + "required_by": [], + "resource_name": "random2", + "resource_status": "CREATE_COMPLETE", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::RandomString", + "updated_time": "2016-05-26T02:51:13Z" + }, + { + "links": [ + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test/cea9d5e4-03b9-42ed-9d2a-c7cea6cd75d7/resources/random1", + "rel": "self" + }, + { + "href": "http://192.0.2.1:8004/v1/e00f891d1aec4e6194b9164dd71c68f1/stacks/dot_test/cea9d5e4-03b9-42ed-9d2a-c7cea6cd75d7", + "rel": "stack" + } + ], + "logical_resource_id": "random1", + "physical_resource_id": "dot_test-random1-naw5hspxwnef", + "required_by": [ + "random2" + ], + "resource_name": "random1", + "resource_status": "CREATE_FAILED", + "resource_status_reason": "state changed", + "resource_type": "OS::Heat::RandomString", + "updated_time": "2016-05-26T02:51:13Z" + } + ] +} diff --git a/setup.cfg b/setup.cfg index 4d6a6c5c..688c3670 100644 --- a/setup.cfg +++ b/setup.cfg @@ -85,6 +85,14 @@ heatclient.event.formatter.list = yaml = cliff.formatters.yaml_format:YAMLFormatter json = cliff.formatters.json_format:JSONFormatter +heatclient.resource.formatter.list = + dot = heatclient.common.resource_formatter:ResourceDotFormatter + table = cliff.formatters.table:TableFormatter + csv = cliff.formatters.commaseparated:CSVLister + value = cliff.formatters.value:ValueFormatter + yaml = cliff.formatters.yaml_format:YAMLFormatter + json = cliff.formatters.json_format:JSONFormatter + [global] setup-hooks = pbr.hooks.setup_hook