From d48568a8e4bf3619bd80fcc5db82ad0c7314157c Mon Sep 17 00:00:00 2001
From: Runar Borge <runar@borge.nu>
Date: Wed, 3 Jun 2020 23:05:27 +0200
Subject: T2638: FRR: New framework for configuring FRR

This commit adds the python module vyos.frr that support reading and modifying the frr cofiguration.

Functions
get_configuration() :
    Collects the configuration from FRR and returns it as a string

reload_configuration() :
    uses frr-reload.py to activate a new configuration.
    The configuration applied will overwrite the current configuration.

mark_configuration() :
    Does syntax check/validation and add "end" tags to the configuration
    The marked configuration will be returned as a string
    A syntax fault will render an Exception

execute() :
    Execute a command inside vtysh

configure() :
    Executes a command in vtysh config mode

replace_section() / _replace_section() :
    Adds the ability to replace a section of frr code

remove_section() :
    Removes the specified configuration block from the config

For now this supports replacing complete config sub-blocks of configuration and selecting the daemon
to replace inside. This should work for most daemons, but static routing will still be an issue because
this is not a separate sub-config mode
---
 python/vyos/frr.py | 288 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 288 insertions(+)
 create mode 100644 python/vyos/frr.py

(limited to 'python')

diff --git a/python/vyos/frr.py b/python/vyos/frr.py
new file mode 100644
index 000000000..e39b6a914
--- /dev/null
+++ b/python/vyos/frr.py
@@ -0,0 +1,288 @@
+# Copyright 2020 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
+r"""
+A Library for interracting with the FRR daemon suite.
+It supports simple configuration manipulation and loading using the official tools
+supplied with FRR (vtysh and frr-reload)
+
+All configuration management and manipulation is done using strings and regex.
+
+
+Example Usage
+#####
+
+# Reading configuration from frr:
+```
+>>> original_config = get_configuration()
+>>> repr(original_config)
+'!\nfrr version 7.3.1\nfrr defaults traditional\nhostname debian\n......
+```
+
+
+# Modify a configuration section:
+```
+>>> new_bgp_section = 'router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n'
+>>> modified_config = replace_section(original_config, new_bgp_section, replace_re=r'router bgp \d+')
+>>> repr(modified_config)
+'............router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n...........'
+```
+
+Remove a configuration section:
+```
+>>> modified_config = remove_section(original_config, r'router ospf')
+```
+
+Test the new configuration:
+```
+>>> try:
+>>>     mark_configuration(modified configuration)
+>>> except ConfigurationNotValid as e:
+>>>     print('resulting configuration is not valid')
+>>>     sys.exit(1)
+```
+
+Apply the new configuration:
+```
+>>> try:
+>>>     replace_configuration(modified_config)
+>>> except CommitError as e:
+>>>     print('Exception while commiting the supplied configuration')
+>>>     print(e)
+>>>     exit(1)
+```
+"""
+
+import tempfile
+import re
+from vyos import util
+
+_frr_daemons = ['zebra', 'bgpd', 'fabricd', 'isisd', 'ospf6d', 'ospfd', 'pbrd',
+                'pimd', 'ripd', 'ripngd', 'sharpd', 'staticd', 'vrrpd', 'ldpd']
+
+path_vtysh = '/usr/bin/vtysh'
+path_frr_reload = '/usr/lib/frr/frr-reload.py'
+
+
+class FrrError(Exception):
+    pass
+
+
+class ConfigurationNotValid(FrrError):
+    """
+    The configuratioin supplied to vtysh is not valid
+    """
+    pass
+
+
+class CommitError(FrrError):
+    """
+    Commiting the supplied configuration failed to commit by a unknown reason
+    see commit error and/or run mark_configuration on the specified configuration
+    to se error generated
+
+    used by: reload_configuration()
+    """
+    pass
+
+
+class ConfigSectionNotFound(FrrError):
+    """
+    Removal of configuration failed because it is not existing in the supplied configuration
+    """
+    pass
+
+
+def get_configuration(daemon=None, marked=False):
+    """ Get current running FRR configuration
+    daemon:  Collect only configuration for the specified FRR daemon,
+             supplying daemon=None retrieves the complete configuration
+    marked:  Mark the configuration with "end" tags
+
+    return:  string containing the running configuration from frr
+
+    """
+    if daemon and daemon not in _frr_daemons:
+        raise ValueError(f'The specified daemon type is not supported {repr(daemon)}')
+
+    cmd = f"{path_vtysh} -c 'show run'"
+    if daemon:
+        cmd += f' -d {daemon}'
+
+    output, code = util.popen(cmd, stderr=util.STDOUT)
+    if code:
+        raise OSError(code, output)
+
+    config = output.replace('\r', '')
+    # Remove first header lines from FRR config
+    config = config.split("\n", 3)[-1]
+    # Mark the configuration with end tags
+    if marked:
+        config = mark_configuration(config)
+
+    return config
+
+
+def mark_configuration(config):
+    """ Add end marks and Test the configuration for syntax faults
+    If the configuration is valid a marked version of the configuration is returned,
+    or else it failes with a ConfigurationNotValid Exception
+
+    config:  The configuration string to mark/test
+    return:  The marked configuration from FRR
+    """
+    output, code = util.popen(f"{path_vtysh} -m -f -", stderr=util.STDOUT, input=config)
+
+    if code == 2:
+        raise ConfigurationNotValid(str(output))
+    elif code:
+        raise OSError(code, output)
+
+    config = output.replace('\r', '')
+    return config
+
+
+def reload_configuration(config, daemon=None):
+    """ Execute frr-reload with the new configuration
+    This will try to reapply the supplied configuration inside FRR.
+    The configuration needs to be a complete configuration from the integrated config or
+    from a daemon.
+
+    config:  The configuration to apply
+    daemon:  Apply the conigutaion to the specified FRR daemon,
+             supplying daemon=None applies to the integrated configuration
+    return:  None
+    """
+    if daemon and daemon not in _frr_daemons:
+        raise ValueError(f'The specified daemon type is not supported {repr(daemon)}')
+
+    f = tempfile.NamedTemporaryFile('w')
+    f.write(config)
+    f.flush()
+
+    cmd = f'{path_frr_reload} --reload'
+    if daemon:
+        cmd += f' --daemon {daemon}'
+    cmd += f' {f.name}'
+
+    output, code = util.popen(cmd, stderr=util.STDOUT)
+    f.close()
+    if code == 1:
+        raise CommitError(f'Configuration FRR failed while commiting code: {repr(output)}')
+    elif code:
+        raise OSError(code, output)
+
+    return output
+
+
+def execute(command):
+    """ Run commands inside vtysh
+    command:  str containing commands to execute inside a vtysh session
+    """
+    if not isinstance(command, str):
+        raise ValueError(f'command needs to be a string: {repr(command)}')
+
+    cmd = f"{path_vtysh} -c '{command}'"
+
+    output, code = util.popen(cmd, stderr=util.STDOUT)
+    if code:
+        raise OSError(code, output)
+
+    config = output.replace('\r', '')
+    return config
+
+
+def configure(lines, daemon=False):
+    """ run commands inside config mode vtysh
+    lines:  list or str conaining commands to execute inside a configure session
+            only one command executed on each configure()
+            Executing commands inside a subcontext uses the list to describe the context
+            ex: ['router bgp 6500', 'neighbor 192.0.2.1 remote-as 65000']
+    return: None
+    """
+    if isinstance(lines, str):
+        lines = [lines]
+    elif not isinstance(lines, list):
+        raise ValueError('lines needs to be string or list of commands')
+
+    if daemon and daemon not in _frr_daemons:
+        raise ValueError(f'The specified daemon type is not supported {repr(daemon)}')
+
+    cmd = f'{path_vtysh}'
+    if daemon:
+        cmd += f' -d {daemon}'
+
+    cmd += " -c 'configure terminal'"
+    for x in lines:
+        cmd += f" -c '{x}'"
+
+    output, code = util.popen(cmd, stderr=util.STDOUT)
+    if code == 1:
+        raise ConfigurationNotValid(f'Configuration FRR failed: {repr(output)}')
+    elif code:
+        raise OSError(code, output)
+
+    config = output.replace('\r', '')
+    return config
+
+
+def _replace_section(config, replacement, replace_re, before_re):
+    r"""Replace a section of FRR config
+    config:      full original configuration
+    replacement: replacement configuration section
+    replace_re:  The regex to replace
+                 example: ^router bgp \d+$.?*^!$
+                 this will replace everything between ^router bgp X$ and ^!$
+    before_re:   When replace_re is not existant, the config will be added before this tag
+                 example: ^line vty$
+
+    return:      modified configuration as a text file
+    """
+    # Check if block is configured, remove the existing instance else add a new one
+    if re.findall(replace_re, config, flags=re.MULTILINE | re.DOTALL):
+        # Section is in the configration, replace it
+        return re.sub(replace_re, replacement, config, count=1,
+                      flags=re.MULTILINE | re.DOTALL)
+    if before_re:
+        if not re.findall(before_re, config, flags=re.MULTILINE | re.DOTALL):
+            raise ConfigSectionNotFound(f"Config section {before_re} not found in config")
+
+        # If no section is in the configuration, add it before the line vty line
+        return re.sub(before_re, rf'{replacement}\n\g<1>', config, count=1,
+                      flags=re.MULTILINE | re.DOTALL)
+
+    raise ConfigSectionNotFound(f"Config section {replacement} not found in config")
+
+
+def replace_section(config, replacement, from_re, to_re=r'!', before_re=r'line vty'):
+    r"""Replace a section of FRR config
+    config:      full original configuration
+    replacement: replacement configuration section
+    from_re:     Regex for the start of section matching
+                 example: 'router bgp \d+'
+    to_re:       Regex for stop of section matching
+                 default: '!'
+                 example: '!'  or  'end'
+    before_re:   When from_re/to_re  does not return a match, the config will
+                 be added before this tag
+                 default: ^line vty$
+
+    startline and endline tags will be automatically added to the resulting from_re/to_re and before_re regex'es
+    """
+    return _replace_section(config, replacement, replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=rf'^{before_re}$')
+
+
+def remove_section(config, from_re, to_re='!'):
+    return _replace_section(config, '', replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=None)
-- 
cgit v1.2.3