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