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