Skip to content

Commit 66ef326

Browse files
committed
vyos_config improved diffing
1 parent 36004b2 commit 66ef326

File tree

5 files changed

+403
-7
lines changed

5 files changed

+403
-7
lines changed

plugins/cliconf/vyos.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
to_list,
5656
)
5757
from ansible.plugins.cliconf import CliconfBase
58+
from ansible_collections.vyos.vyos.plugins.cliconf_utils.vyosconf import (
59+
VyosConf,
60+
)
5861

5962

6063
class Cliconf(CliconfBase):
@@ -263,6 +266,12 @@ def get_diff(
263266
diff["config_diff"] = list(candidate_commands)
264267
return diff
265268

269+
if diff_match == "smart":
270+
running_conf = VyosConf(running.splitlines())
271+
candidate_conf = VyosConf(candidate_commands)
272+
diff["config_diff"] = running_conf.diff_commands_to(candidate_conf)
273+
return diff
274+
266275
running_commands = [
267276
str(c).replace("'", "") for c in running.splitlines()
268277
]
@@ -339,7 +348,7 @@ def get_device_operations(self):
339348
def get_option_values(self):
340349
return {
341350
"format": ["text", "set"],
342-
"diff_match": ["line", "none"],
351+
"diff_match": ["line", "smart", "none"],
343352
"diff_replace": [],
344353
"output": [],
345354
}

plugins/cliconf_utils/__init__.py

Whitespace-only changes.

plugins/cliconf_utils/vyosconf.py

+220
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#
2+
# This file is part of Ansible
3+
#
4+
# Ansible is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# Ansible is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
16+
#
17+
from __future__ import absolute_import, division, print_function
18+
19+
__metaclass__ = type
20+
21+
import re
22+
23+
24+
class VyosConf:
25+
def __init__(self, commands=None):
26+
self.config = {}
27+
if type(commands) is list:
28+
self.run_commands(commands)
29+
30+
def set_entry(self, path, leaf):
31+
"""
32+
This function sets a value in the configuration given a path.
33+
:param path: list of strings to traveser in the config
34+
:param leaf: value to set at the destination
35+
:return: dict
36+
"""
37+
target = self.config
38+
path = path + [leaf]
39+
for key in path:
40+
if key not in target or type(target[key]) is not dict:
41+
target[key] = {}
42+
target = target[key]
43+
return self.config
44+
45+
def del_entry(self, path, leaf):
46+
"""
47+
This function deletes a value from the configuration given a path
48+
and also removes all the parents that are now empty.
49+
:param path: list of strings to traveser in the config
50+
:param leaf: value to delete at the destination
51+
:return: dict
52+
"""
53+
target = self.config
54+
firstNoSiblingKey = None
55+
for key in path:
56+
if key not in target:
57+
return self.config
58+
if len(target[key]) <= 1:
59+
if firstNoSiblingKey is None:
60+
firstNoSiblingKey = [target, key]
61+
else:
62+
firstNoSiblingKey = None
63+
target = target[key]
64+
65+
if firstNoSiblingKey is None:
66+
firstNoSiblingKey = [target, leaf]
67+
68+
target = firstNoSiblingKey[0]
69+
targetKey = firstNoSiblingKey[1]
70+
del target[targetKey]
71+
return self.config
72+
73+
def check_entry(self, path, leaf):
74+
"""
75+
This function checks if a value exists in the config.
76+
:param path: list of strings to traveser in the config
77+
:param leaf: value to check for existence
78+
:return: bool
79+
"""
80+
target = self.config
81+
path = path + [leaf]
82+
existing = []
83+
for key in path:
84+
if key not in target or type(target[key]) is not dict:
85+
return False
86+
existing.append(key)
87+
target = target[key]
88+
return True
89+
90+
def parse_line(self, line):
91+
"""
92+
This function parses a given command from string.
93+
:param line: line to parse
94+
:return: [command, path, leaf]
95+
"""
96+
line = (
97+
re.match(r"^('(.*)'|\"(.*)\"|([^#\"']*))*", line).group(0).strip()
98+
)
99+
path = re.findall(r"('.*?'|\".*?\"|\S+)", line)
100+
leaf = path[-1]
101+
if leaf.startswith('"') and leaf.endswith('"'):
102+
leaf = leaf[1:-1]
103+
if leaf.startswith("'") and leaf.endswith("'"):
104+
leaf = leaf[1:-1]
105+
return [path[0], path[1:-1], leaf]
106+
107+
def run_command(self, command):
108+
"""
109+
This function runs a given command string.
110+
:param command: command to run
111+
:return: dict
112+
"""
113+
[cmd, path, leaf] = self.parse_line(command)
114+
if cmd.startswith("set"):
115+
self.set_entry(path, leaf)
116+
if cmd.startswith("del"):
117+
self.del_entry(path, leaf)
118+
return self.config
119+
120+
def run_commands(self, commands):
121+
"""
122+
This function runs a a list of command strings.
123+
:param commands: commands to run
124+
:return: dict
125+
"""
126+
for c in commands:
127+
self.run_command(c)
128+
return self.config
129+
130+
def check_command(self, command):
131+
"""
132+
This function checkes a command for existance in the config.
133+
:param command: command to check
134+
:return: bool
135+
"""
136+
[cmd, path, leaf] = self.parse_line(command)
137+
if cmd.startswith("set"):
138+
return self.check_entry(path, leaf)
139+
if cmd.startswith("del"):
140+
return not self.check_entry(path, leaf)
141+
return True
142+
143+
def check_commands(self, commands):
144+
"""
145+
This function checkes a list of commands for existance in the config.
146+
:param commands: list of commands to check
147+
:return: [bool]
148+
"""
149+
return [self.check_command(c) for c in commands]
150+
151+
def build_commands(self, structure=None, nested=False):
152+
"""
153+
This function builds a list of commands to recreate the current configuration.
154+
:return: [str]
155+
"""
156+
if type(structure) is not dict:
157+
structure = self.config
158+
if len(structure) == 0:
159+
return [""] if nested else []
160+
commands = []
161+
for (key, value) in structure.items():
162+
for c in self.build_commands(value, True):
163+
if " " in key or '"' in key:
164+
key = "'" + key + "'"
165+
commands.append((key + " " + c).strip())
166+
if nested:
167+
return commands
168+
return ["set " + c for c in commands]
169+
170+
def diff_to(self, other, structure):
171+
if type(other) is not dict:
172+
other = {}
173+
if len(structure) == 0:
174+
return ([], [""])
175+
if type(structure) is not dict:
176+
structure = {}
177+
if len(other) == 0:
178+
return ([""], [])
179+
if len(other) == 0 and len(structure) == 0:
180+
return ([], [])
181+
182+
toset = []
183+
todel = []
184+
for key in structure.keys():
185+
quoted_key = "'" + key + "'" if " " in key or '"' in key else key
186+
if key in other:
187+
# keys in both configs, pls compare subkeys
188+
(subset, subdel) = self.diff_to(other[key], structure[key])
189+
for s in subset:
190+
toset.append(quoted_key + " " + s)
191+
if "!" not in other[key]:
192+
for d in subdel:
193+
todel.append(quoted_key + " " + d)
194+
else:
195+
# keys only in this, pls del
196+
todel.append(quoted_key)
197+
continue # del
198+
for (key, value) in other.items():
199+
if key == "!":
200+
continue
201+
quoted_key = "'" + key + "'" if " " in key or '"' in key else key
202+
if key not in structure:
203+
# keys only in other, pls set all subkeys
204+
(subset, subdel) = self.diff_to(other[key], None)
205+
for s in subset:
206+
toset.append(quoted_key + " " + s)
207+
208+
return (toset, todel)
209+
210+
def diff_commands_to(self, other):
211+
"""
212+
This function calculates the required commands to change the current into
213+
the given configuration.
214+
:param other: VyosConf
215+
:return: [str]
216+
"""
217+
(toset, todel) = self.diff_to(other.config, self.config)
218+
return ["delete " + c.strip() for c in todel] + [
219+
"set " + c.strip() for c in toset
220+
]

plugins/modules/vyos_config.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,18 @@
5858
match:
5959
description:
6060
- The C(match) argument controls the method used to match against the current
61-
active configuration. By default, the desired config is matched against the
62-
active config and the deltas are loaded. If the C(match) argument is set to
63-
C(none) the active configuration is ignored and the configuration is always
64-
loaded.
61+
active configuration. By default, the configuration commands config are
62+
matched against the active config and the deltas are loaded line by line.
63+
If the C(match) argument is set to C(none) the active configuration is ignored
64+
and the configuration is always loaded. If the C(match) argument is set to C(smart)
65+
both the active configuration and the target configuration are simlulated
66+
and the results compared to bring the target device into a reliable and
67+
reproducable state.
6568
type: str
6669
default: line
6770
choices:
6871
- line
72+
- smart
6973
- none
7074
backup:
7175
description:
@@ -139,6 +143,7 @@
139143
140144
- name: render a Jinja2 template onto the VyOS router
141145
vyos.vyos.vyos_config:
146+
match: smart
142147
src: vyos_template.j2
143148
144149
- name: for idempotency, use full-form commands
@@ -211,7 +216,9 @@
211216
DEFAULT_COMMENT = "configured by vyos_config"
212217

213218
CONFIG_FILTERS = [
214-
re.compile(r"set system login user \S+ authentication encrypted-password")
219+
re.compile(
220+
r"set system login user \S+ authentication encrypted-password"
221+
)
215222
]
216223

217224

@@ -332,7 +339,7 @@ def main():
332339
argument_spec = dict(
333340
src=dict(type="path"),
334341
lines=dict(type="list", elements="str"),
335-
match=dict(default="line", choices=["line", "none"]),
342+
match=dict(default="line", choices=["line", "smart", "none"]),
336343
comment=dict(default=DEFAULT_COMMENT),
337344
config=dict(),
338345
backup=dict(type="bool", default=False),

0 commit comments

Comments
 (0)