diff options
Diffstat (limited to 'tools/frr-reload.py')
-rwxr-xr-x | tools/frr-reload.py | 2275 |
1 files changed, 2275 insertions, 0 deletions
diff --git a/tools/frr-reload.py b/tools/frr-reload.py new file mode 100755 index 0000000..3a478f6 --- /dev/null +++ b/tools/frr-reload.py @@ -0,0 +1,2275 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +# Frr Reloader +# Copyright (C) 2014 Cumulus Networks, Inc. +# +""" +This program +- reads a frr configuration text file +- reads frr's current running configuration via "vtysh -c 'show running'" +- compares the two configs and determines what commands to execute to + synchronize frr's running configuration with the configuation in the + text file +""" + +from __future__ import print_function, unicode_literals +import argparse +import logging +import os, os.path +import random +import re +import string +import subprocess +import sys +from collections import OrderedDict +from ipaddress import IPv6Address, ip_network +from pprint import pformat + +# Python 3 +def iteritems(d): + return iter(d.items()) + + +log = logging.getLogger(__name__) + + +class VtyshException(Exception): + pass + + +class Vtysh(object): + def __init__(self, bindir=None, confdir=None, sockdir=None, pathspace=None): + self.bindir = bindir + self.confdir = confdir + self.pathspace = pathspace + self.common_args = [os.path.join(bindir or "", "vtysh")] + if confdir: + self.common_args.extend(["--config_dir", confdir]) + if sockdir: + self.common_args.extend(["--vty_socket", sockdir]) + if pathspace: + self.common_args.extend(["-N", pathspace]) + + def _call(self, args, stdin=None, stdout=None, stderr=None): + kwargs = {} + if stdin is not None: + kwargs["stdin"] = stdin + if stdout is not None: + kwargs["stdout"] = stdout + if stderr is not None: + kwargs["stderr"] = stderr + return subprocess.Popen(self.common_args + args, **kwargs) + + def _call_cmd(self, command, stdin=None, stdout=None, stderr=None): + if isinstance(command, list): + args = [item for sub in command for item in ["-c", sub]] + else: + args = ["-c", command] + return self._call(args, stdin, stdout, stderr) + + def __call__(self, command, stdouts=None): + """ + Call a CLI command (e.g. "show running-config") + + Output text is automatically redirected, decoded and returned. + Multiple commands may be passed as list. + """ + proc = self._call_cmd(command, stdout=subprocess.PIPE) + stdout, stderr = proc.communicate() + if proc.wait() != 0: + if stdouts is not None: + stdouts.append(stdout.decode("UTF-8")) + raise VtyshException( + 'vtysh returned status %d for command "%s"' % (proc.returncode, command) + ) + return stdout.decode("UTF-8") + + def is_config_available(self): + """ + Return False if no frr daemon is running or some other vtysh session is + in 'configuration terminal' mode which will prevent us from making any + configuration changes. + """ + + output = self("configure") + + if "VTY configuration is locked by other VTY" in output: + log.error("vtysh 'configure' returned\n%s\n" % (output)) + return False + + return True + + def exec_file(self, filename): + child = self._call(["-f", filename]) + if child.wait() != 0: + raise VtyshException( + "vtysh (exec file) exited with status %d" % (child.returncode) + ) + + def mark_file(self, filename, stdin=None): + child = self._call( + ["-m", "-f", filename], + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + stdout, stderr = child.communicate() + except subprocess.TimeoutExpired: + child.kill() + stdout, stderr = child.communicate() + raise VtyshException("vtysh call timed out!") + + if child.wait() != 0: + raise VtyshException( + "vtysh (mark file) exited with status %d:\n%s" + % (child.returncode, stderr) + ) + + return stdout.decode("UTF-8") + + def mark_show_run(self, daemon=None): + cmd = "show running-config" + if daemon: + cmd += " %s" % daemon + cmd += " no-header" + show_run = self._call_cmd(cmd, stdout=subprocess.PIPE) + mark = self._call( + ["-m", "-f", "-"], stdin=show_run.stdout, stdout=subprocess.PIPE + ) + + show_run.wait() + stdout, stderr = mark.communicate() + mark.wait() + + if show_run.returncode != 0: + raise VtyshException( + "vtysh (show running-config) exited with status %d:" + % (show_run.returncode) + ) + if mark.returncode != 0: + raise VtyshException( + "vtysh (mark running-config) exited with status %d" % (mark.returncode) + ) + + return stdout.decode("UTF-8") + + +class Context(object): + """ + A Context object represents a section of frr configuration such as: + ! + interface swp3 + description swp3 -> r8's swp1 + ipv6 nd suppress-ra + link-detect + ! + + or a single line context object such as this: + + ip forwarding + + """ + + def __init__(self, keys, lines): + self.keys = keys + self.lines = lines + + # Keep a dictionary of the lines, this is to make it easy to tell if a + # line exists in this Context + self.dlines = OrderedDict() + + for ligne in lines: + self.dlines[ligne] = True + + def __str__(self): + return str(self.keys) + " : " + str(self.lines) + + def add_lines(self, lines): + """ + Add lines to specified context + """ + + self.lines.extend(lines) + + for ligne in lines: + self.dlines[ligne] = True + + +def get_normalized_es_id(line): + """ + The es-id or es-sys-mac need to be converted to lower case + """ + sub_strs = ["evpn mh es-id", "evpn mh es-sys-mac"] + for sub_str in sub_strs: + obj = re.match(sub_str + " (?P<esi>\S*)", line) + if obj: + line = "%s %s" % (sub_str, obj.group("esi").lower()) + break + return line + + +def get_normalized_mac_ip_line(line): + if line.startswith("evpn mh es"): + return get_normalized_es_id(line) + + if not "ipv6 add" in line: + return get_normalized_ipv6_line(line) + + return line + + +# This dictionary contains a tree of all commands that we know start a +# new multi-line context. All other commands are treated either as +# commands inside a multi-line context or as single-line contexts. This +# dictionary should be updated whenever a new node is added to FRR. +ctx_keywords = { + "router bgp ": { + "address-family ": { + "vni ": {}, + }, + "vnc defaults": {}, + "vnc nve-group ": {}, + "vnc l2-group ": {}, + "vrf-policy ": {}, + "bmp targets ": {}, + "segment-routing srv6": {}, + }, + "router rip": {}, + "router ripng": {}, + "router isis ": {}, + "router openfabric ": {}, + "router ospf": {}, + "router ospf6": {}, + "router eigrp ": {}, + "router babel": {}, + "mpls ldp": {"address-family ": {"interface ": {}}}, + "l2vpn ": {"member pseudowire ": {}}, + "key chain ": {"key ": {}}, + "vrf ": {}, + "interface ": {"link-params": {}}, + "pseudowire ": {}, + "segment-routing": { + "traffic-eng": { + "segment-list ": {}, + "policy ": {"candidate-path ": {}}, + "pcep": {"pcc": {}, "pce ": {}, "pce-config ": {}}, + }, + "srv6": {"locators": {"locator ": {}}}, + }, + "nexthop-group ": {}, + "route-map ": {}, + "pbr-map ": {}, + "rpki": {}, + "bfd": {"peer ": {}, "profile ": {}}, + "line vty": {}, +} + + +class Config(object): + """ + A frr configuration is stored in a Config object. A Config object + contains a dictionary of Context objects where the Context keys + ('router ospf' for example) are our dictionary key. + """ + + def __init__(self, vtysh): + self.lines = [] + self.contexts = OrderedDict() + self.vtysh = vtysh + + def load_from_file(self, filename): + """ + Read configuration from specified file and slurp it into internal memory + The internal representation has been marked appropriately by passing it + through vtysh with the -m parameter + """ + log.info("Loading Config object from file %s", filename) + + file_output = self.vtysh.mark_file(filename) + + for line in file_output.split("\n"): + line = line.strip() + + # Compress duplicate whitespaces + line = " ".join(line.split()) + + if ":" in line: + line = get_normalized_mac_ip_line(line) + + # vrf static routes can be added in two ways. The old way is: + # + # "ip route x.x.x.x/x y.y.y.y vrf <vrfname>" + # + # but it's rendered in the configuration as the new way:: + # + # vrf <vrf-name> + # ip route x.x.x.x/x y.y.y.y + # exit-vrf + # + # this difference causes frr-reload to not consider them a + # match and delete vrf static routes incorrectly. + # fix the old way to match new "show running" output so a + # proper match is found. + if ( + line.startswith("ip route ") or line.startswith("ipv6 route ") + ) and " vrf " in line: + newline = line.split(" ") + vrf_index = newline.index("vrf") + vrf_ctx = newline[vrf_index] + " " + newline[vrf_index + 1] + del newline[vrf_index : vrf_index + 2] + newline = " ".join(newline) + self.lines.append(vrf_ctx) + self.lines.append(newline) + self.lines.append("exit-vrf") + line = "end" + + self.lines.append(line) + + self.load_contexts() + + def load_from_show_running(self, daemon): + """ + Read running configuration and slurp it into internal memory + The internal representation has been marked appropriately by passing it + through vtysh with the -m parameter + """ + log.info("Loading Config object from vtysh show running") + + config_text = self.vtysh.mark_show_run(daemon) + + for line in config_text.split("\n"): + line = line.strip() + + if ( + line == "Building configuration..." + or line == "Current configuration:" + or not line + ): + continue + + self.lines.append(line) + + self.load_contexts() + + def get_lines(self): + """ + Return the lines read in from the configuration + """ + return "\n".join(self.lines) + + def get_contexts(self): + """ + Return the parsed context as strings for display, log etc. + """ + for (_, ctx) in sorted(iteritems(self.contexts)): + print(str(ctx)) + + def save_contexts(self, key, lines): + """ + Save the provided key and lines as a context + """ + if not key: + return + + # IP addresses specified in "network" statements, "ip prefix-lists" + # etc. can differ in the host part of the specification the user + # provides and what the running config displays. For example, user can + # specify 11.1.1.1/24, and the running config displays this as + # 11.1.1.0/24. Ensure we don't do a needless operation for such lines. + # IS-IS & OSPFv3 have no "network" support. + re_key_rt = re.match(r"(ip|ipv6)\s+route\s+([A-Fa-f:.0-9/]+)(.*)$", key[0]) + if re_key_rt: + addr = re_key_rt.group(2) + if "/" in addr: + try: + newaddr = ip_network(addr, strict=False) + key[0] = "%s route %s/%s%s" % ( + re_key_rt.group(1), + str(newaddr.network_address), + newaddr.prefixlen, + re_key_rt.group(3), + ) + except ValueError: + pass + + re_key_rt = re.match( + r"(ip|ipv6)\s+prefix-list(.*)(permit|deny)\s+([A-Fa-f:.0-9/]+)(.*)$", key[0] + ) + if re_key_rt: + addr = re_key_rt.group(4) + if "/" in addr: + try: + network_addr = ip_network(addr, strict=False) + newaddr = "%s/%s" % ( + str(network_addr.network_address), + network_addr.prefixlen, + ) + except ValueError: + newaddr = addr + else: + newaddr = addr + + legestr = re_key_rt.group(5) + re_lege = re.search(r"(.*)le\s+(\d+)\s+ge\s+(\d+)(.*)", legestr) + if re_lege: + legestr = "%sge %s le %s%s" % ( + re_lege.group(1), + re_lege.group(3), + re_lege.group(2), + re_lege.group(4), + ) + + key[0] = "%s prefix-list%s%s %s%s" % ( + re_key_rt.group(1), + re_key_rt.group(2), + re_key_rt.group(3), + newaddr, + legestr, + ) + + if lines and key[0].startswith("router bgp"): + newlines = [] + for line in lines: + re_net = re.match(r"network\s+([A-Fa-f:.0-9/]+)(.*)$", line) + if re_net: + addr = re_net.group(1) + if "/" not in addr and key[0].startswith("router bgp"): + # This is most likely an error because with no + # prefixlen, BGP treats the prefixlen as 8 + addr = addr + "/8" + + try: + network_addr = ip_network(addr, strict=False) + line = "network %s/%s %s" % ( + str(network_addr.network_address), + network_addr.prefixlen, + re_net.group(2), + ) + newlines.append(line) + except ValueError: + # Really this should be an error. Whats a network + # without an IP Address following it ? + newlines.append(line) + else: + newlines.append(line) + lines = newlines + + # More fixups in user specification and what running config shows. + # "null0" in routes must be replaced by Null0. + if ( + key[0].startswith("ip route") + or key[0].startswith("ipv6 route") + and "null0" in key[0] + ): + key[0] = re.sub(r"\s+null0(\s*$)", " Null0", key[0]) + + if lines and key[0].startswith("vrf "): + newlines = [] + for line in lines: + if line.startswith("ip route ") or line.startswith("ipv6 route "): + if "null0" in line: + line = re.sub(r"\s+null0(\s*$)", " Null0", line) + newlines.append(line) + else: + newlines.append(line) + lines = newlines + + if lines: + if tuple(key) not in self.contexts: + ctx = Context(tuple(key), lines) + self.contexts[tuple(key)] = ctx + else: + ctx = self.contexts[tuple(key)] + ctx.add_lines(lines) + + else: + if tuple(key) not in self.contexts: + ctx = Context(tuple(key), []) + self.contexts[tuple(key)] = ctx + + def load_contexts(self): + """ + Parse the configuration and create contexts for each appropriate block + + The end of a context is flagged via the 'end' keyword: + + ! + interface swp52 + ipv6 nd suppress-ra + link-detect + ! + end + router bgp 10 + bgp router-id 10.0.0.1 + bgp log-neighbor-changes + no bgp default ipv4-unicast + neighbor EBGP peer-group + neighbor EBGP advertisement-interval 1 + neighbor EBGP timers connect 10 + neighbor 2001:40:1:4::6 remote-as 40 + neighbor 2001:40:1:8::a remote-as 40 + ! + end + address-family ipv6 + neighbor IBGPv6 activate + neighbor 2001:10::2 peer-group IBGPv6 + neighbor 2001:10::3 peer-group IBGPv6 + exit-address-family + ! + end + router ospf + ospf router-id 10.0.0.1 + log-adjacency-changes detail + timers throttle spf 0 50 5000 + ! + end + + The code assumes that its working on the output from the "vtysh -m" + command. That provides the appropriate markers to signify end of + a context. This routine uses that to build the contexts for the + config. + + There are single line contexts such as "log file /media/node/zebra.log" + and multi-line contexts such as "router ospf" and subcontexts + within a context such as "address-family" within "router bgp" + In each of these cases, the first line of the context becomes the + key of the context. So "router bgp 10" is the key for the non-address + family part of bgp, "router bgp 10, address-family ipv6 unicast" is + the key for the subcontext and so on. + """ + # stack of context keys + ctx_keys = [] + # stack of context keywords + cur_ctx_keywords = [ctx_keywords] + # list of stored commands + cur_ctx_lines = [] + + for line in self.lines: + + if not line: + continue + + if line.startswith("!") or line.startswith("#"): + continue + + if line.startswith("exit"): + # ignore on top level + if len(ctx_keys) == 0: + continue + + # save current context + self.save_contexts(ctx_keys, cur_ctx_lines) + + # exit current context + log.debug("LINE %-50s: exit context %-50s", line, ctx_keys) + + ctx_keys.pop() + cur_ctx_keywords.pop() + cur_ctx_lines = [] + + continue + + if line.startswith("end"): + # exit all contexts + while len(ctx_keys) > 0: + # save current context + self.save_contexts(ctx_keys, cur_ctx_lines) + + # exit current context + log.debug("LINE %-50s: exit context %-50s", line, ctx_keys) + + ctx_keys.pop() + cur_ctx_keywords.pop() + cur_ctx_lines = [] + + continue + + new_ctx = False + + # check if the line is a context-entering keyword + for k, v in cur_ctx_keywords[-1].items(): + if line.startswith(k): + # candidate-path is a special case. It may be a node and + # may be a single-line command. The distinguisher is the + # word "dynamic" or "explicit" at the middle of the line. + # It was perhaps not the best choice by the pathd authors + # but we have what we have. + if k == "candidate-path " and "explicit" in line: + # this is a single-line command + break + + # save current context + self.save_contexts(ctx_keys, cur_ctx_lines) + + # enter new context + new_ctx = True + ctx_keys.append(line) + cur_ctx_keywords.append(v) + cur_ctx_lines = [] + + log.debug("LINE %-50s: enter context %-50s", line, ctx_keys) + break + + if new_ctx: + continue + + if len(ctx_keys) == 0: + log.debug("LINE %-50s: single-line context", line) + self.save_contexts([line], []) + else: + log.debug("LINE %-50s: add to current context %-50s", line, ctx_keys) + cur_ctx_lines.append(line) + + # Save the context of the last one + if len(ctx_keys) > 0: + self.save_contexts(ctx_keys, cur_ctx_lines) + + +def lines_to_config(ctx_keys, line, delete): + """ + Return the command as it would appear in frr.conf + """ + cmd = [] + + # If there's no `line` and `ctx_keys` length is 1, then it may be a single-line command. + # In this case, we should treat it as a single command in an empty context. + if len(ctx_keys) == 1 and not line: + single = True + + for k, v in ctx_keywords.items(): + if ctx_keys[0].startswith(k): + single = False + break + + if single: + line = ctx_keys[0] + ctx_keys = [] + + if line: + for (i, ctx_key) in enumerate(ctx_keys): + cmd.append(" " * i + ctx_key) + + line = line.lstrip() + indent = len(ctx_keys) * " " + + # There are some commands that are on by default so their "no" form will be + # displayed in the config. "no bgp default ipv4-unicast" is one of these. + # If we need to remove this line we do so by adding "bgp default ipv4-unicast", + # not by doing a "no no bgp default ipv4-unicast" + if delete: + if line.startswith("no "): + cmd.append("%s%s" % (indent, line[3:])) + else: + cmd.append("%sno %s" % (indent, line)) + + else: + cmd.append(indent + line) + + for i in reversed(range(len(ctx_keys))): + cmd.append(" " * i + "exit") + + # If line is None then we are typically deleting an entire + # context ('no router ospf' for example) + else: + for i, ctx_key in enumerate(ctx_keys[:-1]): + cmd.append("%s%s" % (" " * i, ctx_key)) + + # Only put the 'no' on the last sub-context + if delete: + if ctx_keys[-1].startswith("no "): + cmd.append("%s%s" % (" " * (len(ctx_keys) - 1), ctx_keys[-1][3:])) + else: + cmd.append("%sno %s" % (" " * (len(ctx_keys) - 1), ctx_keys[-1])) + else: + cmd.append("%s%s" % (" " * (len(ctx_keys) - 1), ctx_keys[-1])) + cmd.append("%sexit" % (" " * (len(ctx_keys) - 1))) + + for i in reversed(range(len(ctx_keys) - 1)): + cmd.append(" " * i + "exit") + + return cmd + + +def get_normalized_ipv6_line(line): + """ + Return a normalized IPv6 line as produced by frr, + with all letters in lower case and trailing and leading + zeros removed, and only the network portion present if + the IPv6 word is a network + """ + norm_line = "" + words = line.split(" ") + for word in words: + if ":" in word: + norm_word = None + if "/" in word: + try: + v6word = ip_network(word, strict=False) + norm_word = "%s/%s" % ( + str(v6word.network_address), + v6word.prefixlen, + ) + except ValueError: + pass + if not norm_word: + try: + norm_word = "%s" % IPv6Address(word) + except ValueError: + norm_word = word + else: + norm_word = word + norm_line = norm_line + " " + norm_word + + return norm_line.strip() + + +def line_exist(lines, target_ctx_keys, target_line, exact_match=True): + for (ctx_keys, line) in lines: + if ctx_keys == target_ctx_keys: + if exact_match: + if line == target_line: + return True + else: + if line.startswith(target_line): + return True + return False + + +def bgp_delete_inst_move_line(lines_to_del): + # Deletion of bgp default inst followed by + # bgp vrf inst leads to issue of default + # instance can not be removed. + # Move the bgp default instance line to end. + bgp_defult_inst = False + bgp_vrf_inst = False + + for (ctx_keys, line) in lines_to_del: + # Find bgp default inst + if ( + ctx_keys[0].startswith("router bgp") + and not line + and "vrf" not in ctx_keys[0] + ): + bgp_defult_inst = True + # Find bgp vrf inst + if ctx_keys[0].startswith("router bgp") and not line and "vrf" in ctx_keys[0]: + bgp_vrf_inst = True + + if bgp_defult_inst and bgp_vrf_inst: + for (ctx_keys, line) in lines_to_del: + # move bgp default inst to end + if ( + ctx_keys[0].startswith("router bgp") + and not line + and "vrf" not in ctx_keys[0] + ): + lines_to_del.remove((ctx_keys, line)) + lines_to_del.append((ctx_keys, line)) + + +def bgp_delete_nbr_remote_as_line(lines_to_add): + # Handle deletion of neighbor <nbr> remote-as line from + # lines_to_add if the nbr is configured with peer-group and + # peer-group has remote-as config present. + # 'neighbor <nbr> remote-as change on peer is not allowed + # if the peer is part of peer-group and peer-group has + # remote-as config. + + pg_dict = dict() + found_pg_cmd = False + + # Find all peer-group commands; create dict of each peer-group + # to store assoicated neighbor as value + for ctx_keys, line in lines_to_add: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + # {'router bgp 65001': {'PG': [], 'PG1': []}, + # 'router bgp 65001 vrf vrf1': {'PG': [], 'PG1': []}} + if ctx_keys[0] not in pg_dict: + pg_dict[ctx_keys[0]] = dict() + # find 'neighbor <pg_name> peer-group' + re_pg = re.match("neighbor (\S+) peer-group$", line) + if re_pg and re_pg.group(1) not in pg_dict[ctx_keys[0]]: + pg_dict[ctx_keys[0]][re_pg.group(1)] = { + "nbr": list(), + "remoteas": False, + } + found_pg_cmd = True + + # Do nothing if there is no any "peer-group" + if found_pg_cmd is False: + return + + # Find peer-group with remote-as command, also search neighbor + # associated to peer-group and store into peer-group dict + for ctx_keys, line in lines_to_add: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + if ctx_keys[0] in pg_dict: + for pg_key in pg_dict[ctx_keys[0]]: + # Find 'neighbor <pg_name> remote-as' + pg_rmtas = "neighbor %s remote-as (\S+)" % pg_key + re_pg_rmtas = re.search(pg_rmtas, line) + if re_pg_rmtas: + pg_dict[ctx_keys[0]][pg_key]["remoteas"] = True + + # Find 'neighbor <peer> [interface] peer-group <pg_name>' + nb_pg = "neighbor (\S+) peer-group %s$" % pg_key + re_nbr_pg = re.search(nb_pg, line) + if ( + re_nbr_pg + and re_nbr_pg.group(1) not in pg_dict[ctx_keys[0]][pg_key] + ): + pg_dict[ctx_keys[0]][pg_key]["nbr"].append(re_nbr_pg.group(1)) + + # Find any neighbor <nbr> remote-as config line check if the nbr + # is in the peer group's list of nbrs. Remove 'neighbor <nbr> remote-as <>' + # from lines_to_add. + lines_to_del_from_add = [] + for ctx_keys, line in lines_to_add: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + nbr_rmtas = "neighbor (\S+) remote-as.*" + re_nbr_rmtas = re.search(nbr_rmtas, line) + if re_nbr_rmtas and ctx_keys[0] in pg_dict: + for pg in pg_dict[ctx_keys[0]]: + if pg_dict[ctx_keys[0]][pg]["remoteas"] == True: + for nbr in pg_dict[ctx_keys[0]][pg]["nbr"]: + if re_nbr_rmtas.group(1) == nbr: + lines_to_del_from_add.append((ctx_keys, line)) + + for ctx_keys, line in lines_to_del_from_add: + lines_to_add.remove((ctx_keys, line)) + + +def bgp_remove_neighbor_cfg(lines_to_del, del_nbr_dict): + + # This method handles deletion of bgp neighbor configs, + # if there is neighbor to peer-group cmd is in delete list. + # As 'no neighbor .* peer-group' deletes the neighbor, + # subsequent neighbor speciic config line deletion results + # in error. + lines_to_del_to_del = [] + + for (ctx_keys, line) in lines_to_del: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + if ctx_keys[0] in del_nbr_dict: + for nbr in del_nbr_dict[ctx_keys[0]]: + re_nbr_pg = re.search("neighbor (\S+) .*peer-group (\S+)", line) + nb_exp = "neighbor %s .*" % nbr + if not re_nbr_pg: + re_nb = re.search(nb_exp, line) + if re_nb: + lines_to_del_to_del.append((ctx_keys, line)) + + for (ctx_keys, line) in lines_to_del_to_del: + lines_to_del.remove((ctx_keys, line)) + + +def bgp_delete_move_lines(lines_to_add, lines_to_del): + # This method handles deletion of bgp peer group config. + # The objective is to delete config lines related to peers + # associated with the peer-group and move the peer-group + # config line to the end of the lines_to_del list. + + bgp_delete_nbr_remote_as_line(lines_to_add) + + del_dict = dict() + del_nbr_dict = dict() + # Stores the lines to move to the end of the pending list. + lines_to_del_to_del = [] + # Stores the lines to move to end of the pending list. + lines_to_del_to_app = [] + found_pg_del_cmd = False + + # When "neighbor <pg_name> peer-group" under a bgp instance is removed, + # it also deletes the associated peer config. Any config line below no form of + # peer-group related to a peer are errored out as the peer no longer exists. + # To cleanup peer-group and associated peer(s) configs: + # - Remove all the peers config lines from the pending list (lines_to_del list). + # - Move peer-group deletion line to the end of the pending list, to allow + # removal of any of the peer-group specific configs. + # + # Create a dictionary of config context (i.e. router bgp vrf x). + # Under each context node, create a dictionary of a peer-group name. + # Append a peer associated to the peer-group into a list under a peer-group node. + # Remove all of the peer associated config lines from the pending list. + # Append peer-group deletion line to end of the pending list. + # + # Example: + # neighbor underlay peer-group + # neighbor underlay remote-as external + # neighbor underlay advertisement-interval 0 + # neighbor underlay timers 3 9 + # neighbor underlay timers connect 10 + # neighbor swp1 interface peer-group underlay + # neighbor swp1 advertisement-interval 0 + # neighbor swp1 timers 3 9 + # neighbor swp1 timers connect 10 + # neighbor swp2 interface peer-group underlay + # neighbor swp2 advertisement-interval 0 + # neighbor swp2 timers 3 9 + # neighbor swp2 timers connect 10 + # neighbor swp3 interface peer-group underlay + # neighbor uplink1 interface remote-as internal + # neighbor uplink1 advertisement-interval 0 + # neighbor uplink1 timers 3 9 + # neighbor uplink1 timers connect 10 + + # New order: + # "router bgp 200 no bgp bestpath as-path multipath-relax" + # "router bgp 200 no neighbor underlay advertisement-interval 0" + # "router bgp 200 no neighbor underlay timers 3 9" + # "router bgp 200 no neighbor underlay timers connect 10" + # "router bgp 200 no neighbor uplink1 advertisement-interval 0" + # "router bgp 200 no neighbor uplink1 timers 3 9" + # "router bgp 200 no neighbor uplink1 timers connect 10" + # "router bgp 200 no neighbor underlay remote-as external" + # "router bgp 200 no neighbor uplink1 interface remote-as internal" + # "router bgp 200 no neighbor underlay peer-group" + + for (ctx_keys, line) in lines_to_del: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + # When 'neighbor <peer> remote-as <>' is removed it deletes the peer, + # there might be a peer associated config which also needs to be removed + # prior to peer. + # Append the 'neighbor <peer> remote-as <>' to the lines_to_del. + # Example: + # + # neighbor uplink1 interface remote-as internal + # neighbor uplink1 advertisement-interval 0 + # neighbor uplink1 timers 3 9 + # neighbor uplink1 timers connect 10 + + # Move to end: + # neighbor uplink1 advertisement-interval 0 + # neighbor uplink1 timers 3 9 + # neighbor uplink1 timers connect 10 + # ... + # + # neighbor uplink1 interface remote-as internal + # + # 'no neighbor peer [interface] remote-as <>' + nb_remoteas = "neighbor (\S+) .*remote-as (\S+)" + re_nb_remoteas = re.search(nb_remoteas, line) + if re_nb_remoteas: + lines_to_del_to_app.append((ctx_keys, line)) + + # 'no neighbor peer [interface] peer-group <>' is in lines_to_del + # copy the neighbor and look for all config removal lines associated + # to neighbor and delete them from the lines_to_del + re_nbr_pg = re.search("neighbor (\S+) .*peer-group (\S+)", line) + if re_nbr_pg: + if ctx_keys[0] not in del_nbr_dict: + del_nbr_dict[ctx_keys[0]] = list() + if re_nbr_pg.group(1) not in del_nbr_dict[ctx_keys[0]]: + del_nbr_dict[ctx_keys[0]].append(re_nbr_pg.group(1)) + + # {'router bgp 65001': {'PG': [], 'PG1': []}, + # 'router bgp 65001 vrf vrf1': {'PG': [], 'PG1': []}} + if ctx_keys[0] not in del_dict: + del_dict[ctx_keys[0]] = dict() + # find 'no neighbor <pg_name> peer-group' + re_pg = re.match("neighbor (\S+) peer-group$", line) + if re_pg and re_pg.group(1) not in del_dict[ctx_keys[0]]: + del_dict[ctx_keys[0]][re_pg.group(1)] = list() + found_pg_del_cmd = True + + if found_pg_del_cmd == False: + bgp_delete_inst_move_line(lines_to_del) + if del_nbr_dict: + bgp_remove_neighbor_cfg(lines_to_del, del_nbr_dict) + return (lines_to_add, lines_to_del) + + for (ctx_keys, line) in lines_to_del_to_app: + lines_to_del.remove((ctx_keys, line)) + lines_to_del.append((ctx_keys, line)) + + # {'router bgp 65001': {'PG': ['10.1.1.2'], 'PG1': ['10.1.1.21']}, + # 'router bgp 65001 vrf vrf1': {'PG': ['10.1.1.2'], 'PG1': ['10.1.1.21']}} + for (ctx_keys, line) in lines_to_del: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + if ctx_keys[0] in del_dict: + for pg_key in del_dict[ctx_keys[0]]: + # 'neighbor <peer> [interface] peer-group <pg_name>' + nb_pg = "neighbor (\S+) .*peer-group %s$" % pg_key + re_nbr_pg = re.search(nb_pg, line) + if ( + re_nbr_pg + and re_nbr_pg.group(1) not in del_dict[ctx_keys[0]][pg_key] + ): + del_dict[ctx_keys[0]][pg_key].append(re_nbr_pg.group(1)) + + lines_to_del_to_app = [] + for (ctx_keys, line) in lines_to_del: + if ( + ctx_keys[0].startswith("router bgp") + and line + and line.startswith("neighbor ") + ): + if ctx_keys[0] in del_dict: + for pg in del_dict[ctx_keys[0]]: + for nbr in del_dict[ctx_keys[0]][pg]: + nb_exp = "neighbor %s .*" % nbr + re_nb = re.search(nb_exp, line) + # add peer configs to delete list. + if re_nb and line not in lines_to_del_to_del: + lines_to_del_to_del.append((ctx_keys, line)) + + pg_exp = "neighbor %s peer-group$" % pg + re_pg = re.match(pg_exp, line) + if re_pg: + lines_to_del_to_app.append((ctx_keys, line)) + + for (ctx_keys, line) in lines_to_del_to_del: + lines_to_del.remove((ctx_keys, line)) + + for (ctx_keys, line) in lines_to_del_to_app: + lines_to_del.remove((ctx_keys, line)) + lines_to_del.append((ctx_keys, line)) + + bgp_delete_inst_move_line(lines_to_del) + + return (lines_to_add, lines_to_del) + + +def pim_delete_move_lines(lines_to_add, lines_to_del): + + # Under interface context, if 'no ip pim' is present + # remove subsequent 'no ip pim <blah>' options as it + # they are implicitly deleted by 'no ip pim'. + # Remove all such depdendent options from delete + # pending list. + pim_disable = False + + for (ctx_keys, line) in lines_to_del: + if ctx_keys[0].startswith("interface") and line and line == "ip pim": + pim_disable = True + + if pim_disable: + for (ctx_keys, line) in lines_to_del: + if ( + ctx_keys[0].startswith("interface") + and line + and line.startswith("ip pim ") + ): + lines_to_del.remove((ctx_keys, line)) + + return (lines_to_add, lines_to_del) + + +def delete_move_lines(lines_to_add, lines_to_del): + + lines_to_add, lines_to_del = bgp_delete_move_lines(lines_to_add, lines_to_del) + lines_to_add, lines_to_del = pim_delete_move_lines(lines_to_add, lines_to_del) + + return (lines_to_add, lines_to_del) + + +def ignore_delete_re_add_lines(lines_to_add, lines_to_del): + + # Quite possibly the most confusing (while accurate) variable names in history + lines_to_add_to_del = [] + lines_to_del_to_del = [] + + index = 0 + for (ctx_keys, line) in lines_to_del: + deleted = False + + # no form of route-map description command only + # accept 'no description', replace 'no description blah' + # to just 'no description'. + index = index + 1 + if ( + ctx_keys[0].startswith("route-map") + and line + and line.startswith("description ") + ): + lines_to_del.remove((ctx_keys, line)) + lines_to_del.insert(index, (ctx_keys, "description")) + + # If there is a change in the segment routing block ranges, do it + # in-place, to avoid requesting spurious label chunks which might fail + if line and "segment-routing global-block" in line: + for (add_key, add_line) in lines_to_add: + if ( + ctx_keys[0] == add_key[0] + and add_line + and "segment-routing global-block" in add_line + ): + lines_to_del_to_del.append((ctx_keys, line)) + break + continue + + if ctx_keys[0].startswith("router bgp") and line: + + if line.startswith("neighbor "): + # BGP changed how it displays swpX peers that are part of peer-group. Older + # versions of frr would display these on separate lines: + # neighbor swp1 interface + # neighbor swp1 peer-group FOO + # + # but today we display via a single line + # neighbor swp1 interface peer-group FOO + # + # This change confuses frr-reload.py so check to see if we are deleting + # neighbor swp1 interface peer-group FOO + # + # and adding + # neighbor swp1 interface + # neighbor swp1 peer-group FOO + # + # If so then chop the del line and the corresponding add lines + re_swpx_int_peergroup = re.search( + "neighbor (\S+) interface peer-group (\S+)", line + ) + re_swpx_int_v6only_peergroup = re.search( + "neighbor (\S+) interface v6only peer-group (\S+)", line + ) + + if re_swpx_int_peergroup or re_swpx_int_v6only_peergroup: + swpx_interface = None + swpx_peergroup = None + + if re_swpx_int_peergroup: + swpx = re_swpx_int_peergroup.group(1) + peergroup = re_swpx_int_peergroup.group(2) + swpx_interface = "neighbor %s interface" % swpx + elif re_swpx_int_v6only_peergroup: + swpx = re_swpx_int_v6only_peergroup.group(1) + peergroup = re_swpx_int_v6only_peergroup.group(2) + swpx_interface = "neighbor %s interface v6only" % swpx + + swpx_peergroup = "neighbor %s peer-group %s" % (swpx, peergroup) + found_add_swpx_interface = line_exist( + lines_to_add, ctx_keys, swpx_interface + ) + found_add_swpx_peergroup = line_exist( + lines_to_add, ctx_keys, swpx_peergroup + ) + tmp_ctx_keys = tuple(list(ctx_keys)) + + if not found_add_swpx_peergroup: + tmp_ctx_keys = list(ctx_keys) + tmp_ctx_keys.append("address-family ipv4 unicast") + tmp_ctx_keys = tuple(tmp_ctx_keys) + found_add_swpx_peergroup = line_exist( + lines_to_add, tmp_ctx_keys, swpx_peergroup + ) + + if not found_add_swpx_peergroup: + tmp_ctx_keys = list(ctx_keys) + tmp_ctx_keys.append("address-family ipv6 unicast") + tmp_ctx_keys = tuple(tmp_ctx_keys) + found_add_swpx_peergroup = line_exist( + lines_to_add, tmp_ctx_keys, swpx_peergroup + ) + + if found_add_swpx_interface and found_add_swpx_peergroup: + deleted = True + lines_to_del_to_del.append((ctx_keys, line)) + lines_to_add_to_del.append((ctx_keys, swpx_interface)) + lines_to_add_to_del.append((tmp_ctx_keys, swpx_peergroup)) + + # Changing the bfd timers on neighbors is allowed without doing + # a delete/add process. Since doing a "no neighbor blah bfd + # ..." will cause the peer to bounce unnecessarily, just skip + # the delete and just do the add. + re_nbr_bfd_timers = re.search( + r"neighbor (\S+) bfd (\S+) (\S+) (\S+)", line + ) + + if re_nbr_bfd_timers: + nbr = re_nbr_bfd_timers.group(1) + bfd_nbr = "neighbor %s" % nbr + bfd_search_string = bfd_nbr + r" bfd (\S+) (\S+) (\S+)" + + for (ctx_keys, add_line) in lines_to_add: + if ctx_keys[0].startswith("router bgp"): + re_add_nbr_bfd_timers = re.search( + bfd_search_string, add_line + ) + + if re_add_nbr_bfd_timers: + found_add_bfd_nbr = line_exist( + lines_to_add, ctx_keys, bfd_nbr, False + ) + + if found_add_bfd_nbr: + lines_to_del_to_del.append((ctx_keys, line)) + + # Neighbor changes of route-maps need to be accounted for in + # that we do not want to do a `no route-map...` `route-map + # ....` when changing a route-map. This is bad mojo as that we + # will send/receive data we don't want. Additionally we need + # to ensure that if we have different afi/safi variants that + # they actually match and if we are going from a very old style + # command such that the neighbor command is under the `router + # bgp ..` node that we need to handle that appropriately + re_nbr_rm = re.search("neighbor(.*)route-map(.*)(in|out)$", line) + if re_nbr_rm: + adjust_for_bgp_node = 0 + neighbor_name = re_nbr_rm.group(1) + rm_name_del = re_nbr_rm.group(2) + dir = re_nbr_rm.group(3) + search = "neighbor%sroute-map(.*)%s" % (neighbor_name, dir) + save_line = "EMPTY" + for (ctx_keys_al, add_line) in lines_to_add: + if ctx_keys_al[0].startswith("router bgp"): + if add_line: + rm_match = re.search(search, add_line) + if rm_match: + rm_name_add = rm_match.group(1) + if rm_name_add == rm_name_del: + continue + if len(ctx_keys_al) == 1: + save_line = line + adjust_for_bgp_node = 1 + else: + if ( + len(ctx_keys) > 1 + and len(ctx_keys_al) > 1 + and ctx_keys[1] == ctx_keys_al[1] + ): + lines_to_del_to_del.append((ctx_keys_al, line)) + + if adjust_for_bgp_node == 1: + for (ctx_keys_dl, dl_line) in lines_to_del: + if ( + ctx_keys_dl[0].startswith("router bgp") + and len(ctx_keys_dl) > 1 + and ctx_keys_dl[1] == "address-family ipv4 unicast" + ): + if save_line == dl_line: + lines_to_del_to_del.append((ctx_keys_dl, save_line)) + + # We changed how we display the neighbor interface command. Older + # versions of frr would display the following: + # neighbor swp1 interface + # neighbor swp1 remote-as external + # neighbor swp1 capability extended-nexthop + # + # but today we display via a single line + # neighbor swp1 interface remote-as external + # + # and capability extended-nexthop is no longer needed because we + # automatically enable it when the neighbor is of type interface. + # + # This change confuses frr-reload.py so check to see if we are deleting + # neighbor swp1 interface remote-as (external|internal|ASNUM) + # + # and adding + # neighbor swp1 interface + # neighbor swp1 remote-as (external|internal|ASNUM) + # neighbor swp1 capability extended-nexthop + # + # If so then chop the del line and the corresponding add lines + re_swpx_int_remoteas = re.search( + "neighbor (\S+) interface remote-as (\S+)", line + ) + re_swpx_int_v6only_remoteas = re.search( + "neighbor (\S+) interface v6only remote-as (\S+)", line + ) + + if re_swpx_int_remoteas or re_swpx_int_v6only_remoteas: + swpx_interface = None + swpx_remoteas = None + + if re_swpx_int_remoteas: + swpx = re_swpx_int_remoteas.group(1) + remoteas = re_swpx_int_remoteas.group(2) + swpx_interface = "neighbor %s interface" % swpx + elif re_swpx_int_v6only_remoteas: + swpx = re_swpx_int_v6only_remoteas.group(1) + remoteas = re_swpx_int_v6only_remoteas.group(2) + swpx_interface = "neighbor %s interface v6only" % swpx + + swpx_remoteas = "neighbor %s remote-as %s" % (swpx, remoteas) + found_add_swpx_interface = line_exist( + lines_to_add, ctx_keys, swpx_interface + ) + found_add_swpx_remoteas = line_exist( + lines_to_add, ctx_keys, swpx_remoteas + ) + tmp_ctx_keys = tuple(list(ctx_keys)) + + if found_add_swpx_interface and found_add_swpx_remoteas: + deleted = True + lines_to_del_to_del.append((ctx_keys, line)) + lines_to_add_to_del.append((ctx_keys, swpx_interface)) + lines_to_add_to_del.append((tmp_ctx_keys, swpx_remoteas)) + + # We made the 'bgp bestpath as-path multipath-relax' command + # automatically assume 'no-as-set' since the lack of this option + # caused weird routing problems. When the running config is shown + # in releases with this change, the no-as-set keyword is not shown + # as it is the default. This causes frr-reload to unnecessarily + # unapply this option only to apply it back again, causing + # unnecessary session resets. + if "multipath-relax" in line: + re_asrelax_new = re.search( + "^bgp\s+bestpath\s+as-path\s+multipath-relax$", line + ) + old_asrelax_cmd = "bgp bestpath as-path multipath-relax no-as-set" + found_asrelax_old = line_exist(lines_to_add, ctx_keys, old_asrelax_cmd) + + if re_asrelax_new and found_asrelax_old: + deleted = True + lines_to_del_to_del.append((ctx_keys, line)) + lines_to_add_to_del.append((ctx_keys, old_asrelax_cmd)) + + # If we are modifying the BGP table-map we need to avoid a del/add + # and instead modify the table-map in place via an add. This is + # needed to avoid installing all routes in the RIB the second the + # 'no table-map' is issued. + if line.startswith("table-map"): + found_table_map = line_exist(lines_to_add, ctx_keys, "table-map", False) + + if found_table_map: + lines_to_del_to_del.append((ctx_keys, line)) + + # More old-to-new config handling. ip import-table no longer accepts + # distance, but we honor the old syntax. But 'show running' shows only + # the new syntax. This causes an unnecessary 'no import-table' followed + # by the same old 'ip import-table' which causes perturbations in + # announced routes leading to traffic blackholes. Fix this issue. + re_importtbl = re.search("^ip\s+import-table\s+(\d+)$", ctx_keys[0]) + if re_importtbl: + table_num = re_importtbl.group(1) + for ctx in lines_to_add: + if ctx[0][0].startswith("ip import-table %s distance" % table_num): + lines_to_del_to_del.append( + (("ip import-table %s" % table_num,), None) + ) + lines_to_add_to_del.append((ctx[0], None)) + + # ip/ipv6 prefix-lists and access-lists can be specified without a seq + # number. However, the running config always adds 'seq x', where x is + # a number incremented by 5 for every element of the prefix/access + # list. So, ignore such lines as well. Sample prefix-list and + # acces-list lines: + # ip prefix-list PR-TABLE-2 seq 5 permit 20.8.2.0/24 le 32 + # ip prefix-list PR-TABLE-2 seq 10 permit 20.8.2.0/24 le 32 + # ipv6 prefix-list vrfdev6-12 permit 2000:9:2::/64 gt 64 + # access-list FOO seq 5 permit 2.2.2.2/32 + # ipv6 access-list BAR seq 5 permit 2:2:2::2/128 + re_acl_pfxlst = re.search( + "^(ip |ipv6 |)(prefix-list|access-list)(\s+\S+\s+)(seq \d+\s+)(permit|deny)(.*)$", + ctx_keys[0], + ) + if re_acl_pfxlst: + found = False + tmpline = ( + re_acl_pfxlst.group(1) + + re_acl_pfxlst.group(2) + + re_acl_pfxlst.group(3) + + re_acl_pfxlst.group(5) + + re_acl_pfxlst.group(6) + ) + for ctx in lines_to_add: + if ctx[0][0] == tmpline: + lines_to_del_to_del.append((ctx_keys, None)) + lines_to_add_to_del.append(((tmpline,), None)) + found = True + # If prefix-lists or access-lists are being deleted and not added + # (see comment above), add command with 'no' to lines_to_add and + # remove from lines_to_del to improve scaling performance. + if found is False: + add_cmd = ("no " + ctx_keys[0],) + lines_to_add.append((add_cmd, None)) + lines_to_del_to_del.append((ctx_keys, None)) + + # bgp community-list, large-community-list, extcommunity-list can be + # specified without a seq number. However, the running config always + # adds `seq X` (sequence number). So, ignore such lines as well. + # Examples: + # bgp community-list standard clist seq 5 permit 222:213 + # bgp large-community-list standard llist seq 5 permit 65001:65001:1 + # bgp extcommunity-list standard elist seq 5 permit soo 123:123 + re_bgp_lists = re.search( + "^(bgp )(community-list|large-community-list|extcommunity-list)(\s+\S+\s+)(\S+\s+)(seq \d+\s+)(permit|deny)(.*)$", + ctx_keys[0], + ) + if re_bgp_lists: + found = False + tmpline = ( + re_bgp_lists.group(1) + + re_bgp_lists.group(2) + + re_bgp_lists.group(3) + + re_bgp_lists.group(4) + + re_bgp_lists.group(6) + + re_bgp_lists.group(7) + ) + for ctx in lines_to_add: + if ctx[0][0] == tmpline: + lines_to_del_to_del.append((ctx_keys, None)) + lines_to_add_to_del.append(((tmpline,), None)) + found = True + if found is False: + add_cmd = ("no " + ctx_keys[0],) + lines_to_add.append((add_cmd, None)) + lines_to_del_to_del.append((ctx_keys, None)) + + if ( + len(ctx_keys) == 3 + and ctx_keys[0].startswith("router bgp") + and ctx_keys[1] == "address-family l2vpn evpn" + and ctx_keys[2].startswith("vni") + ): + + re_route_target = ( + re.search("^route-target import (.*)$", line) + if line is not None + else False + ) + + if re_route_target: + rt = re_route_target.group(1).strip() + route_target_import_line = line + route_target_export_line = "route-target export %s" % rt + route_target_both_line = "route-target both %s" % rt + + found_route_target_export_line = line_exist( + lines_to_del, ctx_keys, route_target_export_line + ) + found_route_target_both_line = line_exist( + lines_to_add, ctx_keys, route_target_both_line + ) + + # If the running configs has + # route-target import 1:1 + # route-target export 1:1 + # and the config we are reloading against has + # route-target both 1:1 + # then we can ignore deleting the import/export and ignore adding the 'both' + if found_route_target_export_line and found_route_target_both_line: + lines_to_del_to_del.append((ctx_keys, route_target_import_line)) + lines_to_del_to_del.append((ctx_keys, route_target_export_line)) + lines_to_add_to_del.append((ctx_keys, route_target_both_line)) + + # Deleting static routes under a vrf can lead to time-outs if each is sent + # as separate vtysh -c commands. Change them from being in lines_to_del and + # put the "no" form in lines_to_add + if ctx_keys[0].startswith("vrf ") and line: + if line.startswith("ip route") or line.startswith("ipv6 route"): + add_cmd = "no " + line + lines_to_add.append((ctx_keys, add_cmd)) + lines_to_del_to_del.append((ctx_keys, line)) + + if not deleted: + found_add_line = line_exist(lines_to_add, ctx_keys, line) + + if found_add_line: + lines_to_del_to_del.append((ctx_keys, line)) + lines_to_add_to_del.append((ctx_keys, line)) + else: + # We have commands that used to be displayed in the global part + # of 'router bgp' that are now displayed under 'address-family ipv4 unicast' + # + # # old way + # router bgp 64900 + # neighbor ISL advertisement-interval 0 + # + # vs. + # + # # new way + # router bgp 64900 + # address-family ipv4 unicast + # neighbor ISL advertisement-interval 0 + # + # Look to see if we are deleting it in one format just to add it back in the other + if ( + ctx_keys[0].startswith("router bgp") + and len(ctx_keys) > 1 + and ctx_keys[1] == "address-family ipv4 unicast" + ): + tmp_ctx_keys = list(ctx_keys)[:-1] + tmp_ctx_keys = tuple(tmp_ctx_keys) + + found_add_line = line_exist(lines_to_add, tmp_ctx_keys, line) + + if found_add_line: + lines_to_del_to_del.append((ctx_keys, line)) + lines_to_add_to_del.append((tmp_ctx_keys, line)) + + for (ctx_keys, line) in lines_to_del_to_del: + try: + lines_to_del.remove((ctx_keys, line)) + except ValueError: + pass + + for (ctx_keys, line) in lines_to_add_to_del: + try: + lines_to_add.remove((ctx_keys, line)) + except ValueError: + pass + + + return (lines_to_add, lines_to_del) + + +def ignore_unconfigurable_lines(lines_to_add, lines_to_del): + """ + There are certain commands that cannot be removed. Remove + those commands from lines_to_del. + """ + lines_to_del_to_del = [] + + for (ctx_keys, line) in lines_to_del: + + # The integrated-vtysh-config one is technically "no"able but if we did + # so frr-reload would stop working so do not let the user shoot + # themselves in the foot by removing this. + if any( + [ + ctx_keys[0].startswith(x) + for x in [ + "agentx", + "frr version", + "frr defaults", + "username", + "password", + "line vty", + "service integrated-vtysh-config", + ] + ] + ): + log.info('"%s" cannot be removed' % (ctx_keys[-1],)) + lines_to_del_to_del.append((ctx_keys, line)) + + for (ctx_keys, line) in lines_to_del_to_del: + lines_to_del.remove((ctx_keys, line)) + + return (lines_to_add, lines_to_del) + + +def compare_context_objects(newconf, running): + """ + Create a context diff for the two specified contexts + """ + + # Compare the two Config objects to find the lines that we need to add/del + lines_to_add = [] + lines_to_del = [] + pollist_to_del = [] + seglist_to_del = [] + pceconf_to_del = [] + pcclist_to_del = [] + candidates_to_add = [] + delete_bgpd = False + area_stub_no_sum = "area (\S+) stub no-summary" + deleted_keychains = [] + + # Find contexts that are in newconf but not in running + # Find contexts that are in running but not in newconf + for (running_ctx_keys, running_ctx) in iteritems(running.contexts): + + if running_ctx_keys in newconf.contexts: + newconf_ctx = newconf.contexts[running_ctx_keys] + + for line in running_ctx.lines: + # ospf area <> stub no-summary line removal requires + # to remoe area <> stub as no form of original + # retains the stub form. + # lines_to_del will contain: + # no area <x> stub no-summary and + # no area <x> stub + if ( + running_ctx_keys[0].startswith("router ospf") + and line not in newconf_ctx.dlines + ): + re_area_stub_no_sum = re.search(area_stub_no_sum, line) + if re_area_stub_no_sum: + new_del_line = "area %s stub" % re_area_stub_no_sum.group(1) + lines_to_del.append((running_ctx_keys, new_del_line)) + + if running_ctx_keys not in newconf.contexts: + + # We check that the len is 1 here so that we only look at ('router bgp 10') + # and not ('router bgp 10', 'address-family ipv4 unicast'). The + # latter could cause a false delete_bgpd positive if ipv4 unicast is in + # running but not in newconf. + if "router bgp" in running_ctx_keys[0] and len(running_ctx_keys) == 1: + delete_bgpd = True + lines_to_del.append((running_ctx_keys, None)) + + # We cannot do 'no interface' or 'no vrf' in FRR, and so deal with it + elif running_ctx_keys[0].startswith("interface") or running_ctx_keys[ + 0 + ].startswith("vrf"): + for line in running_ctx.lines: + lines_to_del.append((running_ctx_keys, line)) + + # If this is an address-family under 'router bgp' and we are already deleting the + # entire 'router bgp' context then ignore this sub-context + elif ( + "router bgp" in running_ctx_keys[0] + and len(running_ctx_keys) > 1 + and delete_bgpd + ): + continue + + # Check if key chain is being deleted: + # - If it is being deleted then avoid deleting its contexts + # - Else delete its configuration without removing the root node + elif ( + running_ctx_keys[0].startswith("key chain ") + and len(running_ctx_keys) == 1 + ): + deleted_keychains.append(running_ctx_keys[0]) + lines_to_del.append((running_ctx_keys, None)) + elif ( + running_ctx_keys[0].startswith("key chain ") + and len(running_ctx_keys) > 1 + and running_ctx_keys[0] in deleted_keychains + ): + continue + + # Delete an entire vni sub-context under "address-family l2vpn evpn" + elif ( + "router bgp" in running_ctx_keys[0] + and len(running_ctx_keys) > 2 + and running_ctx_keys[1].startswith("address-family l2vpn evpn") + and running_ctx_keys[2].startswith("vni ") + ): + lines_to_del.append((running_ctx_keys, None)) + + elif ( + "router bgp" in running_ctx_keys[0] + and len(running_ctx_keys) > 1 + and running_ctx_keys[1].startswith("address-family") + ): + # There's no 'no address-family' support and so we have to + # delete each line individually again + for line in running_ctx.lines: + lines_to_del.append((running_ctx_keys, line)) + + # Some commands can happen at higher counts that make + # doing vtysh -c inefficient (and can time out.) For + # these commands, instead of adding them to lines_to_del, + # add the "no " version to lines_to_add. + elif running_ctx_keys[0].startswith("ip route") or running_ctx_keys[ + 0 + ].startswith("ipv6 route"): + add_cmd = ("no " + running_ctx_keys[0],) + lines_to_add.append((add_cmd, None)) + + # if this an interface sub-subcontext in an address-family block in ldpd and + # we are already deleting the whole context, then ignore this + elif ( + len(running_ctx_keys) > 2 + and running_ctx_keys[0].startswith("mpls ldp") + and running_ctx_keys[1].startswith("address-family") + and (running_ctx_keys[:2], None) in lines_to_del + ): + continue + + # same thing for a pseudowire sub-context inside an l2vpn context + elif ( + len(running_ctx_keys) > 1 + and running_ctx_keys[0].startswith("l2vpn") + and running_ctx_keys[1].startswith("member pseudowire") + and (running_ctx_keys[:1], None) in lines_to_del + ): + continue + + # Segment routing and traffic engineering never need to be deleted + elif ( + running_ctx_keys[0].startswith("segment-routing") + and len(running_ctx_keys) < 3 + ): + continue + + # Neither the pcep command + elif ( + len(running_ctx_keys) == 3 + and running_ctx_keys[0].startswith("segment-routing") + and running_ctx_keys[2].startswith("pcep") + ): + continue + + # Segment lists can only be deleted after we removed all the candidate paths that + # use them, so add them to a separate array that is going to be appended at the end + elif ( + len(running_ctx_keys) == 3 + and running_ctx_keys[0].startswith("segment-routing") + and running_ctx_keys[2].startswith("segment-list") + ): + seglist_to_del.append((running_ctx_keys, None)) + + # Policies must be deleted after there candidate path, to be sure + # we add them to a separate array that is going to be appended at the end + elif ( + len(running_ctx_keys) == 3 + and running_ctx_keys[0].startswith("segment-routing") + and running_ctx_keys[2].startswith("policy") + ): + pollist_to_del.append((running_ctx_keys, None)) + + # pce-config must be deleted after the pce, to be sure we add them + # to a separate array that is going to be appended at the end + elif ( + len(running_ctx_keys) >= 4 + and running_ctx_keys[0].startswith("segment-routing") + and running_ctx_keys[3].startswith("pce-config") + ): + pceconf_to_del.append((running_ctx_keys, None)) + + # pcc must be deleted after the pce and pce-config too + elif ( + len(running_ctx_keys) >= 4 + and running_ctx_keys[0].startswith("segment-routing") + and running_ctx_keys[3].startswith("pcc") + ): + pcclist_to_del.append((running_ctx_keys, None)) + + # Non-global context + elif running_ctx_keys and not any( + "address-family" in key for key in running_ctx_keys + ): + lines_to_del.append((running_ctx_keys, None)) + + elif running_ctx_keys and not any("vni" in key for key in running_ctx_keys): + lines_to_del.append((running_ctx_keys, None)) + + # Global context + else: + for line in running_ctx.lines: + lines_to_del.append((running_ctx_keys, line)) + + # if we have some policies commands to delete, append them to lines_to_del + if len(pollist_to_del) > 0: + lines_to_del.extend(pollist_to_del) + + # if we have some segment list commands to delete, append them to lines_to_del + if len(seglist_to_del) > 0: + lines_to_del.extend(seglist_to_del) + + # if we have some pce list commands to delete, append them to lines_to_del + if len(pceconf_to_del) > 0: + lines_to_del.extend(pceconf_to_del) + + # if we have some pcc list commands to delete, append them to lines_to_del + if len(pcclist_to_del) > 0: + lines_to_del.extend(pcclist_to_del) + + # Find the lines within each context to add + # Find the lines within each context to del + for (newconf_ctx_keys, newconf_ctx) in iteritems(newconf.contexts): + + if newconf_ctx_keys in running.contexts: + running_ctx = running.contexts[newconf_ctx_keys] + + for line in newconf_ctx.lines: + if line not in running_ctx.dlines: + + # candidate paths can only be added after the policy and segment list, + # so add them to a separate array that is going to be appended at the end + if ( + len(newconf_ctx_keys) == 3 + and newconf_ctx_keys[0].startswith("segment-routing") + and newconf_ctx_keys[2].startswith("policy ") + and line.startswith("candidate-path ") + ): + candidates_to_add.append((newconf_ctx_keys, line)) + + else: + lines_to_add.append((newconf_ctx_keys, line)) + + for line in running_ctx.lines: + if line not in newconf_ctx.dlines: + lines_to_del.append((newconf_ctx_keys, line)) + + for (newconf_ctx_keys, newconf_ctx) in iteritems(newconf.contexts): + + if newconf_ctx_keys not in running.contexts: + + # candidate paths can only be added after the policy and segment list, + # so add them to a separate array that is going to be appended at the end + if ( + len(newconf_ctx_keys) == 4 + and newconf_ctx_keys[0].startswith("segment-routing") + and newconf_ctx_keys[3].startswith("candidate-path") + ): + candidates_to_add.append((newconf_ctx_keys, None)) + for line in newconf_ctx.lines: + candidates_to_add.append((newconf_ctx_keys, line)) + + else: + lines_to_add.append((newconf_ctx_keys, None)) + + for line in newconf_ctx.lines: + lines_to_add.append((newconf_ctx_keys, line)) + + # if we have some candidate paths commands to add, append them to lines_to_add + if len(candidates_to_add) > 0: + lines_to_add.extend(candidates_to_add) + + (lines_to_add, lines_to_del) = ignore_delete_re_add_lines( + lines_to_add, lines_to_del + ) + (lines_to_add, lines_to_del) = delete_move_lines(lines_to_add, lines_to_del) + (lines_to_add, lines_to_del) = ignore_unconfigurable_lines( + lines_to_add, lines_to_del + ) + + return (lines_to_add, lines_to_del) + + +if __name__ == "__main__": + # Command line options + parser = argparse.ArgumentParser( + description="Dynamically apply diff in frr configs" + ) + parser.add_argument( + "--input", help='Read running config from file instead of "show running"' + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--reload", action="store_true", help="Apply the deltas", default=False + ) + group.add_argument( + "--test", action="store_true", help="Show the deltas", default=False + ) + level_group = parser.add_mutually_exclusive_group() + level_group.add_argument( + "--debug", + action="store_true", + help="Enable debugs (synonym for --log-level=debug)", + default=False, + ) + level_group.add_argument( + "--log-level", + help="Log level", + default="info", + choices=("critical", "error", "warning", "info", "debug"), + ) + parser.add_argument( + "--stdout", action="store_true", help="Log to STDOUT", default=False + ) + parser.add_argument( + "--pathspace", + "-N", + metavar="NAME", + help="Reload specified path/namespace", + default=None, + ) + parser.add_argument("filename", help="Location of new frr config file") + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite frr.conf with running config output", + default=False, + ) + parser.add_argument( + "--bindir", help="path to the vtysh executable", default="/usr/bin" + ) + parser.add_argument( + "--confdir", help="path to the daemon config files", default="/etc/frr" + ) + parser.add_argument( + "--rundir", help="path for the temp config file", default="/var/run/frr" + ) + parser.add_argument( + "--vty_socket", + help="socket to be used by vtysh to connect to the daemons", + default=None, + ) + parser.add_argument( + "--daemon", help="daemon for which want to replace the config", default="" + ) + parser.add_argument( + "--test-reset", + action="store_true", + help="Used by topotest to not delete debug or log file commands", + ) + + args = parser.parse_args() + + # Logging + # For --test log to stdout + # For --reload log to /var/log/frr/frr-reload.log + if args.test or args.stdout: + logging.basicConfig(format="%(asctime)s %(levelname)5s: %(message)s") + + # Color the errors and warnings in red + logging.addLevelName( + logging.ERROR, "\033[91m %s\033[0m" % logging.getLevelName(logging.ERROR) + ) + logging.addLevelName( + logging.WARNING, "\033[91m%s\033[0m" % logging.getLevelName(logging.WARNING) + ) + + elif args.reload: + if not os.path.isdir("/var/log/frr/"): + os.makedirs("/var/log/frr/", mode=0o0755) + + logging.basicConfig( + filename="/var/log/frr/frr-reload.log", + format="%(asctime)s %(levelname)5s: %(message)s", + ) + + # argparse should prevent this from happening but just to be safe... + else: + raise Exception("Must specify --reload or --test") + log = logging.getLogger(__name__) + + if args.debug: + log.setLevel(logging.DEBUG) + else: + log.setLevel(args.log_level.upper()) + + if args.reload and not args.stdout: + # Additionally send errors and above to STDOUT, with no metadata, + # when we are logging to a file. This specifically does not follow + # args.log_level, and is analagous to behaviour in earlier versions + # which additionally logged most errors using print(). + + stdout_hdlr = logging.StreamHandler(sys.stdout) + stdout_hdlr.setLevel(logging.ERROR) + stdout_hdlr.setFormatter(logging.Formatter()) + log.addHandler(stdout_hdlr) + + # Verify the new config file is valid + if not os.path.isfile(args.filename): + log.error("Filename %s does not exist" % args.filename) + sys.exit(1) + + if not os.path.getsize(args.filename): + log.error("Filename %s is an empty file" % args.filename) + sys.exit(1) + + # Verify that confdir is correct + if not os.path.isdir(args.confdir): + log.error("Confdir %s is not a valid path" % args.confdir) + sys.exit(1) + + # Verify that bindir is correct + if not os.path.isdir(args.bindir) or not os.path.isfile(args.bindir + "/vtysh"): + log.error("Bindir %s is not a valid path to vtysh" % args.bindir) + sys.exit(1) + + # verify that the vty_socket, if specified, is valid + if args.vty_socket and not os.path.isdir(args.vty_socket): + log.error("vty_socket %s is not a valid path" % args.vty_socket) + sys.exit(1) + + # verify that the daemon, if specified, is valid + if args.daemon and args.daemon not in [ + "zebra", + "bgpd", + "fabricd", + "isisd", + "babeld", + "ospf6d", + "ospfd", + "pbrd", + "pimd", + "pim6d", + "ripd", + "ripngd", + "sharpd", + "staticd", + "vrrpd", + "ldpd", + "nhrpd", + "pathd", + "bfdd", + "eigrpd", + ]: + msg = "Daemon %s is not a valid option for 'show running-config'" % args.daemon + print(msg) + log.error(msg) + sys.exit(1) + + vtysh = Vtysh(args.bindir, args.confdir, args.vty_socket, args.pathspace) + + # Verify that 'service integrated-vtysh-config' is configured + if args.pathspace: + vtysh_filename = args.confdir + "/" + args.pathspace + "/vtysh.conf" + else: + vtysh_filename = args.confdir + "/vtysh.conf" + service_integrated_vtysh_config = True + + if os.path.isfile(vtysh_filename): + with open(vtysh_filename, "r") as fh: + for line in fh.readlines(): + line = line.strip() + + if line == "no service integrated-vtysh-config": + service_integrated_vtysh_config = False + break + + if not args.test and not service_integrated_vtysh_config and not args.daemon: + log.error( + "'service integrated-vtysh-config' is not configured, this is required for 'service frr reload'" + ) + sys.exit(1) + + log.info('Called via "%s"', str(args)) + + # Create a Config object from the config generated by newconf + newconf = Config(vtysh) + try: + newconf.load_from_file(args.filename) + reload_ok = True + except VtyshException as ve: + log.error("vtysh failed to process new configuration: {}".format(ve)) + reload_ok = False + + if args.test: + + # Create a Config object from the running config + running = Config(vtysh) + + if args.input: + running.load_from_file(args.input) + else: + running.load_from_show_running(args.daemon) + + (lines_to_add, lines_to_del) = compare_context_objects(newconf, running) + + if lines_to_del: + if not args.test_reset: + print("\nLines To Delete") + print("===============") + + for (ctx_keys, line) in lines_to_del: + + if line == "!": + continue + + nolines = lines_to_config(ctx_keys, line, True) + + if args.test_reset: + # For topotests the original code stripped the lines, and ommitted blank lines + # after, do that here + nolines = [x.strip() for x in nolines] + # For topotests leave these lines in (don't delete them) + # [chopps: why is "log file" more special than other "log" commands?] + nolines = [ + x for x in nolines if "debug" not in x and "log file" not in x + ] + if not nolines: + continue + + cmd = "\n".join(nolines) + print(cmd) + + if lines_to_add: + if not args.test_reset: + print("\nLines To Add") + print("============") + + for (ctx_keys, line) in lines_to_add: + + if line == "!": + continue + + lines = lines_to_config(ctx_keys, line, False) + + if args.test_reset: + # For topotests the original code stripped the lines, and ommitted blank lines + # after, do that here + lines = [x.strip() for x in lines if x.strip()] + if not lines: + continue + + cmd = "\n".join(lines) + print(cmd) + + elif args.reload: + lines_to_configure = [] + + # We will not be able to do anything, go ahead and exit(1) + if not vtysh.is_config_available() or not reload_ok: + sys.exit(1) + + log.debug("New Frr Config\n%s", newconf.get_lines()) + + # This looks a little odd but we have to do this twice...here is why + # If the user had this running bgp config: + # + # router bgp 10 + # neighbor 1.1.1.1 remote-as 50 + # neighbor 1.1.1.1 route-map FOO out + # + # and this config in the newconf config file + # + # router bgp 10 + # neighbor 1.1.1.1 remote-as 999 + # neighbor 1.1.1.1 route-map FOO out + # + # + # Then the script will do + # - no neighbor 1.1.1.1 remote-as 50 + # - neighbor 1.1.1.1 remote-as 999 + # + # The problem is the "no neighbor 1.1.1.1 remote-as 50" will also remove + # the "neighbor 1.1.1.1 route-map FOO out" line...so we compare the + # configs again to put this line back. + + # There are many keywords in FRR that can only appear one time under + # a context, take "bgp router-id" for example. If the config that we are + # reloading against has the following: + # + # router bgp 10 + # bgp router-id 1.1.1.1 + # bgp router-id 2.2.2.2 + # + # The final config needs to contain "bgp router-id 2.2.2.2". On the + # first pass we will add "bgp router-id 2.2.2.2" but then on the second + # pass we will see that "bgp router-id 1.1.1.1" is missing and add that + # back which cancels out the "bgp router-id 2.2.2.2". The fix is for the + # second pass to include all of the "adds" from the first pass. + lines_to_add_first_pass = [] + + for x in range(2): + running = Config(vtysh) + running.load_from_show_running(args.daemon) + log.debug("Running Frr Config (Pass #%d)\n%s", x, running.get_lines()) + + (lines_to_add, lines_to_del) = compare_context_objects(newconf, running) + + if x == 0: + lines_to_add_first_pass = lines_to_add + else: + lines_to_add.extend(lines_to_add_first_pass) + + # Only do deletes on the first pass. The reason being if we + # configure a bgp neighbor via "neighbor swp1 interface" FRR + # will automatically add: + # + # interface swp1 + # ipv6 nd ra-interval 10 + # no ipv6 nd suppress-ra + # ! + # + # but those lines aren't in the config we are reloading against so + # on the 2nd pass they will show up in lines_to_del. This could + # apply to other scenarios as well where configuring FOO adds BAR + # to the config. + if lines_to_del and x == 0: + for (ctx_keys, line) in lines_to_del: + + if line == "!": + continue + + # 'no' commands are tricky, we can't just put them in a file and + # vtysh -f that file. See the next comment for an explanation + # of their quirks + cmd = lines_to_config(ctx_keys, line, True) + original_cmd = cmd + + # Some commands in frr are picky about taking a "no" of the entire line. + # OSPF is bad about this, you can't "no" the entire line, you have to "no" + # only the beginning. If we hit one of these command an exception will be + # thrown. Catch it and remove the last '-c', 'FOO' from cmd and try again. + # + # Example: + # frr(config-if)# ip ospf authentication message-digest 1.1.1.1 + # frr(config-if)# no ip ospf authentication message-digest 1.1.1.1 + # % Unknown command. + # frr(config-if)# no ip ospf authentication message-digest + # % Unknown command. + # frr(config-if)# no ip ospf authentication + # frr(config-if)# + + stdouts = [] + while True: + try: + vtysh(["configure"] + cmd, stdouts) + + except VtyshException: + + # - Pull the last entry from cmd (this would be + # 'no ip ospf authentication message-digest 1.1.1.1' in + # our example above + # - Split that last entry by whitespace and drop the last word + log.info("Failed to execute %s", " ".join(cmd)) + last_arg = cmd[-1].split(" ") + + if len(last_arg) <= 2: + log.error( + '"%s" we failed to remove this command', + " -- ".join(original_cmd), + ) + # Log first error msg for original_cmd + if stdouts: + log.error(stdouts[0]) + reload_ok = False + break + + new_last_arg = last_arg[0:-1] + cmd[-1] = " ".join(new_last_arg) + else: + log.info('Executed "%s"', " ".join(cmd)) + break + + if lines_to_add: + lines_to_configure = [] + + for (ctx_keys, line) in lines_to_add: + + if line == "!": + continue + + # Don't run "no" commands twice since they can error + # out the second time due to first deletion + if x == 1 and ctx_keys[0].startswith("no "): + continue + + cmd = "\n".join(lines_to_config(ctx_keys, line, False)) + "\n" + lines_to_configure.append(cmd) + + if lines_to_configure: + random_string = "".join( + random.SystemRandom().choice( + string.ascii_uppercase + string.digits + ) + for _ in range(6) + ) + + filename = args.rundir + "/reload-%s.txt" % random_string + log.info("%s content\n%s" % (filename, pformat(lines_to_configure))) + + with open(filename, "w") as fh: + for line in lines_to_configure: + fh.write(line + "\n") + + try: + vtysh.exec_file(filename) + except VtyshException as e: + log.warning("frr-reload.py failed due to\n%s" % e.args) + reload_ok = False + os.unlink(filename) + + # Make these changes persistent + target = str(args.confdir + "/frr.conf") + if args.overwrite or (not args.daemon and args.filename != target): + vtysh("write") + + if not reload_ok: + sys.exit(1) |