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