diff --git a/plugins/cliconf/vyos.py b/plugins/cliconf/vyos.py index d63c677e..0471fd5e 100644 --- a/plugins/cliconf/vyos.py +++ b/plugins/cliconf/vyos.py @@ -55,6 +55,9 @@ to_list, ) from ansible.plugins.cliconf import CliconfBase +from ansible_collections.vyos.vyos.plugins.cliconf_utils.vyosconf import ( + VyosConf, +) class Cliconf(CliconfBase): @@ -263,6 +266,12 @@ def get_diff( diff["config_diff"] = list(candidate_commands) return diff + if diff_match == "smart": + running_conf = VyosConf(running.splitlines()) + candidate_conf = VyosConf(candidate_commands) + diff["config_diff"] = running_conf.diff_commands_to(candidate_conf) + return diff + running_commands = [ str(c).replace("'", "") for c in running.splitlines() ] @@ -339,7 +348,7 @@ def get_device_operations(self): def get_option_values(self): return { "format": ["text", "set"], - "diff_match": ["line", "none"], + "diff_match": ["line", "smart", "none"], "diff_replace": [], "output": [], } diff --git a/plugins/cliconf_utils/__init__.py b/plugins/cliconf_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/cliconf_utils/vyosconf.py b/plugins/cliconf_utils/vyosconf.py new file mode 100644 index 00000000..404948ed --- /dev/null +++ b/plugins/cliconf_utils/vyosconf.py @@ -0,0 +1,220 @@ +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import re + + +class VyosConf: + def __init__(self, commands=None): + self.config = {} + if type(commands) is list: + self.run_commands(commands) + + def set_entry(self, path, leaf): + """ + This function sets a value in the configuration given a path. + :param path: list of strings to traveser in the config + :param leaf: value to set at the destination + :return: dict + """ + target = self.config + path = path + [leaf] + for key in path: + if key not in target or type(target[key]) is not dict: + target[key] = {} + target = target[key] + return self.config + + def del_entry(self, path, leaf): + """ + This function deletes a value from the configuration given a path + and also removes all the parents that are now empty. + :param path: list of strings to traveser in the config + :param leaf: value to delete at the destination + :return: dict + """ + target = self.config + firstNoSiblingKey = None + for key in path: + if key not in target: + return self.config + if len(target[key]) <= 1: + if firstNoSiblingKey is None: + firstNoSiblingKey = [target, key] + else: + firstNoSiblingKey = None + target = target[key] + + if firstNoSiblingKey is None: + firstNoSiblingKey = [target, leaf] + + target = firstNoSiblingKey[0] + targetKey = firstNoSiblingKey[1] + del target[targetKey] + return self.config + + def check_entry(self, path, leaf): + """ + This function checks if a value exists in the config. + :param path: list of strings to traveser in the config + :param leaf: value to check for existence + :return: bool + """ + target = self.config + path = path + [leaf] + existing = [] + for key in path: + if key not in target or type(target[key]) is not dict: + return False + existing.append(key) + target = target[key] + return True + + def parse_line(self, line): + """ + This function parses a given command from string. + :param line: line to parse + :return: [command, path, leaf] + """ + line = ( + re.match(r"^('(.*)'|\"(.*)\"|([^#\"']*))*", line).group(0).strip() + ) + path = re.findall(r"('.*?'|\".*?\"|\S+)", line) + leaf = path[-1] + if leaf.startswith('"') and leaf.endswith('"'): + leaf = leaf[1:-1] + if leaf.startswith("'") and leaf.endswith("'"): + leaf = leaf[1:-1] + return [path[0], path[1:-1], leaf] + + def run_command(self, command): + """ + This function runs a given command string. + :param command: command to run + :return: dict + """ + [cmd, path, leaf] = self.parse_line(command) + if cmd.startswith("set"): + self.set_entry(path, leaf) + if cmd.startswith("del"): + self.del_entry(path, leaf) + return self.config + + def run_commands(self, commands): + """ + This function runs a a list of command strings. + :param commands: commands to run + :return: dict + """ + for c in commands: + self.run_command(c) + return self.config + + def check_command(self, command): + """ + This function checkes a command for existance in the config. + :param command: command to check + :return: bool + """ + [cmd, path, leaf] = self.parse_line(command) + if cmd.startswith("set"): + return self.check_entry(path, leaf) + if cmd.startswith("del"): + return not self.check_entry(path, leaf) + return True + + def check_commands(self, commands): + """ + This function checkes a list of commands for existance in the config. + :param commands: list of commands to check + :return: [bool] + """ + return [self.check_command(c) for c in commands] + + def build_commands(self, structure=None, nested=False): + """ + This function builds a list of commands to recreate the current configuration. + :return: [str] + """ + if type(structure) is not dict: + structure = self.config + if len(structure) == 0: + return [""] if nested else [] + commands = [] + for (key, value) in structure.items(): + for c in self.build_commands(value, True): + if " " in key or '"' in key: + key = "'" + key + "'" + commands.append((key + " " + c).strip()) + if nested: + return commands + return ["set " + c for c in commands] + + def diff_to(self, other, structure): + if type(other) is not dict: + other = {} + if len(structure) == 0: + return ([], [""]) + if type(structure) is not dict: + structure = {} + if len(other) == 0: + return ([""], []) + if len(other) == 0 and len(structure) == 0: + return ([], []) + + toset = [] + todel = [] + for key in structure.keys(): + quoted_key = "'" + key + "'" if " " in key or '"' in key else key + if key in other: + # keys in both configs, pls compare subkeys + (subset, subdel) = self.diff_to(other[key], structure[key]) + for s in subset: + toset.append(quoted_key + " " + s) + if "!" not in other[key]: + for d in subdel: + todel.append(quoted_key + " " + d) + else: + # keys only in this, pls del + todel.append(quoted_key) + continue # del + for (key, value) in other.items(): + if key == "!": + continue + quoted_key = "'" + key + "'" if " " in key or '"' in key else key + if key not in structure: + # keys only in other, pls set all subkeys + (subset, subdel) = self.diff_to(other[key], None) + for s in subset: + toset.append(quoted_key + " " + s) + + return (toset, todel) + + def diff_commands_to(self, other): + """ + This function calculates the required commands to change the current into + the given configuration. + :param other: VyosConf + :return: [str] + """ + (toset, todel) = self.diff_to(other.config, self.config) + return ["delete " + c.strip() for c in todel] + [ + "set " + c.strip() for c in toset + ] diff --git a/plugins/modules/vyos_config.py b/plugins/modules/vyos_config.py index 583ba094..a2969d3f 100644 --- a/plugins/modules/vyos_config.py +++ b/plugins/modules/vyos_config.py @@ -58,14 +58,18 @@ match: description: - The C(match) argument controls the method used to match against the current - active configuration. By default, the desired config is matched against the - active config and the deltas are loaded. If the C(match) argument is set to - C(none) the active configuration is ignored and the configuration is always - loaded. + active configuration. By default, the configuration commands config are + matched against the active config and the deltas are loaded line by line. + If the C(match) argument is set to C(none) the active configuration is ignored + and the configuration is always loaded. If the C(match) argument is set to C(smart) + both the active configuration and the target configuration are simlulated + and the results compared to bring the target device into a reliable and + reproducable state. type: str default: line choices: - line + - smart - none backup: description: @@ -139,6 +143,7 @@ - name: render a Jinja2 template onto the VyOS router vyos.vyos.vyos_config: + match: smart src: vyos_template.j2 - name: for idempotency, use full-form commands @@ -211,7 +216,9 @@ DEFAULT_COMMENT = "configured by vyos_config" CONFIG_FILTERS = [ - re.compile(r"set system login user \S+ authentication encrypted-password") + re.compile( + r"set system login user \S+ authentication encrypted-password" + ) ] @@ -332,7 +339,7 @@ def main(): argument_spec = dict( src=dict(type="path"), lines=dict(type="list", elements="str"), - match=dict(default="line", choices=["line", "none"]), + match=dict(default="line", choices=["line", "smart", "none"]), comment=dict(default=DEFAULT_COMMENT), config=dict(), backup=dict(type="bool", default=False), diff --git a/tests/unit/cliconf/test_utils_vyosconf.py b/tests/unit/cliconf/test_utils_vyosconf.py new file mode 100644 index 00000000..34b49c38 --- /dev/null +++ b/tests/unit/cliconf/test_utils_vyosconf.py @@ -0,0 +1,160 @@ +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import unittest +from ansible_collections.vyos.vyos.plugins.cliconf_utils.vyosconf import ( + VyosConf, +) + + +class TestListElements(unittest.TestCase): + def test_add(self): + conf = VyosConf() + conf.set_entry(["a", "b"], "c") + self.assertEqual(conf.config, {"a": {"b": {"c": {}}}}) + conf.set_entry(["a", "b"], "d") + self.assertEqual(conf.config, {"a": {"b": {"c": {}, "d": {}}}}) + conf.set_entry(["a", "c"], "b") + self.assertEqual( + conf.config, {"a": {"b": {"c": {}, "d": {}}, "c": {"b": {}}}} + ) + conf.set_entry(["a", "c", "b"], "d") + self.assertEqual( + conf.config, + {"a": {"b": {"c": {}, "d": {}}, "c": {"b": {"d": {}}}}}, + ) + + def test_del(self): + conf = VyosConf() + conf.set_entry(["a", "b"], "c") + conf.set_entry(["a", "c", "b"], "d") + conf.set_entry(["a", "b"], "d") + self.assertEqual( + conf.config, + {"a": {"b": {"c": {}, "d": {}}, "c": {"b": {"d": {}}}}}, + ) + conf.del_entry(["a", "c", "b"], "d") + self.assertEqual(conf.config, {"a": {"b": {"c": {}, "d": {}}}}) + conf.set_entry(["a", "b", "c"], "d") + conf.del_entry(["a", "b", "c"], "d") + self.assertEqual(conf.config, {"a": {"b": {"d": {}}}}) + + def test_parse(self): + conf = VyosConf() + self.assertListEqual( + conf.parse_line("set a b c"), ["set", ["a", "b"], "c"] + ) + self.assertListEqual( + conf.parse_line('set a b "c"'), ["set", ["a", "b"], "c"] + ) + self.assertListEqual( + conf.parse_line("set a b 'c d'"), ["set", ["a", "b"], "c d"] + ) + self.assertListEqual( + conf.parse_line("set a b 'c'"), ["set", ["a", "b"], "c"] + ) + self.assertListEqual( + conf.parse_line("delete a b 'c'"), ["delete", ["a", "b"], "c"] + ) + self.assertListEqual( + conf.parse_line("del a b 'c'"), ["del", ["a", "b"], "c"] + ) + self.assertListEqual( + conf.parse_line("set a b '\"c'"), ["set", ["a", "b"], '"c'] + ) + self.assertListEqual( + conf.parse_line("set a b 'c' #this is a comment"), + ["set", ["a", "b"], "c"], + ) + self.assertListEqual( + conf.parse_line("set a b '#c'"), ["set", ["a", "b"], "#c"] + ) + + def test_run_commands(self): + self.assertEqual( + VyosConf(["set a b 'c'", "set a c 'b'"]).config, + {"a": {"b": {"c": {}}, "c": {"b": {}}}}, + ) + self.assertEqual( + VyosConf(["set a b c 'd'", "set a c 'b'", "del a b c d"]).config, + {"a": {"c": {"b": {}}}}, + ) + + def test_build_commands(self): + self.assertEqual( + sorted( + VyosConf( + [ + "set a b 'c a'", + "set a c a", + "set a c b", + "delete a c a", + ] + ).build_commands() + ), + sorted(["set a b 'c a'", "set a c b"]), + ) + + def test_check_commands(self): + conf = VyosConf(["set a b 'c a'", "set a c b"]) + self.assertListEqual( + conf.check_commands( + ["set a b 'c a'", "del a c b", "set a b 'c'", "del a a a"] + ), + [True, False, False, True], + ) + + def test_diff_commands_to(self): + conf = VyosConf(["set a b 'c a'", "set a c b"]) + + self.assertListEqual( + conf.diff_commands_to(VyosConf(["set a c b"])), ["delete a b"] + ) + self.assertListEqual( + conf.diff_commands_to(VyosConf(["set a b 'c a'", "set a c b"])), [] + ) + + self.assertListEqual( + conf.diff_commands_to( + VyosConf( + [ + "set a b !", + ] + ) + ), + ["delete a c"], + ) + self.assertListEqual( + conf.diff_commands_to(VyosConf(["set a !", "set a d e"])), + ["set a d e"], + ) + self.assertListEqual( + conf.diff_commands_to(VyosConf(["set a b", "set a c b"])), + ["delete a b 'c a'"], + ) + + self.assertListEqual( + conf.diff_commands_to(VyosConf(["set a b 'a c'", "set a c b"])), + ["delete a b 'c a'", "set a b 'a c'"], + ) + + +if __name__ == "__main__": + unittest.main()