]> git.proxmox.com Git - mirror_frr.git/blob - tools/frr-reload.py
Merge pull request #3868 from ton31337/fix/v6_only_flag
[mirror_frr.git] / tools / frr-reload.py
1 #!/usr/bin/python
2 # Frr Reloader
3 # Copyright (C) 2014 Cumulus Networks, Inc.
4 #
5 # This file is part of Frr.
6 #
7 # Frr is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation; either version 2, or (at your option) any
10 # later version.
11 #
12 # Frr is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 # General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Frr; see the file COPYING. If not, write to the Free
19 # Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA
20 # 02111-1307, USA.
21 #
22 """
23 This program
24 - reads a frr configuration text file
25 - reads frr's current running configuration via "vtysh -c 'show running'"
26 - compares the two configs and determines what commands to execute to
27 synchronize frr's running configuration with the configuation in the
28 text file
29 """
30
31 from __future__ import print_function, unicode_literals
32 import argparse
33 import copy
34 import logging
35 import os
36 import random
37 import re
38 import string
39 import subprocess
40 import sys
41 from collections import OrderedDict
42 try:
43 from ipaddress import IPv6Address, ip_network
44 except ImportError:
45 from ipaddr import IPv6Address, IPNetwork
46 from pprint import pformat
47
48 try:
49 dict.iteritems
50 except AttributeError:
51 # Python 3
52 def iteritems(d):
53 return iter(d.items())
54 else:
55 # Python 2
56 def iteritems(d):
57 return d.iteritems()
58
59 log = logging.getLogger(__name__)
60
61
62 class VtyshMarkException(Exception):
63 pass
64
65
66 class Context(object):
67
68 """
69 A Context object represents a section of frr configuration such as:
70 !
71 interface swp3
72 description swp3 -> r8's swp1
73 ipv6 nd suppress-ra
74 link-detect
75 !
76
77 or a single line context object such as this:
78
79 ip forwarding
80
81 """
82
83 def __init__(self, keys, lines):
84 self.keys = keys
85 self.lines = lines
86
87 # Keep a dictionary of the lines, this is to make it easy to tell if a
88 # line exists in this Context
89 self.dlines = OrderedDict()
90
91 for ligne in lines:
92 self.dlines[ligne] = True
93
94 def add_lines(self, lines):
95 """
96 Add lines to specified context
97 """
98
99 self.lines.extend(lines)
100
101 for ligne in lines:
102 self.dlines[ligne] = True
103
104
105 class Config(object):
106
107 """
108 A frr configuration is stored in a Config object. A Config object
109 contains a dictionary of Context objects where the Context keys
110 ('router ospf' for example) are our dictionary key.
111 """
112
113 def __init__(self):
114 self.lines = []
115 self.contexts = OrderedDict()
116
117 def load_from_file(self, filename):
118 """
119 Read configuration from specified file and slurp it into internal memory
120 The internal representation has been marked appropriately by passing it
121 through vtysh with the -m parameter
122 """
123 log.info('Loading Config object from file %s', filename)
124
125 try:
126 file_output = subprocess.check_output(['/usr/bin/vtysh', '-m', '-f', filename],
127 stderr=subprocess.STDOUT)
128 except subprocess.CalledProcessError as e:
129 ve = VtyshMarkException(e)
130 ve.output = e.output
131 raise ve
132
133 for line in file_output.decode('utf-8').split('\n'):
134 line = line.strip()
135
136 # Compress duplicate whitespaces
137 line = ' '.join(line.split())
138
139 if ":" in line:
140 qv6_line = get_normalized_ipv6_line(line)
141 self.lines.append(qv6_line)
142 else:
143 self.lines.append(line)
144
145 self.load_contexts()
146
147 def load_from_show_running(self):
148 """
149 Read running configuration and slurp it into internal memory
150 The internal representation has been marked appropriately by passing it
151 through vtysh with the -m parameter
152 """
153 log.info('Loading Config object from vtysh show running')
154
155 try:
156 config_text = subprocess.check_output(
157 "/usr/bin/vtysh -c 'show run' | /usr/bin/tail -n +4 | /usr/bin/vtysh -m -f -",
158 shell=True, stderr=subprocess.STDOUT)
159 except subprocess.CalledProcessError as e:
160 ve = VtyshMarkException(e)
161 ve.output = e.output
162 raise ve
163
164 for line in config_text.decode('utf-8').split('\n'):
165 line = line.strip()
166
167 if (line == 'Building configuration...' or
168 line == 'Current configuration:' or
169 not line):
170 continue
171
172 self.lines.append(line)
173
174 self.load_contexts()
175
176 def get_lines(self):
177 """
178 Return the lines read in from the configuration
179 """
180
181 return '\n'.join(self.lines)
182
183 def get_contexts(self):
184 """
185 Return the parsed context as strings for display, log etc.
186 """
187
188 for (_, ctx) in sorted(iteritems(self.contexts)):
189 print(str(ctx) + '\n')
190
191 def save_contexts(self, key, lines):
192 """
193 Save the provided key and lines as a context
194 """
195
196 if not key:
197 return
198
199 '''
200 IP addresses specified in "network" statements, "ip prefix-lists"
201 etc. can differ in the host part of the specification the user
202 provides and what the running config displays. For example, user
203 can specify 11.1.1.1/24, and the running config displays this as
204 11.1.1.0/24. Ensure we don't do a needless operation for such
205 lines. IS-IS & OSPFv3 have no "network" support.
206 '''
207 re_key_rt = re.match(r'(ip|ipv6)\s+route\s+([A-Fa-f:.0-9/]+)(.*)$', key[0])
208 if re_key_rt:
209 addr = re_key_rt.group(2)
210 if '/' in addr:
211 try:
212 if 'ipaddress' not in sys.modules:
213 newaddr = IPNetwork(addr)
214 key[0] = '%s route %s/%s%s' % (re_key_rt.group(1),
215 newaddr.network,
216 newaddr.prefixlen,
217 re_key_rt.group(3))
218 else:
219 newaddr = ip_network(addr, strict=False)
220 key[0] = '%s route %s/%s%s' % (re_key_rt.group(1),
221 str(newaddr.network_address),
222 newaddr.prefixlen,
223 re_key_rt.group(3))
224 except ValueError:
225 pass
226
227 re_key_rt = re.match(
228 r'(ip|ipv6)\s+prefix-list(.*)(permit|deny)\s+([A-Fa-f:.0-9/]+)(.*)$',
229 key[0]
230 )
231 if re_key_rt:
232 addr = re_key_rt.group(4)
233 if '/' in addr:
234 try:
235 if 'ipaddress' not in sys.modules:
236 newaddr = '%s/%s' % (IPNetwork(addr).network,
237 IPNetwork(addr).prefixlen)
238 else:
239 network_addr = ip_network(addr, strict=False)
240 newaddr = '%s/%s' % (str(network_addr.network_address),
241 network_addr.prefixlen)
242 except ValueError:
243 newaddr = addr
244 else:
245 newaddr = addr
246
247 legestr = re_key_rt.group(5)
248 re_lege = re.search(r'(.*)le\s+(\d+)\s+ge\s+(\d+)(.*)', legestr)
249 if re_lege:
250 legestr = '%sge %s le %s%s' % (re_lege.group(1),
251 re_lege.group(3),
252 re_lege.group(2),
253 re_lege.group(4))
254 re_lege = re.search(r'(.*)ge\s+(\d+)\s+le\s+(\d+)(.*)', legestr)
255
256 if (re_lege and ((re_key_rt.group(1) == "ip" and
257 re_lege.group(3) == "32") or
258 (re_key_rt.group(1) == "ipv6" and
259 re_lege.group(3) == "128"))):
260 legestr = '%sge %s%s' % (re_lege.group(1),
261 re_lege.group(2),
262 re_lege.group(4))
263
264 key[0] = '%s prefix-list%s%s %s%s' % (re_key_rt.group(1),
265 re_key_rt.group(2),
266 re_key_rt.group(3),
267 newaddr,
268 legestr)
269
270 if lines and key[0].startswith('router bgp'):
271 newlines = []
272 for line in lines:
273 re_net = re.match(r'network\s+([A-Fa-f:.0-9/]+)(.*)$', line)
274 if re_net:
275 addr = re_net.group(1)
276 if '/' not in addr and key[0].startswith('router bgp'):
277 # This is most likely an error because with no
278 # prefixlen, BGP treats the prefixlen as 8
279 addr = addr + '/8'
280
281 try:
282 if 'ipaddress' not in sys.modules:
283 newaddr = IPNetwork(addr)
284 line = 'network %s/%s %s' % (newaddr.network,
285 newaddr.prefixlen,
286 re_net.group(2))
287 else:
288 network_addr = ip_network(addr, strict=False)
289 line = 'network %s/%s %s' % (str(network_addr.network_address),
290 network_addr.prefixlen,
291 re_net.group(2))
292 newlines.append(line)
293 except ValueError:
294 # Really this should be an error. Whats a network
295 # without an IP Address following it ?
296 newlines.append(line)
297 else:
298 newlines.append(line)
299 lines = newlines
300
301 '''
302 More fixups in user specification and what running config shows.
303 "null0" in routes must be replaced by Null0.
304 '''
305 if (key[0].startswith('ip route') or key[0].startswith('ipv6 route') and
306 'null0' in key[0]):
307 key[0] = re.sub(r'\s+null0(\s*$)', ' Null0', key[0])
308
309 if lines:
310 if tuple(key) not in self.contexts:
311 ctx = Context(tuple(key), lines)
312 self.contexts[tuple(key)] = ctx
313 else:
314 ctx = self.contexts[tuple(key)]
315 ctx.add_lines(lines)
316
317 else:
318 if tuple(key) not in self.contexts:
319 ctx = Context(tuple(key), [])
320 self.contexts[tuple(key)] = ctx
321
322 def load_contexts(self):
323 """
324 Parse the configuration and create contexts for each appropriate block
325 """
326
327 current_context_lines = []
328 ctx_keys = []
329
330 '''
331 The end of a context is flagged via the 'end' keyword:
332
333 !
334 interface swp52
335 ipv6 nd suppress-ra
336 link-detect
337 !
338 end
339 router bgp 10
340 bgp router-id 10.0.0.1
341 bgp log-neighbor-changes
342 no bgp default ipv4-unicast
343 neighbor EBGP peer-group
344 neighbor EBGP advertisement-interval 1
345 neighbor EBGP timers connect 10
346 neighbor 2001:40:1:4::6 remote-as 40
347 neighbor 2001:40:1:8::a remote-as 40
348 !
349 end
350 address-family ipv6
351 neighbor IBGPv6 activate
352 neighbor 2001:10::2 peer-group IBGPv6
353 neighbor 2001:10::3 peer-group IBGPv6
354 exit-address-family
355 !
356 end
357 address-family evpn
358 neighbor LEAF activate
359 advertise-all-vni
360 vni 10100
361 rd 65000:10100
362 route-target import 10.1.1.1:10100
363 route-target export 10.1.1.1:10100
364 exit-vni
365 exit-address-family
366 !
367 end
368 router ospf
369 ospf router-id 10.0.0.1
370 log-adjacency-changes detail
371 timers throttle spf 0 50 5000
372 !
373 end
374 '''
375
376 # The code assumes that its working on the output from the "vtysh -m"
377 # command. That provides the appropriate markers to signify end of
378 # a context. This routine uses that to build the contexts for the
379 # config.
380 #
381 # There are single line contexts such as "log file /media/node/zebra.log"
382 # and multi-line contexts such as "router ospf" and subcontexts
383 # within a context such as "address-family" within "router bgp"
384 # In each of these cases, the first line of the context becomes the
385 # key of the context. So "router bgp 10" is the key for the non-address
386 # family part of bgp, "router bgp 10, address-family ipv6 unicast" is
387 # the key for the subcontext and so on.
388 ctx_keys = []
389 main_ctx_key = []
390 new_ctx = True
391
392 # the keywords that we know are single line contexts. bgp in this case
393 # is not the main router bgp block, but enabling multi-instance
394 oneline_ctx_keywords = ("access-list ",
395 "agentx",
396 "bgp ",
397 "debug ",
398 "dump ",
399 "enable ",
400 "frr ",
401 "hostname ",
402 "ip ",
403 "ipv6 ",
404 "log ",
405 "mpls",
406 "no ",
407 "password ",
408 "ptm-enable",
409 "router-id ",
410 "service ",
411 "table ",
412 "username ",
413 "zebra ")
414
415 for line in self.lines:
416
417 if not line:
418 continue
419
420 if line.startswith('!') or line.startswith('#'):
421 continue
422
423 # one line contexts
424 if new_ctx is True and any(line.startswith(keyword) for keyword in oneline_ctx_keywords):
425 self.save_contexts(ctx_keys, current_context_lines)
426
427 # Start a new context
428 main_ctx_key = []
429 ctx_keys = [line, ]
430 current_context_lines = []
431
432 log.debug('LINE %-50s: entering new context, %-50s', line, ctx_keys)
433 self.save_contexts(ctx_keys, current_context_lines)
434 new_ctx = True
435
436 elif line in ["end", "exit-vrf"]:
437 self.save_contexts(ctx_keys, current_context_lines)
438 log.debug('LINE %-50s: exiting old context, %-50s', line, ctx_keys)
439
440 # Start a new context
441 new_ctx = True
442 main_ctx_key = []
443 ctx_keys = []
444 current_context_lines = []
445
446 elif line in ["exit-address-family", "exit", "exit-vnc"]:
447 # if this exit is for address-family ipv4 unicast, ignore the pop
448 if main_ctx_key:
449 self.save_contexts(ctx_keys, current_context_lines)
450
451 # Start a new context
452 ctx_keys = copy.deepcopy(main_ctx_key)
453 current_context_lines = []
454 log.debug('LINE %-50s: popping from subcontext to ctx%-50s', line, ctx_keys)
455
456 elif line == "exit-vni":
457 if sub_main_ctx_key:
458 self.save_contexts(ctx_keys, current_context_lines)
459
460 # Start a new context
461 ctx_keys = copy.deepcopy(sub_main_ctx_key)
462 current_context_lines = []
463 log.debug('LINE %-50s: popping from sub-subcontext to ctx%-50s', line, ctx_keys)
464
465 elif new_ctx is True:
466 if not main_ctx_key:
467 ctx_keys = [line, ]
468 else:
469 ctx_keys = copy.deepcopy(main_ctx_key)
470 main_ctx_key = []
471
472 current_context_lines = []
473 new_ctx = False
474 log.debug('LINE %-50s: entering new context, %-50s', line, ctx_keys)
475 elif (line.startswith("address-family ") or
476 line.startswith("vnc defaults") or
477 line.startswith("vnc l2-group") or
478 line.startswith("vnc nve-group")):
479 main_ctx_key = []
480
481 # Save old context first
482 self.save_contexts(ctx_keys, current_context_lines)
483 current_context_lines = []
484 main_ctx_key = copy.deepcopy(ctx_keys)
485 log.debug('LINE %-50s: entering sub-context, append to ctx_keys', line)
486
487 if line == "address-family ipv6":
488 ctx_keys.append("address-family ipv6 unicast")
489 elif line == "address-family ipv4":
490 ctx_keys.append("address-family ipv4 unicast")
491 elif line == "address-family evpn":
492 ctx_keys.append("address-family l2vpn evpn")
493 else:
494 ctx_keys.append(line)
495
496 elif ((line.startswith("vni ") and
497 len(ctx_keys) == 2 and
498 ctx_keys[0].startswith('router bgp') and
499 ctx_keys[1] == 'address-family l2vpn evpn')):
500
501 # Save old context first
502 self.save_contexts(ctx_keys, current_context_lines)
503 current_context_lines = []
504 sub_main_ctx_key = copy.deepcopy(ctx_keys)
505 log.debug('LINE %-50s: entering sub-sub-context, append to ctx_keys', line)
506 ctx_keys.append(line)
507
508 else:
509 # Continuing in an existing context, add non-commented lines to it
510 current_context_lines.append(line)
511 log.debug('LINE %-50s: append to current_context_lines, %-50s', line, ctx_keys)
512
513 # Save the context of the last one
514 self.save_contexts(ctx_keys, current_context_lines)
515
516
517 def line_to_vtysh_conft(ctx_keys, line, delete):
518 """
519 Return the vtysh command for the specified context line
520 """
521
522 cmd = []
523 cmd.append('vtysh')
524 cmd.append('-c')
525 cmd.append('conf t')
526
527 if line:
528 for ctx_key in ctx_keys:
529 cmd.append('-c')
530 cmd.append(ctx_key)
531
532 line = line.lstrip()
533
534 if delete:
535 cmd.append('-c')
536
537 if line.startswith('no '):
538 cmd.append('%s' % line[3:])
539 else:
540 cmd.append('no %s' % line)
541
542 else:
543 cmd.append('-c')
544 cmd.append(line)
545
546 # If line is None then we are typically deleting an entire
547 # context ('no router ospf' for example)
548 else:
549
550 if delete:
551
552 # Only put the 'no' on the last sub-context
553 for ctx_key in ctx_keys:
554 cmd.append('-c')
555
556 if ctx_key == ctx_keys[-1]:
557 cmd.append('no %s' % ctx_key)
558 else:
559 cmd.append('%s' % ctx_key)
560 else:
561 for ctx_key in ctx_keys:
562 cmd.append('-c')
563 cmd.append(ctx_key)
564
565 return cmd
566
567
568 def line_for_vtysh_file(ctx_keys, line, delete):
569 """
570 Return the command as it would appear in frr.conf
571 """
572 cmd = []
573
574 if line:
575 for (i, ctx_key) in enumerate(ctx_keys):
576 cmd.append(' ' * i + ctx_key)
577
578 line = line.lstrip()
579 indent = len(ctx_keys) * ' '
580
581 if delete:
582 if line.startswith('no '):
583 cmd.append('%s%s' % (indent, line[3:]))
584 else:
585 cmd.append('%sno %s' % (indent, line))
586
587 else:
588 cmd.append(indent + line)
589
590 # If line is None then we are typically deleting an entire
591 # context ('no router ospf' for example)
592 else:
593 if delete:
594
595 # Only put the 'no' on the last sub-context
596 for ctx_key in ctx_keys:
597
598 if ctx_key == ctx_keys[-1]:
599 cmd.append('no %s' % ctx_key)
600 else:
601 cmd.append('%s' % ctx_key)
602 else:
603 for ctx_key in ctx_keys:
604 cmd.append(ctx_key)
605
606 cmd = '\n' + '\n'.join(cmd)
607
608 # There are some commands that are on by default so their "no" form will be
609 # displayed in the config. "no bgp default ipv4-unicast" is one of these.
610 # If we need to remove this line we do so by adding "bgp default ipv4-unicast",
611 # not by doing a "no no bgp default ipv4-unicast"
612 cmd = cmd.replace('no no ', '')
613
614 return cmd
615
616
617 def get_normalized_ipv6_line(line):
618 """
619 Return a normalized IPv6 line as produced by frr,
620 with all letters in lower case and trailing and leading
621 zeros removed, and only the network portion present if
622 the IPv6 word is a network
623 """
624 norm_line = ""
625 words = line.split(' ')
626 for word in words:
627 if ":" in word:
628 norm_word = None
629 if "/" in word:
630 try:
631 if 'ipaddress' not in sys.modules:
632 v6word = IPNetwork(word)
633 norm_word = '%s/%s' % (v6word.network, v6word.prefixlen)
634 else:
635 v6word = ip_network(word, strict=False)
636 norm_word = '%s/%s' % (str(v6word.network_address), v6word.prefixlen)
637 except ValueError:
638 pass
639 if not norm_word:
640 try:
641 norm_word = '%s' % IPv6Address(word)
642 except ValueError:
643 norm_word = word
644 else:
645 norm_word = word
646 norm_line = norm_line + " " + norm_word
647
648 return norm_line.strip()
649
650
651 def line_exist(lines, target_ctx_keys, target_line, exact_match=True):
652 for (ctx_keys, line) in lines:
653 if ctx_keys == target_ctx_keys:
654 if exact_match:
655 if line == target_line:
656 return True
657 else:
658 if line.startswith(target_line):
659 return True
660 return False
661
662
663 def ignore_delete_re_add_lines(lines_to_add, lines_to_del):
664
665 # Quite possibly the most confusing (while accurate) variable names in history
666 lines_to_add_to_del = []
667 lines_to_del_to_del = []
668
669 for (ctx_keys, line) in lines_to_del:
670 deleted = False
671
672 if ctx_keys[0].startswith('router bgp') and line:
673
674 if line.startswith('neighbor '):
675 '''
676 BGP changed how it displays swpX peers that are part of peer-group. Older
677 versions of frr would display these on separate lines:
678 neighbor swp1 interface
679 neighbor swp1 peer-group FOO
680
681 but today we display via a single line
682 neighbor swp1 interface peer-group FOO
683
684 This change confuses frr-reload.py so check to see if we are deleting
685 neighbor swp1 interface peer-group FOO
686
687 and adding
688 neighbor swp1 interface
689 neighbor swp1 peer-group FOO
690
691 If so then chop the del line and the corresponding add lines
692 '''
693
694 re_swpx_int_peergroup = re.search('neighbor (\S+) interface peer-group (\S+)', line)
695 re_swpx_int_v6only_peergroup = re.search('neighbor (\S+) interface v6only peer-group (\S+)', line)
696
697 if re_swpx_int_peergroup or re_swpx_int_v6only_peergroup:
698 swpx_interface = None
699 swpx_peergroup = None
700
701 if re_swpx_int_peergroup:
702 swpx = re_swpx_int_peergroup.group(1)
703 peergroup = re_swpx_int_peergroup.group(2)
704 swpx_interface = "neighbor %s interface" % swpx
705 elif re_swpx_int_v6only_peergroup:
706 swpx = re_swpx_int_v6only_peergroup.group(1)
707 peergroup = re_swpx_int_v6only_peergroup.group(2)
708 swpx_interface = "neighbor %s interface v6only" % swpx
709
710 swpx_peergroup = "neighbor %s peer-group %s" % (swpx, peergroup)
711 found_add_swpx_interface = line_exist(lines_to_add, ctx_keys, swpx_interface)
712 found_add_swpx_peergroup = line_exist(lines_to_add, ctx_keys, swpx_peergroup)
713 tmp_ctx_keys = tuple(list(ctx_keys))
714
715 if not found_add_swpx_peergroup:
716 tmp_ctx_keys = list(ctx_keys)
717 tmp_ctx_keys.append('address-family ipv4 unicast')
718 tmp_ctx_keys = tuple(tmp_ctx_keys)
719 found_add_swpx_peergroup = line_exist(lines_to_add, tmp_ctx_keys, swpx_peergroup)
720
721 if not found_add_swpx_peergroup:
722 tmp_ctx_keys = list(ctx_keys)
723 tmp_ctx_keys.append('address-family ipv6 unicast')
724 tmp_ctx_keys = tuple(tmp_ctx_keys)
725 found_add_swpx_peergroup = line_exist(lines_to_add, tmp_ctx_keys, swpx_peergroup)
726
727 if found_add_swpx_interface and found_add_swpx_peergroup:
728 deleted = True
729 lines_to_del_to_del.append((ctx_keys, line))
730 lines_to_add_to_del.append((ctx_keys, swpx_interface))
731 lines_to_add_to_del.append((tmp_ctx_keys, swpx_peergroup))
732
733 '''
734 We changed how we display the neighbor interface command. Older
735 versions of frr would display the following:
736 neighbor swp1 interface
737 neighbor swp1 remote-as external
738 neighbor swp1 capability extended-nexthop
739
740 but today we display via a single line
741 neighbor swp1 interface remote-as external
742
743 and capability extended-nexthop is no longer needed because we
744 automatically enable it when the neighbor is of type interface.
745
746 This change confuses frr-reload.py so check to see if we are deleting
747 neighbor swp1 interface remote-as (external|internal|ASNUM)
748
749 and adding
750 neighbor swp1 interface
751 neighbor swp1 remote-as (external|internal|ASNUM)
752 neighbor swp1 capability extended-nexthop
753
754 If so then chop the del line and the corresponding add lines
755 '''
756 re_swpx_int_remoteas = re.search('neighbor (\S+) interface remote-as (\S+)', line)
757 re_swpx_int_v6only_remoteas = re.search('neighbor (\S+) interface v6only remote-as (\S+)', line)
758
759 if re_swpx_int_remoteas or re_swpx_int_v6only_remoteas:
760 swpx_interface = None
761 swpx_remoteas = None
762
763 if re_swpx_int_remoteas:
764 swpx = re_swpx_int_remoteas.group(1)
765 remoteas = re_swpx_int_remoteas.group(2)
766 swpx_interface = "neighbor %s interface" % swpx
767 elif re_swpx_int_v6only_remoteas:
768 swpx = re_swpx_int_v6only_remoteas.group(1)
769 remoteas = re_swpx_int_v6only_remoteas.group(2)
770 swpx_interface = "neighbor %s interface v6only" % swpx
771
772 swpx_remoteas = "neighbor %s remote-as %s" % (swpx, remoteas)
773 found_add_swpx_interface = line_exist(lines_to_add, ctx_keys, swpx_interface)
774 found_add_swpx_remoteas = line_exist(lines_to_add, ctx_keys, swpx_remoteas)
775 tmp_ctx_keys = tuple(list(ctx_keys))
776
777 if found_add_swpx_interface and found_add_swpx_remoteas:
778 deleted = True
779 lines_to_del_to_del.append((ctx_keys, line))
780 lines_to_add_to_del.append((ctx_keys, swpx_interface))
781 lines_to_add_to_del.append((tmp_ctx_keys, swpx_remoteas))
782
783 '''
784 We made the 'bgp bestpath as-path multipath-relax' command
785 automatically assume 'no-as-set' since the lack of this option caused
786 weird routing problems. When the running config is shown in
787 releases with this change, the no-as-set keyword is not shown as it
788 is the default. This causes frr-reload to unnecessarily unapply
789 this option only to apply it back again, causing unnecessary session
790 resets.
791 '''
792 if 'multipath-relax' in line:
793 re_asrelax_new = re.search('^bgp\s+bestpath\s+as-path\s+multipath-relax$', line)
794 old_asrelax_cmd = 'bgp bestpath as-path multipath-relax no-as-set'
795 found_asrelax_old = line_exist(lines_to_add, ctx_keys, old_asrelax_cmd)
796
797 if re_asrelax_new and found_asrelax_old:
798 deleted = True
799 lines_to_del_to_del.append((ctx_keys, line))
800 lines_to_add_to_del.append((ctx_keys, old_asrelax_cmd))
801
802 '''
803 If we are modifying the BGP table-map we need to avoid a del/add and
804 instead modify the table-map in place via an add. This is needed to
805 avoid installing all routes in the RIB the second the 'no table-map'
806 is issued.
807 '''
808 if line.startswith('table-map'):
809 found_table_map = line_exist(lines_to_add, ctx_keys, 'table-map', False)
810
811 if found_table_map:
812 lines_to_del_to_del.append((ctx_keys, line))
813
814 '''
815 More old-to-new config handling. ip import-table no longer accepts
816 distance, but we honor the old syntax. But 'show running' shows only
817 the new syntax. This causes an unnecessary 'no import-table' followed
818 by the same old 'ip import-table' which causes perturbations in
819 announced routes leading to traffic blackholes. Fix this issue.
820 '''
821 re_importtbl = re.search('^ip\s+import-table\s+(\d+)$', ctx_keys[0])
822 if re_importtbl:
823 table_num = re_importtbl.group(1)
824 for ctx in lines_to_add:
825 if ctx[0][0].startswith('ip import-table %s distance' % table_num):
826 lines_to_del_to_del.append((('ip import-table %s' % table_num,), None))
827 lines_to_add_to_del.append((ctx[0], None))
828
829 '''
830 ip/ipv6 prefix-list can be specified without a seq number. However,
831 the running config always adds 'seq x', where x is a number incremented
832 by 5 for every element, to the prefix list. So, ignore such lines as
833 well. Sample prefix-list lines:
834 ip prefix-list PR-TABLE-2 seq 5 permit 20.8.2.0/24 le 32
835 ip prefix-list PR-TABLE-2 seq 10 permit 20.8.2.0/24 le 32
836 ipv6 prefix-list vrfdev6-12 permit 2000:9:2::/64 gt 64
837 '''
838 re_ip_pfxlst = re.search('^(ip|ipv6)(\s+prefix-list\s+)(\S+\s+)(seq \d+\s+)(permit|deny)(.*)$',
839 ctx_keys[0])
840 if re_ip_pfxlst:
841 tmpline = (re_ip_pfxlst.group(1) + re_ip_pfxlst.group(2) +
842 re_ip_pfxlst.group(3) + re_ip_pfxlst.group(5) +
843 re_ip_pfxlst.group(6))
844 for ctx in lines_to_add:
845 if ctx[0][0] == tmpline:
846 lines_to_del_to_del.append((ctx_keys, None))
847 lines_to_add_to_del.append(((tmpline,), None))
848
849 if (len(ctx_keys) == 3 and
850 ctx_keys[0].startswith('router bgp') and
851 ctx_keys[1] == 'address-family l2vpn evpn' and
852 ctx_keys[2].startswith('vni')):
853
854 re_route_target = re.search('^route-target import (.*)$', line) if line is not None else False
855
856 if re_route_target:
857 rt = re_route_target.group(1).strip()
858 route_target_import_line = line
859 route_target_export_line = "route-target export %s" % rt
860 route_target_both_line = "route-target both %s" % rt
861
862 found_route_target_export_line = line_exist(lines_to_del, ctx_keys, route_target_export_line)
863 found_route_target_both_line = line_exist(lines_to_add, ctx_keys, route_target_both_line)
864
865 '''
866 If the running configs has
867 route-target import 1:1
868 route-target export 1:1
869
870 and the config we are reloading against has
871 route-target both 1:1
872
873 then we can ignore deleting the import/export and ignore adding the 'both'
874 '''
875 if found_route_target_export_line and found_route_target_both_line:
876 lines_to_del_to_del.append((ctx_keys, route_target_import_line))
877 lines_to_del_to_del.append((ctx_keys, route_target_export_line))
878 lines_to_add_to_del.append((ctx_keys, route_target_both_line))
879
880 if not deleted:
881 found_add_line = line_exist(lines_to_add, ctx_keys, line)
882
883 if found_add_line:
884 lines_to_del_to_del.append((ctx_keys, line))
885 lines_to_add_to_del.append((ctx_keys, line))
886 else:
887 '''
888 We have commands that used to be displayed in the global part
889 of 'router bgp' that are now displayed under 'address-family ipv4 unicast'
890
891 # old way
892 router bgp 64900
893 neighbor ISL advertisement-interval 0
894
895 vs.
896
897 # new way
898 router bgp 64900
899 address-family ipv4 unicast
900 neighbor ISL advertisement-interval 0
901
902 Look to see if we are deleting it in one format just to add it back in the other
903 '''
904 if ctx_keys[0].startswith('router bgp') and len(ctx_keys) > 1 and ctx_keys[1] == 'address-family ipv4 unicast':
905 tmp_ctx_keys = list(ctx_keys)[:-1]
906 tmp_ctx_keys = tuple(tmp_ctx_keys)
907
908 found_add_line = line_exist(lines_to_add, tmp_ctx_keys, line)
909
910 if found_add_line:
911 lines_to_del_to_del.append((ctx_keys, line))
912 lines_to_add_to_del.append((tmp_ctx_keys, line))
913
914 for (ctx_keys, line) in lines_to_del_to_del:
915 lines_to_del.remove((ctx_keys, line))
916
917 for (ctx_keys, line) in lines_to_add_to_del:
918 lines_to_add.remove((ctx_keys, line))
919
920 return (lines_to_add, lines_to_del)
921
922
923 def ignore_unconfigurable_lines(lines_to_add, lines_to_del):
924 """
925 There are certain commands that cannot be removed. Remove
926 those commands from lines_to_del.
927 """
928 lines_to_del_to_del = []
929
930 for (ctx_keys, line) in lines_to_del:
931
932 if (ctx_keys[0].startswith('frr version') or
933 ctx_keys[0].startswith('frr defaults') or
934 ctx_keys[0].startswith('password') or
935 ctx_keys[0].startswith('line vty') or
936
937 # This is technically "no"able but if we did so frr-reload would
938 # stop working so do not let the user shoot themselves in the foot
939 # by removing this.
940 ctx_keys[0].startswith('service integrated-vtysh-config')):
941
942 log.info("(%s, %s) cannot be removed" % (pformat(ctx_keys), line))
943 lines_to_del_to_del.append((ctx_keys, line))
944
945 for (ctx_keys, line) in lines_to_del_to_del:
946 lines_to_del.remove((ctx_keys, line))
947
948 return (lines_to_add, lines_to_del)
949
950
951 def compare_context_objects(newconf, running):
952 """
953 Create a context diff for the two specified contexts
954 """
955
956 # Compare the two Config objects to find the lines that we need to add/del
957 lines_to_add = []
958 lines_to_del = []
959 delete_bgpd = False
960
961 # Find contexts that are in newconf but not in running
962 # Find contexts that are in running but not in newconf
963 for (running_ctx_keys, running_ctx) in iteritems(running.contexts):
964
965 if running_ctx_keys not in newconf.contexts:
966
967 # We check that the len is 1 here so that we only look at ('router bgp 10')
968 # and not ('router bgp 10', 'address-family ipv4 unicast'). The
969 # latter could cause a false delete_bgpd positive if ipv4 unicast is in
970 # running but not in newconf.
971 if "router bgp" in running_ctx_keys[0] and len(running_ctx_keys) == 1:
972 delete_bgpd = True
973 lines_to_del.append((running_ctx_keys, None))
974
975 # We cannot do 'no interface' or 'no vrf' in FRR, and so deal with it
976 elif running_ctx_keys[0].startswith('interface') or running_ctx_keys[0].startswith('vrf'):
977 for line in running_ctx.lines:
978 lines_to_del.append((running_ctx_keys, line))
979
980 # If this is an address-family under 'router bgp' and we are already deleting the
981 # entire 'router bgp' context then ignore this sub-context
982 elif "router bgp" in running_ctx_keys[0] and len(running_ctx_keys) > 1 and delete_bgpd:
983 continue
984
985 # Delete an entire vni sub-context under "address-family l2vpn evpn"
986 elif ("router bgp" in running_ctx_keys[0] and
987 len(running_ctx_keys) > 2 and
988 running_ctx_keys[1].startswith('address-family l2vpn evpn') and
989 running_ctx_keys[2].startswith('vni ')):
990 lines_to_del.append((running_ctx_keys, None))
991
992 elif ("router bgp" in running_ctx_keys[0] and
993 len(running_ctx_keys) > 1 and
994 running_ctx_keys[1].startswith('address-family')):
995 # There's no 'no address-family' support and so we have to
996 # delete each line individually again
997 for line in running_ctx.lines:
998 lines_to_del.append((running_ctx_keys, line))
999
1000 # Non-global context
1001 elif running_ctx_keys and not any("address-family" in key for key in running_ctx_keys):
1002 lines_to_del.append((running_ctx_keys, None))
1003
1004 elif running_ctx_keys and not any("vni" in key for key in running_ctx_keys):
1005 lines_to_del.append((running_ctx_keys, None))
1006
1007 # Global context
1008 else:
1009 for line in running_ctx.lines:
1010 lines_to_del.append((running_ctx_keys, line))
1011
1012 # Find the lines within each context to add
1013 # Find the lines within each context to del
1014 for (newconf_ctx_keys, newconf_ctx) in iteritems(newconf.contexts):
1015
1016 if newconf_ctx_keys in running.contexts:
1017 running_ctx = running.contexts[newconf_ctx_keys]
1018
1019 for line in newconf_ctx.lines:
1020 if line not in running_ctx.dlines:
1021 lines_to_add.append((newconf_ctx_keys, line))
1022
1023 for line in running_ctx.lines:
1024 if line not in newconf_ctx.dlines:
1025 lines_to_del.append((newconf_ctx_keys, line))
1026
1027 for (newconf_ctx_keys, newconf_ctx) in iteritems(newconf.contexts):
1028
1029 if newconf_ctx_keys not in running.contexts:
1030 lines_to_add.append((newconf_ctx_keys, None))
1031
1032 for line in newconf_ctx.lines:
1033 lines_to_add.append((newconf_ctx_keys, line))
1034
1035 (lines_to_add, lines_to_del) = ignore_delete_re_add_lines(lines_to_add, lines_to_del)
1036 (lines_to_add, lines_to_del) = ignore_unconfigurable_lines(lines_to_add, lines_to_del)
1037
1038 return (lines_to_add, lines_to_del)
1039
1040
1041
1042 def vtysh_config_available():
1043 """
1044 Return False if no frr daemon is running or some other vtysh session is
1045 in 'configuration terminal' mode which will prevent us from making any
1046 configuration changes.
1047 """
1048
1049 try:
1050 cmd = ['/usr/bin/vtysh', '-c', 'conf t']
1051 output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).strip()
1052
1053 if 'VTY configuration is locked by other VTY' in output.decode('utf-8'):
1054 print(output)
1055 log.error("'%s' returned\n%s\n" % (' '.join(cmd), output))
1056 return False
1057
1058 except subprocess.CalledProcessError as e:
1059 msg = "vtysh could not connect with any frr daemons"
1060 print(msg)
1061 log.error(msg)
1062 return False
1063
1064 return True
1065
1066
1067 if __name__ == '__main__':
1068 # Command line options
1069 parser = argparse.ArgumentParser(description='Dynamically apply diff in frr configs')
1070 parser.add_argument('--input', help='Read running config from file instead of "show running"')
1071 group = parser.add_mutually_exclusive_group(required=True)
1072 group.add_argument('--reload', action='store_true', help='Apply the deltas', default=False)
1073 group.add_argument('--test', action='store_true', help='Show the deltas', default=False)
1074 parser.add_argument('--debug', action='store_true', help='Enable debugs', default=False)
1075 parser.add_argument('--stdout', action='store_true', help='Log to STDOUT', default=False)
1076 parser.add_argument('filename', help='Location of new frr config file')
1077 parser.add_argument('--overwrite', action='store_true', help='Overwrite frr.conf with running config output', default=False)
1078 args = parser.parse_args()
1079
1080 # Logging
1081 # For --test log to stdout
1082 # For --reload log to /var/log/frr/frr-reload.log
1083 if args.test or args.stdout:
1084 logging.basicConfig(level=logging.INFO,
1085 format='%(asctime)s %(levelname)5s: %(message)s')
1086
1087 # Color the errors and warnings in red
1088 logging.addLevelName(logging.ERROR, "\033[91m %s\033[0m" % logging.getLevelName(logging.ERROR))
1089 logging.addLevelName(logging.WARNING, "\033[91m%s\033[0m" % logging.getLevelName(logging.WARNING))
1090
1091 elif args.reload:
1092 if not os.path.isdir('/var/log/frr/'):
1093 os.makedirs('/var/log/frr/')
1094
1095 logging.basicConfig(filename='/var/log/frr/frr-reload.log',
1096 level=logging.INFO,
1097 format='%(asctime)s %(levelname)5s: %(message)s')
1098
1099 # argparse should prevent this from happening but just to be safe...
1100 else:
1101 raise Exception('Must specify --reload or --test')
1102 log = logging.getLogger(__name__)
1103
1104 # Verify the new config file is valid
1105 if not os.path.isfile(args.filename):
1106 msg = "Filename %s does not exist" % args.filename
1107 print(msg)
1108 log.error(msg)
1109 sys.exit(1)
1110
1111 if not os.path.getsize(args.filename):
1112 msg = "Filename %s is an empty file" % args.filename
1113 print(msg)
1114 log.error(msg)
1115 sys.exit(1)
1116
1117 # Verify that 'service integrated-vtysh-config' is configured
1118 vtysh_filename = '/etc/frr/vtysh.conf'
1119 service_integrated_vtysh_config = True
1120
1121 if os.path.isfile(vtysh_filename):
1122 with open(vtysh_filename, 'r') as fh:
1123 for line in fh.readlines():
1124 line = line.strip()
1125
1126 if line == 'no service integrated-vtysh-config':
1127 service_integrated_vtysh_config = False
1128 break
1129
1130 if not service_integrated_vtysh_config:
1131 msg = "'service integrated-vtysh-config' is not configured, this is required for 'service frr reload'"
1132 print(msg)
1133 log.error(msg)
1134 sys.exit(1)
1135
1136 if args.debug:
1137 log.setLevel(logging.DEBUG)
1138
1139 log.info('Called via "%s"', str(args))
1140
1141 # Create a Config object from the config generated by newconf
1142 newconf = Config()
1143 newconf.load_from_file(args.filename)
1144 reload_ok = True
1145
1146 if args.test:
1147
1148 # Create a Config object from the running config
1149 running = Config()
1150
1151 if args.input:
1152 running.load_from_file(args.input)
1153 else:
1154 running.load_from_show_running()
1155
1156 (lines_to_add, lines_to_del) = compare_context_objects(newconf, running)
1157 lines_to_configure = []
1158
1159 if lines_to_del:
1160 print("\nLines To Delete")
1161 print("===============")
1162
1163 for (ctx_keys, line) in lines_to_del:
1164
1165 if line == '!':
1166 continue
1167
1168 cmd = line_for_vtysh_file(ctx_keys, line, True)
1169 lines_to_configure.append(cmd)
1170 print(cmd)
1171
1172 if lines_to_add:
1173 print("\nLines To Add")
1174 print("============")
1175
1176 for (ctx_keys, line) in lines_to_add:
1177
1178 if line == '!':
1179 continue
1180
1181 cmd = line_for_vtysh_file(ctx_keys, line, False)
1182 lines_to_configure.append(cmd)
1183 print(cmd)
1184
1185 elif args.reload:
1186
1187 # We will not be able to do anything, go ahead and exit(1)
1188 if not vtysh_config_available():
1189 sys.exit(1)
1190
1191 log.debug('New Frr Config\n%s', newconf.get_lines())
1192
1193 # This looks a little odd but we have to do this twice...here is why
1194 # If the user had this running bgp config:
1195 #
1196 # router bgp 10
1197 # neighbor 1.1.1.1 remote-as 50
1198 # neighbor 1.1.1.1 route-map FOO out
1199 #
1200 # and this config in the newconf config file
1201 #
1202 # router bgp 10
1203 # neighbor 1.1.1.1 remote-as 999
1204 # neighbor 1.1.1.1 route-map FOO out
1205 #
1206 #
1207 # Then the script will do
1208 # - no neighbor 1.1.1.1 remote-as 50
1209 # - neighbor 1.1.1.1 remote-as 999
1210 #
1211 # The problem is the "no neighbor 1.1.1.1 remote-as 50" will also remove
1212 # the "neighbor 1.1.1.1 route-map FOO out" line...so we compare the
1213 # configs again to put this line back.
1214
1215 # There are many keywords in FRR that can only appear one time under
1216 # a context, take "bgp router-id" for example. If the config that we are
1217 # reloading against has the following:
1218 #
1219 # router bgp 10
1220 # bgp router-id 1.1.1.1
1221 # bgp router-id 2.2.2.2
1222 #
1223 # The final config needs to contain "bgp router-id 2.2.2.2". On the
1224 # first pass we will add "bgp router-id 2.2.2.2" but then on the second
1225 # pass we will see that "bgp router-id 1.1.1.1" is missing and add that
1226 # back which cancels out the "bgp router-id 2.2.2.2". The fix is for the
1227 # second pass to include all of the "adds" from the first pass.
1228 lines_to_add_first_pass = []
1229
1230 for x in range(2):
1231 running = Config()
1232 running.load_from_show_running()
1233 log.debug('Running Frr Config (Pass #%d)\n%s', x, running.get_lines())
1234
1235 (lines_to_add, lines_to_del) = compare_context_objects(newconf, running)
1236
1237 if x == 0:
1238 lines_to_add_first_pass = lines_to_add
1239 else:
1240 lines_to_add.extend(lines_to_add_first_pass)
1241
1242 # Only do deletes on the first pass. The reason being if we
1243 # configure a bgp neighbor via "neighbor swp1 interface" FRR
1244 # will automatically add:
1245 #
1246 # interface swp1
1247 # ipv6 nd ra-interval 10
1248 # no ipv6 nd suppress-ra
1249 # !
1250 #
1251 # but those lines aren't in the config we are reloading against so
1252 # on the 2nd pass they will show up in lines_to_del. This could
1253 # apply to other scenarios as well where configuring FOO adds BAR
1254 # to the config.
1255 if lines_to_del and x == 0:
1256 for (ctx_keys, line) in lines_to_del:
1257
1258 if line == '!':
1259 continue
1260
1261 # 'no' commands are tricky, we can't just put them in a file and
1262 # vtysh -f that file. See the next comment for an explanation
1263 # of their quirks
1264 cmd = line_to_vtysh_conft(ctx_keys, line, True)
1265 original_cmd = cmd
1266
1267 # Some commands in frr are picky about taking a "no" of the entire line.
1268 # OSPF is bad about this, you can't "no" the entire line, you have to "no"
1269 # only the beginning. If we hit one of these command an exception will be
1270 # thrown. Catch it and remove the last '-c', 'FOO' from cmd and try again.
1271 #
1272 # Example:
1273 # frr(config-if)# ip ospf authentication message-digest 1.1.1.1
1274 # frr(config-if)# no ip ospf authentication message-digest 1.1.1.1
1275 # % Unknown command.
1276 # frr(config-if)# no ip ospf authentication message-digest
1277 # % Unknown command.
1278 # frr(config-if)# no ip ospf authentication
1279 # frr(config-if)#
1280
1281 while True:
1282 try:
1283 _ = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
1284
1285 except subprocess.CalledProcessError:
1286
1287 # - Pull the last entry from cmd (this would be
1288 # 'no ip ospf authentication message-digest 1.1.1.1' in
1289 # our example above
1290 # - Split that last entry by whitespace and drop the last word
1291 log.info('Failed to execute %s', ' '.join(cmd))
1292 last_arg = cmd[-1].split(' ')
1293
1294 if len(last_arg) <= 2:
1295 log.error('"%s" we failed to remove this command', original_cmd)
1296 break
1297
1298 new_last_arg = last_arg[0:-1]
1299 cmd[-1] = ' '.join(new_last_arg)
1300 else:
1301 log.info('Executed "%s"', ' '.join(cmd))
1302 break
1303
1304 if lines_to_add:
1305 lines_to_configure = []
1306
1307 for (ctx_keys, line) in lines_to_add:
1308
1309 if line == '!':
1310 continue
1311
1312 cmd = line_for_vtysh_file(ctx_keys, line, False)
1313 lines_to_configure.append(cmd)
1314
1315 if lines_to_configure:
1316 random_string = ''.join(random.SystemRandom().choice(
1317 string.ascii_uppercase +
1318 string.digits) for _ in range(6))
1319
1320 filename = "/var/run/frr/reload-%s.txt" % random_string
1321 log.info("%s content\n%s" % (filename, pformat(lines_to_configure)))
1322
1323 with open(filename, 'w') as fh:
1324 for line in lines_to_configure:
1325 fh.write(line + '\n')
1326
1327 try:
1328 subprocess.check_output(['/usr/bin/vtysh', '-f', filename], stderr=subprocess.STDOUT)
1329 except subprocess.CalledProcessError as e:
1330 log.warning("frr-reload.py failed due to\n%s" % e.output)
1331 reload_ok = False
1332 os.unlink(filename)
1333
1334 # Make these changes persistent
1335 if args.overwrite or args.filename != '/etc/frr/frr.conf':
1336 subprocess.call(['/usr/bin/vtysh', '-c', 'write'])
1337
1338 if not reload_ok:
1339 sys.exit(1)