From d2204f6ddd9096d34449d0c2d9eacf9c24417d4d Mon Sep 17 00:00:00 2001 From: Om Nom Date: Wed, 23 Apr 2025 10:38:09 +1000 Subject: [PATCH 1/2] init pr190 changes --- docs/vyos.vyos.vyos_config_module.rst | 2 + plugins/cliconf/vyos.py | 11 +- plugins/cliconf_utils/__init__.py | 0 plugins/cliconf_utils/vyosconf.py | 235 ++++++++++++++++++++++++++ plugins/modules/vyos_config.py | 6 +- 5 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 plugins/cliconf_utils/__init__.py create mode 100644 plugins/cliconf_utils/vyosconf.py diff --git a/docs/vyos.vyos.vyos_config_module.rst b/docs/vyos.vyos.vyos_config_module.rst index 999a3f58d..2b995d9e4 100644 --- a/docs/vyos.vyos.vyos_config_module.rst +++ b/docs/vyos.vyos.vyos_config_module.rst @@ -159,6 +159,7 @@ Parameters @@ -234,6 +235,7 @@ Examples - 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 diff --git a/plugins/cliconf/vyos.py b/plugins/cliconf/vyos.py index 5beffaa1a..337c59201 100644 --- a/plugins/cliconf/vyos.py +++ b/plugins/cliconf/vyos.py @@ -49,11 +49,13 @@ from ansible.errors import AnsibleConnectionFailure from ansible.module_utils._text import to_text from ansible.module_utils.common._collections_compat import Mapping +from ansible.plugins.cliconf import CliconfBase from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.config import ( NetworkConfig, ) from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import to_list -from ansible_collections.ansible.netcommon.plugins.plugin_utils.cliconf_base import CliconfBase + +from ansible_collections.vyos.vyos.plugins.cliconf_utils.vyosconf import VyosConf class Cliconf(CliconfBase): @@ -253,6 +255,11 @@ def get_diff( if diff_match == "none": 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()] @@ -323,7 +330,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 000000000..e69de29bb diff --git a/plugins/cliconf_utils/vyosconf.py b/plugins/cliconf_utils/vyosconf.py new file mode 100644 index 000000000..7a4fc63c6 --- /dev/null +++ b/plugins/cliconf_utils/vyosconf.py @@ -0,0 +1,235 @@ +# +# 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 + + +KEEP_EXISTING_VALUES = "..." + + +class VyosConf: + def __init__(self, commands=None): + self.config = {} + if isinstance(commands, 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 not isinstance(target[key], 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 not isinstance(target[key], 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 quote_key(self, key): + """ + This function adds quotes to key if quotes are needed for correct parsing. + :param key: str to wrap in quotes if needed + :return: str + """ + if len(key) == 0: + return "" + if '"' in key: + return "'" + key + "'" + if "'" in key: + return '"' + key + '"' + if not re.match(r"^[a-zA-Z0-9./-]*$", key): + return "'" + key + "'" + return key + + def build_commands(self, structure=None, nested=False): + """ + This function builds a list of commands to recreate the current configuration. + :return: [str] + """ + if not isinstance(structure, dict): + structure = self.config + if len(structure) == 0: + return [""] if nested else [] + commands = [] + for key, value in structure.items(): + quoted_key = self.quote_key(key) + for c in self.build_commands(value, True): + commands.append((quoted_key + " " + c).strip()) + if nested: + return commands + return ["set " + c for c in commands] + + def diff_to(self, other, structure): + if not isinstance(other, dict): + other = {} + if len(structure) == 0: + return ([], [""]) + if not isinstance(structure, 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 = self.quote_key(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) + for d in subdel: + todel.append(quoted_key + " " + d) + else: + # keys only in this, delete if KEEP_EXISTING_VALUES not set + if KEEP_EXISTING_VALUES not in other: + todel.append(quoted_key) + continue # del + for key, value in other.items(): + if key == KEEP_EXISTING_VALUES: + continue + quoted_key = self.quote_key(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 60be02c80..030d55318 100644 --- a/plugins/modules/vyos_config.py +++ b/plugins/modules/vyos_config.py @@ -67,6 +67,7 @@ default: line choices: - line + - smart - none backup: description: @@ -140,6 +141,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 @@ -330,7 +332,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), @@ -360,7 +362,7 @@ def main(): diff = run_commands(module, commands=["configure", "compare saved"])[1] if diff not in { "[edit]", - "No changes between working and saved configurations.\n\n[edit]" + "No changes between working and saved configurations.\n\n[edit]", }: if not module.check_mode: run_commands(module, commands=["save"]) From 6f8dfaa9a7ccdd2b390918ed2a5f083eabeb4ebb Mon Sep 17 00:00:00 2001 From: Om Nom Date: Wed, 23 Apr 2025 11:00:37 +1000 Subject: [PATCH 2/2] unit test retroffitted --- tests/unit/cliconf/__init__.py | 0 tests/unit/cliconf/test_utils_vyosconf.py | 196 ++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 tests/unit/cliconf/__init__.py create mode 100644 tests/unit/cliconf/test_utils_vyosconf.py diff --git a/tests/unit/cliconf/__init__.py b/tests/unit/cliconf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/cliconf/test_utils_vyosconf.py b/tests/unit/cliconf/test_utils_vyosconf.py new file mode 100644 index 000000000..c6a3264d5 --- /dev/null +++ b/tests/unit/cliconf/test_utils_vyosconf.py @@ -0,0 +1,196 @@ +# +# 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"]), + ) + self.assertEqual( + sorted( + VyosConf( + [ + "set a b 10.0.0.1/24", + "set a c ABCabc123+/=", + "set a d $6$ABC.abc.123.+./=..", + ], + ).build_commands(), + ), + sorted( + [ + "set a b 10.0.0.1/24", + "set a c 'ABCabc123+/='", + "set a d '$6$ABC.abc.123.+./=..'", + ], + ), + ) + + 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'"], + ) + + self.assertListEqual( + VyosConf( + ["set a b c d", "set a b c e", "set a b d"], + ).diff_commands_to(VyosConf(["set a b c d", "set a b ..."])), + ["delete a b c e"], + ) + + +if __name__ == "__main__": + unittest.main()