]> git.proxmox.com Git - mirror_frr.git/blame - tools/quagga-reload.py
Merge branch 'cmaster' of ssh://stash.cumulusnetworks.com:7999/quag/quagga into cmaster
[mirror_frr.git] / tools / quagga-reload.py
CommitLineData
2fc76430
DS
1#!/usr/bin/python
2
3"""
4This program
5- reads a quagga configuration text file
6- reads quagga's current running configuration via "vtysh -c 'show running'"
7- compares the two configs and determines what commands to execute to
8 synchronize quagga's running configuration with the configuation in the
9 text file
10"""
11
12import argparse
13import copy
14import logging
15import os
16import subprocess
17import sys
18from collections import OrderedDict
19from ipaddr import IPv6Address
20
21class Context(object):
22 """
23 A Context object represents a section of quagga configuration such as:
24!
25interface swp3
26 description swp3 -> r8's swp1
27 ipv6 nd suppress-ra
28 link-detect
29!
30
31or a single line context object such as this:
32
33ip forwarding
34
35 """
36
37 def __init__(self, keys, lines):
38 self.keys = keys
39 self.lines = lines
40
41 # Keep a dictionary of the lines, this is to make it easy to tell if a
42 # line exists in this Context
43 self.dlines = OrderedDict()
44
45 for ligne in lines:
46 self.dlines[ligne] = True
47
48 def add_lines(self, lines):
49 """
50 Add lines to specified context
51 """
52
53 self.lines.extend(lines)
54
55 for ligne in lines:
56 self.dlines[ligne] = True
57
58
59class Config(object):
60 """
61 A quagga configuration is stored in a Config object. A Config object
62 contains a dictionary of Context objects where the Context keys
63 ('router ospf' for example) are our dictionary key.
64 """
65
66 def __init__(self):
67 self.lines = []
68 self.contexts = OrderedDict()
69
70 def load_from_file(self, filename):
71 """
72 Read configuration from specified file and slurp it into internal memory
73 The internal representation has been marked appropriately by passing it
74 through vtysh with the -m parameter
75 """
76f69d1c 76 logger.debug('Loading Config object from file %s', filename)
2fc76430
DS
77
78 try:
79 file_output = subprocess.check_output(['vtysh', '-m', '-f', filename])
80 except subprocess.CalledProcessError as e:
81 logger.error('vtysh marking of config file %s failed with error %s:', filename, str(e))
82 print "vtysh marking of file %s failed with error: %s" %(filename, str(e))
83 sys.exit(1)
84
85 for line in file_output.split('\n'):
86 line = line.strip()
87 if ":" in line:
88 qv6_line = get_normalized_ipv6_line(line)
89 self.lines.append(qv6_line)
90 else:
91 self.lines.append(line)
92
93 self.load_contexts()
94
95 def load_from_show_running(self):
96 """
97 Read running configuration and slurp it into internal memory
98 The internal representation has been marked appropriately by passing it
99 through vtysh with the -m parameter
100 """
76f69d1c 101 logger.debug('Loading Config object from vtysh show running')
2fc76430
DS
102
103 try:
104 config_text = subprocess.check_output("vtysh -c 'show run' | tail -n +4 | vtysh -m -f -", shell=True)
105 except subprocess.CalledProcessError as e:
106 logger.error('vtysh marking of running config failed with error %s:', str(e))
107 print "vtysh marking of running config failed with error %s:" %(str(e))
108 sys.exit(1)
109
110
111 for line in config_text.split('\n'):
112 line = line.strip()
113
114 if (line == 'Building configuration...' or
115 line == 'Current configuration:' or
116 not line):
117 continue
118
119 self.lines.append(line)
120
121 self.load_contexts()
122
123 def get_lines(self):
124 """
125 Return the lines read in from the configuration
126 """
127
128 return '\n'.join(self.lines)
129
130 def get_contexts(self):
131 """
132 Return the parsed context as strings for display, log etc.
133 """
134
135 for (_, ctx) in sorted(self.contexts.iteritems()):
136 print str(ctx) + '\n'
137
138 def save_contexts(self, key, lines):
139 """
140 Save the provided key and lines as a context
141 """
142
143 if not key:
144 return
145
146 if lines:
147 if tuple(key) not in self.contexts:
148 ctx = Context(tuple(key), lines)
149 self.contexts[tuple(key)] = ctx
150 else:
151 ctx = self.contexts[tuple(key)]
152 ctx.add_lines(lines)
153
154 else:
155 if tuple(key) not in self.contexts:
156 ctx = Context(tuple(key), [])
157 self.contexts[tuple(key)] = ctx
158
159 def load_contexts(self):
160 """
161 Parse the configuration and create contexts for each appropriate block
162 """
163
164 current_context_lines = []
165 ctx_keys = []
166
167 '''
168 The end of a context is flagged via the 'end' keyword:
169
170!
171interface swp52
172 ipv6 nd suppress-ra
173 link-detect
174!
175end
176router bgp 10
177 bgp router-id 10.0.0.1
178 bgp log-neighbor-changes
179 no bgp default ipv4-unicast
180 neighbor EBGP peer-group
181 neighbor EBGP advertisement-interval 1
182 neighbor EBGP timers connect 10
183 neighbor 2001:40:1:4::6 remote-as 40
184 neighbor 2001:40:1:8::a remote-as 40
185!
186end
187 address-family ipv6
188 neighbor IBGPv6 activate
189 neighbor 2001:10::2 peer-group IBGPv6
190 neighbor 2001:10::3 peer-group IBGPv6
191 exit-address-family
192!
193end
194router ospf
195 ospf router-id 10.0.0.1
196 log-adjacency-changes detail
197 timers throttle spf 0 50 5000
198!
199end
200 '''
201
202 # The code assumes that its working on the output from the "vtysh -m"
203 # command. That provides the appropriate markers to signify end of
204 # a context. This routine uses that to build the contexts for the
205 # config.
206 #
207 # There are single line contexts such as "log file /media/node/zebra.log"
208 # and multi-line contexts such as "router ospf" and subcontexts
209 # within a context such as "address-family" within "router bgp"
210 # In each of these cases, the first line of the context becomes the
211 # key of the context. So "router bgp 10" is the key for the non-address
212 # family part of bgp, "router bgp 10, address-family ipv6 unicast" is
213 # the key for the subcontext and so on.
214
215 ctx_keys = []
216 main_ctx_key = []
217 new_ctx = True
218 number_of_lines = len(self.lines)
219
220 # the keywords that we know are single line contexts. bgp in this case
221 # is not the main router bgp block, but enabling multi-instance
222 oneline_ctx_keywords = ("ip ", "ipv6 ", "log ", "hostname ", "zebra ", "ptm-enable", "debug ", "service ", "enable ", "password ", "access-list ", "bgp ")
223
224 for line in self.lines:
225
226 if not line:
227 continue
228
229 if line.startswith('!') or line.startswith('#'):
230 continue
231
232 # one line contexts
233 if new_ctx is True and any(line.startswith(keyword) for keyword in oneline_ctx_keywords):
234 self.save_contexts(ctx_keys, current_context_lines)
235
236 # Start a new context
237 main_ctx_key = []
238 ctx_keys = [line,]
239 current_context_lines = []
240
76f69d1c 241 logger.debug('LINE %-50s: entering new context, %-50s', line, ctx_keys)
2fc76430
DS
242 self.save_contexts(ctx_keys, current_context_lines)
243 new_ctx = True
244
245 elif line == "end":
246 self.save_contexts(ctx_keys, current_context_lines)
76f69d1c 247 logger.debug('LINE %-50s: exiting old context, %-50s', line, ctx_keys)
2fc76430
DS
248
249 # Start a new context
250 new_ctx = True
251 main_ctx_key = []
252 ctx_keys = []
253 current_context_lines = []
254
255 elif line == "exit-address-family" or line == "exit":
256 # if this exit is for address-family ipv4 unicast, ignore the pop
257 if main_ctx_key:
258 self.save_contexts(ctx_keys, current_context_lines)
259
260 # Start a new context
261 ctx_keys = copy.deepcopy(main_ctx_key)
262 current_context_lines = []
76f69d1c 263 logger.debug('LINE %-50s: popping from subcontext to ctx%-50s', line, ctx_keys)
2fc76430
DS
264
265 elif new_ctx is True:
266 if not main_ctx_key:
267 ctx_keys = [line,]
268 else:
269 ctx_keys = copy.deepcopy(main_ctx_key)
270 main_ctx_key = []
271
272 current_context_lines = []
273 new_ctx = False
76f69d1c 274 logger.debug('LINE %-50s: entering new context, %-50s', line, ctx_keys)
2fc76430
DS
275
276 elif "address-family " in line:
277 main_ctx_key = []
278
279 if line != "address-family ipv4 unicast":
280 # Save old context first
281 self.save_contexts(ctx_keys, current_context_lines)
282 current_context_lines = []
283 main_ctx_key = copy.deepcopy(ctx_keys)
76f69d1c 284 logger.debug('LINE %-50s: entering sub-context, append to ctx_keys', line)
2fc76430 285
2fc76430
DS
286 if line == "address-family ipv6":
287 ctx_keys.append("address-family ipv6 unicast")
288 else:
289 ctx_keys.append(line)
290
291 else:
292 # Continuing in an existing context, add non-commented lines to it
293 current_context_lines.append(line)
76f69d1c 294 logger.debug('LINE %-50s: append to current_context_lines, %-50s', line, ctx_keys)
2fc76430
DS
295
296 # Save the context of the last one
297 self.save_contexts(ctx_keys, current_context_lines)
298
299def line_to_vtysh_conft(ctx_keys, line, delete):
300 """
301 Spit out the vtysh command for the specified context line
302 """
303
304 cmd = []
305 cmd.append('vtysh')
306 cmd.append('-c')
307 cmd.append('conf t')
308
309 if line:
310 for ctx_key in ctx_keys:
311 cmd.append('-c')
312 cmd.append(ctx_key)
313
314 line = line.lstrip()
315
316 if delete:
317 cmd.append('-c')
318
319 if line.startswith('no '):
320 cmd.append('%s' % line[3:])
321 else:
322 cmd.append('no %s' % line)
323
324 else:
325 cmd.append('-c')
326 cmd.append(line)
327
328 # If line is None then we are typically deleting an entire
329 # context ('no router ospf' for example)
330 else:
331
332 if delete:
333
334 # Only put the 'no' on the last sub-context
335 for ctx_key in ctx_keys:
336 cmd.append('-c')
337
338 if ctx_key == ctx_keys[-1]:
339 cmd.append('no %s' % ctx_key)
340 else:
341 cmd.append('%s' % ctx_key)
342 else:
343 for ctx_key in ctx_keys:
344 cmd.append('-c')
345 cmd.append(ctx_key)
346
347 return cmd
348
349def get_normalized_ipv6_line(line):
350 """
351 Return a normalized IPv6 line as produced by quagga,
352 with all letters in lower case and trailing and leading
353 zeros removed
354 """
355 norm_line = ""
356 words = line.split(' ')
357 for word in words:
358 if ":" in word:
359 try:
360 norm_word = str(IPv6Address(word)).lower()
361 except:
362 norm_word = word
363 else:
364 norm_word = word
365 norm_line = norm_line + " " + norm_word
366
367 return norm_line.strip()
368
369def compare_context_objects(newconf, running):
370 """
371 Create a context diff for the two specified contexts
372 """
373
374 # Compare the two Config objects to find the lines that we need to add/del
375 lines_to_add = []
376 lines_to_del = []
514665b9 377 restart_bgpd = False
2fc76430
DS
378
379 # Find contexts that are in newconf but not in running
380 # Find contexts that are in running but not in newconf
381 for (running_ctx_keys, running_ctx) in running.contexts.iteritems():
382
383 if running_ctx_keys not in newconf.contexts:
384
76f69d1c
DW
385 # Check if bgp's local ASN has changed. If yes, just restart it
386 if "router bgp" in running_ctx_keys[0]:
387 restart_bgpd = True
388 continue
514665b9 389
2fc76430 390 # Non-global context
514665b9 391 if running_ctx_keys and not any("address-family" in key for key in running_ctx_keys):
2fc76430
DS
392 lines_to_del.append((running_ctx_keys, None))
393
394 # Global context
395 else:
396 for line in running_ctx.lines:
397 lines_to_del.append((running_ctx_keys, line))
398
399 # Find the lines within each context to add
400 # Find the lines within each context to del
401 for (newconf_ctx_keys, newconf_ctx) in newconf.contexts.iteritems():
402
403 if newconf_ctx_keys in running.contexts:
404 running_ctx = running.contexts[newconf_ctx_keys]
405
406 for line in newconf_ctx.lines:
407 if line not in running_ctx.dlines:
408 lines_to_add.append((newconf_ctx_keys, line))
409
410 for line in running_ctx.lines:
411 if line not in newconf_ctx.dlines:
412 lines_to_del.append((newconf_ctx_keys, line))
413
414 for (newconf_ctx_keys, newconf_ctx) in newconf.contexts.iteritems():
415
416 if newconf_ctx_keys not in running.contexts:
514665b9 417
76f69d1c
DW
418 # If its "router bgp" and we're restarting bgp, skip doing
419 # anything specific for bgp
420 if "router bgp" in newconf_ctx_keys[0] and restart_bgpd:
421 continue
2fc76430
DS
422 lines_to_add.append((newconf_ctx_keys, None))
423
424 for line in newconf_ctx.lines:
425 lines_to_add.append((newconf_ctx_keys, line))
426
514665b9 427 return (lines_to_add, lines_to_del, restart_bgpd)
2fc76430
DS
428
429if __name__ == '__main__':
430 # Command line options
431 parser = argparse.ArgumentParser(description='Dynamically apply diff in quagga configs')
432 parser.add_argument('--input', help='Read running config from file instead of "show running"')
433 group = parser.add_mutually_exclusive_group(required=True)
434 group.add_argument('--reload', action='store_true', help='Apply the deltas', default=False)
435 group.add_argument('--test', action='store_true', help='Show the deltas', default=False)
436 parser.add_argument('--debug', action='store_true', help='Enable debugs', default=False)
437 parser.add_argument('filename', help='Location of new quagga config file')
438 args = parser.parse_args()
439
440 # Logging
441 # For --test log to stdout
442 # For --reload log to /var/log/quagga/quagga-reload.log
443 if args.test:
444 logging.basicConfig(level=logging.DEBUG,
445 format='%(asctime)s %(levelname)5s: %(message)s')
446 elif args.reload:
447 if not os.path.isdir('/var/log/quagga/'):
448 os.makedirs('/var/log/quagga/')
449
450 logging.basicConfig(filename='/var/log/quagga/quagga-reload.log',
451 level=logging.DEBUG,
452 format='%(asctime)s %(levelname)5s: %(message)s')
453
454 # argparse should prevent this from happening but just to be safe...
455 else:
456 raise Exception('Must specify --reload or --test')
457 logger = logging.getLogger(__name__)
458
76f69d1c
DW
459 # Verify the new config file is valid
460 if not os.path.isfile(args.filename):
461 print "Filename %s does not exist" % args.filename
462 sys.exit(1)
463
464 if not os.path.getsize(args.filename):
465 print "Filename %s is an empty file" % args.filename
466 sys.exit(1)
467
468
469 # Verify that 'service integrated-vtysh-config' is configured
470 vtysh_filename = '/etc/quagga/vtysh.conf'
76f69d1c
DW
471 service_integrated_vtysh_config = False
472
f850d14d
DW
473 if os.path.isfile(vtysh_filename):
474 with open(vtysh_filename, 'r') as fh:
475 for line in fh.readlines():
476 line = line.strip()
76f69d1c 477
f850d14d
DW
478 if line == 'service integrated-vtysh-config':
479 service_integrated_vtysh_config = True
480 break
76f69d1c
DW
481
482 if not service_integrated_vtysh_config:
483 print "'service integrated-vtysh-config' is not configured, this is required for 'service quagga reload'"
484 sys.exit(1)
2fc76430
DS
485
486 # Create a Config object from the config generated by newconf
487 newconf = Config()
488 newconf.load_from_file(args.filename)
2fc76430
DS
489
490 if args.test:
491
492 # Create a Config object from the running config
493 running = Config()
494
495 if args.input:
496 running.load_from_file(args.input)
497 else:
498 running.load_from_show_running()
499
514665b9 500 (lines_to_add, lines_to_del, restart_bgp) = compare_context_objects(newconf, running)
2fc76430
DS
501
502 if lines_to_del:
503 print "\nLines To Delete"
504 print "==============="
505
506 for (ctx_keys, line) in lines_to_del:
507
508 if line == '!':
509 continue
510
511 cmd = line_to_vtysh_conft(ctx_keys, line, True)
512 print cmd
513
514 if lines_to_add:
515 print "\nLines To Add"
516 print "============"
517
518 for (ctx_keys, line) in lines_to_add:
519
520 if line == '!':
521 continue
522
523 cmd = line_to_vtysh_conft(ctx_keys, line, False)
524 print cmd
525
76f69d1c
DW
526 if restart_bgp:
527 print "BGP local AS changed, restarting bgpd\n"
2fc76430
DS
528
529 elif args.reload:
530
531 logger.debug('Called via "%s"', str(args))
532 logger.info('New Quagga Config\n%s', newconf.get_lines())
533
534 # This looks a little odd but we have to do this twice...here is why
535 # If the user had this running bgp config:
536
537 # router bgp 10
538 # neighbor 1.1.1.1 remote-as 50
539 # neighbor 1.1.1.1 route-map FOO out
540
541 # and this config in the newconf config file
542
543 # router bgp 10
544 # neighbor 1.1.1.1 remote-as 999
545 # neighbor 1.1.1.1 route-map FOO out
546
547
548 # Then the script will do
549 # - no neighbor 1.1.1.1 remote-as 50
550 # - neighbor 1.1.1.1 remote-as 999
551
552 # The problem is the "no neighbor 1.1.1.1 remote-as 50" will also remove
553 # the "neighbor 1.1.1.1 route-map FOO out" line...so we compare the
554 # configs again to put this line back.
555
556 for x in range(2):
557 running = Config()
558 running.load_from_show_running()
559 logger.info('Running Quagga Config (Pass #%d)\n%s', x, running.get_lines())
560
514665b9 561 (lines_to_add, lines_to_del, restart_bgp) = compare_context_objects(newconf, running)
2fc76430
DS
562
563 if lines_to_del:
564 for (ctx_keys, line) in lines_to_del:
565
566 if line == '!':
567 continue
568
569 cmd = line_to_vtysh_conft(ctx_keys, line, True)
570 original_cmd = cmd
571
76f69d1c
DW
572 # Some commands in quagga are picky about taking a "no" of the entire line.
573 # OSPF is bad about this, you can't "no" the entire line, you have to "no"
574 # only the beginning. If we hit one of these command an exception will be
575 # thrown. Catch it and remove the last '-c', 'FOO' from cmd and try again.
576 # Example:
577 # quagga(config-if)# ip ospf authentication message-digest 1.1.1.1
578 # quagga(config-if)# no ip ospf authentication message-digest 1.1.1.1
579 # % Unknown command.
580 # quagga(config-if)# no ip ospf authentication message-digest
581 # % Unknown command.
582 # quagga(config-if)# no ip ospf authentication
583 # quagga(config-if)#
2fc76430
DS
584
585 while True:
586
587 logger.info(cmd)
588
589 try:
590 _ = subprocess.check_output(cmd)
591
592 except subprocess.CalledProcessError:
593
594 # - Pull the last entry from cmd (this would be
595 # 'no ip ospf authentication message-digest 1.1.1.1' in
596 # our example above
597 # - Split that last entry by whitespace and drop the last word
598 logger.info('%s failed', str(cmd))
599 last_arg = cmd[-1].split(' ')
600
601 if len(last_arg) <= 2:
602 logger.error('"%s" we failed to remove this command', original_cmd)
603 break
604
605 new_last_arg = last_arg[0:-1]
606 cmd[-1] = ' '.join(new_last_arg)
607 else:
608 logger.info('%s worked', str(cmd))
609 break
610
611
612 if lines_to_add:
613 for (ctx_keys, line) in lines_to_add:
614
615 if line == '!':
616 continue
617
618 cmd = line_to_vtysh_conft(ctx_keys, line, False)
76f69d1c 619 logger.debug(cmd)
2fc76430
DS
620 subprocess.call(cmd)
621
76f69d1c
DW
622 if restart_bgp:
623 cmd = ['sudo', 'service', 'quagga', 'restart', 'bgpd']
624 subprocess.call(cmd)