From 156ef0acfdb14a90cbb2c98802291d9f85e74b4e Mon Sep 17 00:00:00 2001 From: "Brian C. Lane" Date: Fri, 24 May 2019 14:43:15 -0700 Subject: [PATCH] composer-cli: Update diff support for customizations and repos.git composer-cli will now output information about changes to customizations entries and the repos.git entries. --- src/composer/cli/blueprints.py | 145 ++++++++++++++++++++++-------- tests/composer/test_blueprints.py | 107 ++++++++++++++++++++-- 2 files changed, 208 insertions(+), 44 deletions(-) diff --git a/src/composer/cli/blueprints.py b/src/composer/cli/blueprints.py index bc8030b4..dc374872 100644 --- a/src/composer/cli/blueprints.py +++ b/src/composer/cli/blueprints.py @@ -187,62 +187,129 @@ def blueprints_diff(socket_path, api_version, args, show_json=False): return rc for diff in result["diff"]: - print(prettyDiffEntry(diff)) + print(pretty_diff_entry(diff)) return rc -def prettyDiffEntry(diff): +def pretty_dict(d): + """Return the dict as a human readable single line + + :param d: key/values + :type d: dict + :returns: String of the dict's keys and values + :rtype: str + + key="str", key="str1,str2", ... + """ + result = [] + for k in d: + if type(d[k]) == type(""): + result.append('%s="%s"' % (k, d[k])) + elif type(d[k]) == type([]) and type(d[k][0]) == type(""): + result.append('%s="%s"' % (k, ", ".join(d[k]))) + elif type(d[k]) == type([]) and type(d[k][0]) == type({}): + result.append('%s="%s"' % (k, pretty_dict(d[k]))) + return " ".join(result) + +def dict_names(lst): + """Return comma-separated list of the dict's name/user fields + + :param d: key/values + :type d: dict + :returns: String of the dict's keys and values + :rtype: str + + root, norm + """ + if "user" in lst[0]: + field_name = "user" + elif "name" in lst[0]: + field_name = "name" + else: + # Use first fields in sorted keys + field_name = sorted(lst[0].keys())[0] + + return ", ".join(d[field_name] for d in lst) + +def pretty_diff_entry(diff): """Generate nice diff entry string. :param diff: Difference entry dict :type diff: dict :returns: Nice string """ - def change(diff): - if diff["old"] and diff["new"]: - return "Changed" - elif diff["new"] and not diff["old"]: - return "Added" - elif diff["old"] and not diff["new"]: - return "Removed" - else: - return "Unknown" + if diff["old"] and diff["new"]: + change = "Changed" + elif diff["new"] and not diff["old"]: + change = "Added" + elif diff["old"] and not diff["new"]: + change = "Removed" + else: + change = "Unknown" - def name(diff): - if diff["old"]: - return list(diff["old"].keys())[0] - elif diff["new"]: - return list(diff["new"].keys())[0] - else: - return "Unknown" + if diff["old"]: + name = list(diff["old"].keys())[0] + elif diff["new"]: + name = list(diff["new"].keys())[0] + else: + name = "Unknown" def details(diff): - if change(diff) == "Changed": - if name(diff) == "Description": - return '"%s" -> "%s"' % (diff["old"][name(diff)], diff["new"][name(diff)]) - elif name(diff) == "Version": - return "%s -> %s" % (diff["old"][name(diff)], diff["new"][name(diff)]) - elif name(diff) in ["Module", "Package"]: - return "%s %s -> %s" % (diff["old"][name(diff)]["name"], diff["old"][name(diff)]["version"], - diff["new"][name(diff)]["version"]) + if change == "Changed": + if type(diff["old"][name]) == type(""): + if name == "Description" or " " in diff["old"][name]: + return '"%s" -> "%s"' % (diff["old"][name], diff["new"][name]) + else: + return "%s -> %s" % (diff["old"][name], diff["new"][name]) + elif name in ["Module", "Package"]: + return "%s %s -> %s" % (diff["old"][name]["name"], diff["old"][name]["version"], + diff["new"][name]["version"]) + elif type(diff["old"][name]) == type([]): + if type(diff["old"][name][0]) == type(""): + return "%s -> %s" % (" ".join(diff["old"][name]), " ".join(diff["new"][name])) + elif type(diff["old"][name][0]) == type({}): + # Lists of dicts are too long to display in detail, just show their names + return "%s -> %s" % (dict_names(diff["old"][name]), dict_names(diff["new"][name])) + elif type(diff["old"][name]) == type({}): + return "%s -> %s" % (pretty_dict(diff["old"][name]), pretty_dict(diff["new"][name])) else: return "Unknown" - elif change(diff) == "Added": - if name(diff) in ["Module", "Package"]: - return "%s %s" % (diff["new"][name(diff)]["name"], diff["new"][name(diff)]["version"]) - elif name(diff) in ["Group"]: - return diff["new"][name(diff)]["name"] + elif change == "Added": + if name in ["Module", "Package"]: + return "%s %s" % (diff["new"][name]["name"], diff["new"][name]["version"]) + elif name in ["Group"]: + return diff["new"][name]["name"] + elif type(diff["new"][name]) == type(""): + return diff["new"][name] + elif type(diff["new"][name]) == type([]): + if type(diff["new"][name][0]) == type(""): + return " ".join(diff["new"][name]) + elif type(diff["new"][name][0]) == type({}): + # Lists of dicts are too long to display in detail, just show their names + return dict_names(diff["new"][name]) + elif type(diff["new"][name]) == type({}): + return pretty_dict(diff["new"][name]) else: - return " ".join([diff["new"][k] for k in diff["new"]]) - elif change(diff) == "Removed": - if name(diff) in ["Module", "Package"]: - return "%s %s" % (diff["old"][name(diff)]["name"], diff["old"][name(diff)]["version"]) - elif name(diff) in ["Group"]: - return diff["old"][name(diff)]["name"] + return "unknown/todo: %s" % type(diff["new"][name]) + elif change == "Removed": + if name in ["Module", "Package"]: + return "%s %s" % (diff["old"][name]["name"], diff["old"][name]["version"]) + elif name in ["Group"]: + return diff["old"][name]["name"] + elif type(diff["old"][name]) == type(""): + return diff["old"][name] + elif type(diff["old"][name]) == type([]): + if type(diff["old"][name][0]) == type(""): + return " ".join(diff["old"][name]) + elif type(diff["old"][name][0]) == type({}): + # Lists of dicts are too long to display in detail, just show their names + return dict_names(diff["old"][name]) + elif type(diff["old"][name]) == type({}): + return pretty_dict(diff["old"][name]) else: - return " ".join([diff["old"][k] for k in diff["old"]]) + return "unknown/todo: %s" % type(diff["new"][name]) - return change(diff) + " " + name(diff) + " " + details(diff) + return change + " " + name + " " + details(diff) def blueprints_save(socket_path, api_version, args, show_json=False): """Save the blueprint to a TOML file diff --git a/tests/composer/test_blueprints.py b/tests/composer/test_blueprints.py index aa7f4b7d..d75dcb06 100644 --- a/tests/composer/test_blueprints.py +++ b/tests/composer/test_blueprints.py @@ -19,9 +19,10 @@ import unittest from ..lib import captured_output -from composer.cli.blueprints import prettyDiffEntry, blueprints_list, blueprints_show, blueprints_changes +from composer.cli.blueprints import pretty_diff_entry, blueprints_list, blueprints_show, blueprints_changes from composer.cli.blueprints import blueprints_diff, blueprints_save, blueprints_delete, blueprints_depsolve from composer.cli.blueprints import blueprints_push, blueprints_freeze, blueprints_undo, blueprints_tag +from composer.cli.blueprints import pretty_dict, dict_names diff_entries = [{'new': {'Description': 'Shiny new description'}, 'old': {'Description': 'Old reliable description'}}, {'new': {'Version': '0.3.1'}, 'old': {'Version': '0.1.1'}}, @@ -29,7 +30,34 @@ diff_entries = [{'new': {'Description': 'Shiny new description'}, 'old': {'Descr {'new': None, 'old': {'Module': {'name': 'bash', 'version': '5.*'}}}, {'new': {'Module': {'name': 'httpd', 'version': '3.8.*'}}, 'old': {'Module': {'name': 'httpd', 'version': '3.7.*'}}}, - {'new': {'Package': {'name': 'git', 'version': '2.13.*'}}, 'old': None}] + {'new': {'Package': {'name': 'git', 'version': '2.13.*'}}, 'old': None}, + # New items + {"new": {"Group": {"name": "core"}}, "old": None}, + {"new": {"Customizations.firewall": {"ports": ["8888:tcp", "22:tcp", "dns:udp", "9090:tcp"], "services": ["smtp"]}}, "old": None}, + {"new": {"Customizations.hostname": "foobar"}, "old": None}, + {"new": {"Customizations.locale": {"keyboard": "US"}}, "old": None}, + {"new": {"Customizations.sshkey": [{"key": "ssh-rsa AAAAB3NzaC1... norm@localhost.localdomain", "user": "norm" }]}, "old": None}, + {"new": {"Customizations.timezone": {"ntpservers": ["ntp.nowhere.com" ], "timezone": "PST8PDT"}}, "old": None}, + {"new": {"Customizations.user": [{"key": "ssh-rsa AAAAB3NzaC1... root@localhost.localdomain", "name": "root", "password": "fobarfobar"}]}, "old": None}, + {"new": {"Repos.git": {"destination": "/opt/server-1/", "ref": "v1.0", "repo": "PATH OF GIT REPO TO CLONE", "rpmname": "server-config", "rpmrelease": "2", "rpmversion": "1.0", "summary": "Setup files for server deployment"}}, "old": None}, + # Removed items (just reversed old/old from above block) + {"old": {"Group": {"name": "core"}}, "new": None}, + {"old": {"Customizations.firewall": {"ports": ["8888:tcp", "22:tcp", "dns:udp", "9090:tcp"], "services": ["smtp"]}}, "new": None}, + {"old": {"Customizations.hostname": "foobar"}, "new": None}, + {"old": {"Customizations.locale": {"keyboard": "US"}}, "new": None}, + {"old": {"Customizations.sshkey": [{"key": "ssh-rsa AAAAB3NzaC1... norm@localhost.localdomain", "user": "norm" }]}, "new": None}, + {"old": {"Customizations.timezone": {"ntpservers": ["ntp.nowhere.com" ], "timezone": "PST8PDT"}}, "new": None}, + {"old": {"Customizations.user": [{"key": "ssh-rsa AAAAB3NzaC1... root@localhost.localdomain", "name": "root", "password": "fobarfobar"}]}, "new": None}, + {"old": {"Repos.git": {"destination": "/opt/server-1/", "ref": "v1.0", "repo": "PATH OF GIT REPO TO CLONE", "rpmname": "server-config", "rpmrelease": "2", "rpmversion": "1.0", "summary": "Setup files for server deployment"}}, "new": None}, + # Changed items + {"old": {"Customizations.firewall": {"ports": ["8888:tcp", "22:tcp", "dns:udp", "9090:tcp"], "services": ["smtp"]}}, "new": {"Customizations.firewall": {"ports": ["8888:tcp", "22:tcp", "25:tcp"]}}}, + {"old": {"Customizations.hostname": "foobar"}, "new": {"Customizations.hostname": "grues"}}, + {"old": {"Customizations.locale": {"keyboard": "US"}}, "new": {"Customizations.locale": {"keyboard": "US", "languages": ["en_US.UTF-8"]}}}, + {"old": {"Customizations.sshkey": [{"key": "ssh-rsa AAAAB3NzaC1... norm@localhost.localdomain", "user": "norm" }]}, "new": {"Customizations.sshkey": [{"key": "ssh-rsa ABCDEF01234... norm@localhost.localdomain", "user": "norm" }]}}, + {"old": {"Customizations.timezone": {"ntpservers": ["ntp.nowhere.com" ], "timezone": "PST8PDT"}}, "new": {"Customizations.timezone": {"timezone": "Antarctica/Palmer"}}}, + {"old": {"Customizations.user": [{"key": "ssh-rsa AAAAB3NzaC1... root@localhost.localdomain", "name": "root", "password": "fobarfobar"}]}, "new": {"Customizations.user": [{"key": "ssh-rsa AAAAB3NzaC1... root@localhost.localdomain", "name": "root", "password": "qweqweqwe"}]}}, + {"old": {"Repos.git": {"destination": "/opt/server-1/", "ref": "v1.0", "repo": "PATH OF GIT REPO TO CLONE", "rpmname": "server-config", "rpmrelease": "2", "rpmversion": "1.0", "summary": "Setup files for server deployment"}}, "new": {"Repos.git": {"destination": "/opt/server-1/", "ref": "v1.0", "repo": "PATH OF GIT REPO TO CLONE", "rpmname": "server-config", "rpmrelease": "1", "rpmversion": "1.1", "summary": "Setup files for server deployment"}}} + ] diff_result = [ 'Changed Description "Old reliable description" -> "Shiny new description"', @@ -37,12 +65,81 @@ diff_result = [ 'Added Module openssh 2.8.1', 'Removed Module bash 5.*', 'Changed Module httpd 3.7.* -> 3.8.*', - 'Added Package git 2.13.*'] + 'Added Package git 2.13.*', + 'Added Group core', + 'Added Customizations.firewall ports="8888:tcp, 22:tcp, dns:udp, 9090:tcp" services="smtp"', + 'Added Customizations.hostname foobar', + 'Added Customizations.locale keyboard="US"', + 'Added Customizations.sshkey norm', + 'Added Customizations.timezone ntpservers="ntp.nowhere.com" timezone="PST8PDT"', + 'Added Customizations.user root', + 'Added Repos.git destination="/opt/server-1/" ref="v1.0" repo="PATH OF GIT REPO TO CLONE" rpmname="server-config" rpmrelease="2" rpmversion="1.0" summary="Setup files for server deployment"', + 'Removed Group core', + 'Removed Customizations.firewall ports="8888:tcp, 22:tcp, dns:udp, 9090:tcp" services="smtp"', + 'Removed Customizations.hostname foobar', + 'Removed Customizations.locale keyboard="US"', + 'Removed Customizations.sshkey norm', + 'Removed Customizations.timezone ntpservers="ntp.nowhere.com" timezone="PST8PDT"', + 'Removed Customizations.user root', + 'Removed Repos.git destination="/opt/server-1/" ref="v1.0" repo="PATH OF GIT REPO TO CLONE" rpmname="server-config" rpmrelease="2" rpmversion="1.0" summary="Setup files for server deployment"', + 'Changed Customizations.firewall ports="8888:tcp, 22:tcp, dns:udp, 9090:tcp" services="smtp" -> ports="8888:tcp, 22:tcp, 25:tcp"', + 'Changed Customizations.hostname foobar -> grues', + 'Changed Customizations.locale keyboard="US" -> keyboard="US" languages="en_US.UTF-8"', + 'Changed Customizations.sshkey norm -> norm', + 'Changed Customizations.timezone ntpservers="ntp.nowhere.com" timezone="PST8PDT" -> timezone="Antarctica/Palmer"', + 'Changed Customizations.user root -> root', + 'Changed Repos.git destination="/opt/server-1/" ref="v1.0" repo="PATH OF GIT REPO TO CLONE" rpmname="server-config" rpmrelease="2" rpmversion="1.0" summary="Setup files for server deployment" -> destination="/opt/server-1/" ref="v1.0" repo="PATH OF GIT REPO TO CLONE" rpmname="server-config" rpmrelease="1" rpmversion="1.1" summary="Setup files for server deployment"', + ] + +dict_entries = [{"ports": ["8888:tcp", "22:tcp", "dns:udp", "9090:tcp"]}, + {"ports": ["8888:tcp", "22:tcp", "dns:udp", "9090:tcp"], "services": ["smtp"]}, + { "destination": "/opt/server-1/", "ref": "v1.0", "repo": "PATH OF GIT REPO TO CLONE", "rpmname": "server-config", "rpmrelease": "1", "rpmversion": "1.0", "summary": "Setup files for server deployment" }, + {"foo": ["one", "two"], "bar": {"baz": "three"}}] + +dict_results = ['ports="8888:tcp, 22:tcp, dns:udp, 9090:tcp"', + 'ports="8888:tcp, 22:tcp, dns:udp, 9090:tcp" services="smtp"', + 'destination="/opt/server-1/" ref="v1.0" repo="PATH OF GIT REPO TO CLONE" rpmname="server-config" rpmrelease="1" rpmversion="1.0" summary="Setup files for server deployment"', + 'foo="one, two"'] + +dict_name_entry1 = [{"name": "bart", "home": "Springfield"}, + {"name": "lisa", "instrument": "Saxaphone"}, + {"name": "homer", "kids": ["bart", "maggie", "lisa"]}] + +dict_name_results1 = "bart, lisa, homer" + +dict_name_entry2 = [{"user": "root", "password": "qweqweqwe"}, + {"user": "norm", "password": "b33r"}, + {"user": "cliff", "password": "POSTMASTER"}] + +dict_name_results2 = "root, norm, cliff" + +dict_name_entry3 = [{"home": "/root", "key": "skeleton"}, + {"home": "/home/norm", "key": "SSH KEY"}, + {"home": "/home/cliff", "key": "lost"}] + +dict_name_results3 = "/root, /home/norm, /home/cliff" + class BlueprintsTest(unittest.TestCase): - def test_prettyDiffEntry(self): + def test_pretty_diff_entry(self): """Return a nice representation of a diff entry""" - self.assertEqual([prettyDiffEntry(entry) for entry in diff_entries], diff_result) + self.assertEqual([pretty_diff_entry(entry) for entry in diff_entries], diff_result) + + def test_pretty_dict(self): + """Return a human readable single line""" + self.assertEqual([pretty_dict(entry) for entry in dict_entries], dict_results) + + def test_dict_names_users(self): + """Return a list of the name field of the list of dicts""" + self.assertEqual(dict_names(dict_name_entry1), dict_name_results1) + + def test_dict_names_sshkey(self): + """Return a list of the user field of the list of dicts""" + self.assertEqual(dict_names(dict_name_entry2), dict_name_results2) + + def test_dict_names_other(self): + """Return a list of the unknown field of the list of dicts""" + self.assertEqual(dict_names(dict_name_entry3), dict_name_results3) @unittest.skipUnless(os.path.exists("/run/weldr/api.socket"), "Test requires a running API server") def test_list(self):