#
# 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 _KeepExistingSentinel:
"""Unique marker for 'preserve whatever's already here' in a diff.
Deliberately not a plain string/value: a real config leaf could
legitimately be "..." (e.g. a description), and a string sentinel
would collide with it. An object identity never can.
"""
def __repr__(self):
return ""
KEEP_EXISTING_VALUES = _KeepExistingSentinel()
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 traverse 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. If the leaf
does not exist at the given path, the configuration is left
unchanged (delete is treated as a no-op, matching VyOS's own
behaviour when deleting a path that isn't set).
:param path: list of strings to traverse in the config
:param leaf: value to delete at the destination
:return: dict
"""
target = self.config
first_no_sibling_key = None
for key in path:
if key not in target:
return self.config
if len(target[key]) <= 1:
if first_no_sibling_key is None:
first_no_sibling_key = [target, key]
else:
first_no_sibling_key = None
target = target[key]
if leaf not in target:
return self.config
if first_no_sibling_key is None:
first_no_sibling_key = [target, leaf]
target = first_no_sibling_key[0]
target_key = first_no_sibling_key[1]
del target[target_key]
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 traverse in the config
:param leaf: value to check for existence
:return: bool
"""
target = self.config
path = path + [leaf]
for key in path:
if key not in target or not isinstance(target[key], dict):
return False
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()
if not line:
return ["", [], ""]
path = re.findall(r"('.*?'|\".*?\"|\S+)", line)
if not path:
return ["", [], ""]
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 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 checks a command for existence 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 checks a list of commands for existence 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. Only top-level sections present in the desired
configuration are enforced; top-level sections the candidate does not
mention at all are left completely untouched.
:param other: VyosConf
:return: [str]
"""
scoped_structure = {k: v for k, v in self.config.items() if k in other.config}
(toset, todel) = self.diff_to(other.config, scoped_structure)
return ["delete " + c.strip() for c in todel] + ["set " + c.strip() for c in toset]