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
This commit is contained in:
Steve Baker 2016-03-02 09:30:36 +13:00
parent bcbbbc9e2c
commit a787334443
5 changed files with 469 additions and 0 deletions

View File

@ -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')

View File

@ -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']

View File

@ -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())

View File

@ -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"
}
]
}

View File

@ -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