diff --git a/common/src/stack/command/stack/commands/list/attr/__init__.py b/common/src/stack/command/stack/commands/list/attr/__init__.py
index 1616fde28..ee420f741 100644
--- a/common/src/stack/command/stack/commands/list/attr/__init__.py
+++ b/common/src/stack/command/stack/commands/list/attr/__init__.py
@@ -215,13 +215,14 @@ def addHostAttrs(self, attributes):
def run(self, params, args):
- (glob, shadow, scope, resolve, var, const) = self.fillParams([
+ (glob, shadow, scope, resolve, var, const, display) = self.fillParams([
('attr', None),
('shadow', True),
('scope', 'global'),
('resolve', None),
('var', True),
- ('const', True)
+ ('const', True),
+ ('display', 'all'),
])
shadow = self.str2bool(shadow)
@@ -327,7 +328,6 @@ def run(self, params, args):
# Mix in any const attributes
lookup[s]['const'](attributes[s])
-
targets = sorted(lookup[scope]['fn'](args))
if resolve and scope == 'host':
@@ -362,7 +362,24 @@ def run(self, params, args):
matches[key] = attributes[scope][o][key]
attributes[scope][o] = matches
-
+ if display != 'all' and scope == 'host':
+ host_attrs = {
+ host: {k: str(v[0]) for k,v in attributes['host'][host].items()}
+ for host in attributes['host'] if host in targets
+ }
+
+ common_attrs = set.intersection(*(set(d.items()) for d in host_attrs.values()))
+ filtered_attrs = {}
+
+ if display == 'common':
+ filtered_attrs['_common_'] = dict((k,(v, None, None)) for k,v in common_attrs)
+ targets = ['_common_']
+ elif display == 'distinct':
+ for host in host_attrs:
+ host_pairs = set(d for d in host_attrs[host].items())
+ filtered_attrs[host] = dict((k,attributes['host'][host][k]) for k,v in host_pairs.difference(common_attrs))
+
+ attributes['host'] = filtered_attrs
self.beginOutput()
diff --git a/common/src/stack/command/stack/commands/list/host/attr/__init__.py b/common/src/stack/command/stack/commands/list/host/attr/__init__.py
index 5e5348d5b..fd8945292 100644
--- a/common/src/stack/command/stack/commands/list/host/attr/__init__.py
+++ b/common/src/stack/command/stack/commands/list/host/attr/__init__.py
@@ -26,6 +26,20 @@ class Command(stack.commands.list.host.command):
be listed.
+
+ Control which attributes are displayed for the provided
+ hosts.
+
+ 'all' will display all attributes for each host, grouped
+ by host. This is the default.
+
+ 'common' will display only attributes which are identical
+ for every host.
+
+ 'distinct' will display only attributes which are not
+ identical for every host.
+
+
List the attributes for backend-0-0.
diff --git a/test-framework/test-suites/integration/tests/list/test_list_host_attr.py b/test-framework/test-suites/integration/tests/list/test_list_host_attr.py
index e69de29bb..9125ff0c6 100644
--- a/test-framework/test-suites/integration/tests/list/test_list_host_attr.py
+++ b/test-framework/test-suites/integration/tests/list/test_list_host_attr.py
@@ -0,0 +1,129 @@
+import json
+from operator import itemgetter
+from itertools import groupby
+
+class TestListHostAttr:
+ def test_invalid(self, host):
+ result = host.run('stack list host attr test')
+ assert result.rc == 255
+ assert result.stderr.startswith('error - ')
+
+ def test_no_args_frontend_only(self, host):
+ result = host.run('stack list host attr output-format=json')
+ assert result.rc == 0
+ attr_obj = json.loads(result.stdout)
+
+ # with no other hosts in the db, these commands produce identical output
+ result = host.run('stack list host attr localhost output-format=json')
+ assert result.rc == 0
+ json.loads(result.stdout) == attr_obj
+
+ # test appliance selector, too
+ result = host.run('stack list host attr a:frontend output-format=json')
+ assert result.rc == 0
+ json.loads(result.stdout) == attr_obj
+
+ # there should be exactly one host
+ assert len({row['host'] for row in attr_obj}) == 1
+
+ def test_with_backend(self, host, add_host):
+ result = host.run('stack list host attr output-format=json')
+ assert result.rc == 0
+ attr_obj = json.loads(result.stdout)
+
+ # with other hosts in the db, this will be different
+ result = host.run('stack list host attr localhost output-format=json')
+ assert result.rc == 0
+ json.loads(result.stdout) != attr_obj
+
+ # test appliance selector, too
+ result = host.run('stack list host attr a:frontend output-format=json')
+ assert result.rc == 0
+ json.loads(result.stdout) != attr_obj
+
+ # both selectors should work together, though
+ result = host.run('stack list host attr a:frontend a:backend output-format=json')
+ assert result.rc == 0
+ json.loads(result.stdout) == attr_obj
+
+ # both hostnames specified should be the same as none by default
+ result = host.run('stack list host attr localhost backend-0-0 output-format=json')
+ assert result.rc == 0
+ json.loads(result.stdout) == attr_obj
+
+ # there should be exactly two hosts
+ assert len({row['host'] for row in attr_obj}) == 2
+
+ def test_common_with_only_frontend(self, host):
+ result = host.run('stack list host attr display=common output-format=json')
+ assert result.rc == 0
+ attr_obj = json.loads(result.stdout)
+
+ # there should be one "host" called '_common_'
+ assert len({row['host'] for row in attr_obj}) == 1
+ assert attr_obj[0]['host'] == '_common_'
+
+ def test_distinct_with_multiple_hosts(self, host, add_host):
+ result = host.run('stack list host attr display=distinct output-format=json')
+ assert result.rc == 0
+ attr_obj = json.loads(result.stdout)
+
+ host_attrs = {
+ k: {i['attr']: i['value'] for i in v}
+ for k, v in groupby(attr_obj, itemgetter('host'))
+ }
+
+ # don't hardcode FE hostname
+ fe_hostname = [h for h in host_attrs if h != 'backend-0-0'].pop()
+ assert len(host_attrs) == 2
+ assert {'backend-0-0', fe_hostname} == set(host_attrs)
+
+ # some keys will only be in common (by default)
+ assert 'Kickstart_PrivateRootPassword' not in host_attrs[fe_hostname]
+ assert 'Kickstart_PrivateRootPassword' not in host_attrs['backend-0-0']
+ # some keys will always be distinct
+ assert 'hostname' in host_attrs['backend-0-0']
+ # backend doesn't have a hostaddr here
+ assert 'hostaddr' in host_attrs[fe_hostname]
+
+ result = host.run('stack add host attr backend-0-0 attr=foo value=bar')
+ assert result.rc == 0
+
+ result = host.run('stack list host attr display=distinct output-format=json')
+ assert result.rc == 0
+ new_attr_obj = json.loads(result.stdout)
+
+ new_host_attrs = {
+ k: {i['attr']: i['value'] for i in v}
+ for k, v in groupby(new_attr_obj, itemgetter('host'))
+ }
+
+ assert len(new_host_attrs['backend-0-0']) == len(host_attrs['backend-0-0']) + 1
+ assert len(new_host_attrs[fe_hostname]) == len(host_attrs[fe_hostname])
+
+ result = host.run('stack list host attr display=common output-format=json')
+ assert result.rc == 0
+ common_attr_obj = json.loads(result.stdout)
+
+ common_host_attrs = {
+ k: {i['attr']: i['value'] for i in v}
+ for k, v in groupby(common_attr_obj, itemgetter('host'))
+ }
+
+ # the set of common attrs and distinct attrs should never overlap
+ assert set(common_host_attrs['_common_']).isdisjoint(new_host_attrs['backend-0-0'])
+
+ def test_common_with_multiple_hosts_single_attr_param(self, host, add_host):
+ result = host.run('stack list host attr display=distinct attr=hostname output-format=json')
+ assert result.rc == 0
+ attr_obj = json.loads(result.stdout)
+
+ # only two hosts, no common attrs here
+ assert len({row['host'] for row in attr_obj}) == 2
+
+ result = host.run('stack list host attr display=common attr=box output-format=json')
+ assert result.rc == 0
+ attr_obj = json.loads(result.stdout)
+
+ # by default these will resolve to the same, so only common will be listed
+ assert {row['host'] for row in attr_obj} == {'_common_'}