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