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