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