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