1 #!/usr/bin/python |
1 #!/usr/bin/python |
|
2 |
|
3 import optparse, shlex |
2 |
4 |
3 import subprocess |
5 import subprocess |
4 import os, os.path |
6 import os, os.path |
5 |
7 |
6 import contextlib |
8 import contextlib |
7 import logging |
9 import logging |
8 |
10 |
9 logging.basicConfig( |
|
10 format = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s', |
|
11 level = logging.DEBUG, |
|
12 ) |
|
13 log = logging.getLogger() |
11 log = logging.getLogger() |
14 |
12 |
15 def invoke (cmd, args) : |
13 class InvokeError (Exception) : |
|
14 def __init__ (self, cmd, exit) : |
|
15 self.cmd = cmd |
|
16 self.exit = exit |
|
17 |
|
18 def __str__ (self) : |
|
19 raise Exception("{cmd} failed: {exit}".format(cmd=self.cmd, exit=self.exit)) |
|
20 |
|
21 def invoke (cmd, args, data=None) : |
16 """ |
22 """ |
17 Invoke a command directly. |
23 Invoke a command directly. |
|
24 |
|
25 data: data to pass in on stdin, returning stdout. |
|
26 if given as False, passes through our process stdin/out |
18 |
27 |
19 Doesn't give any data on stdin, and keeps process stderr. |
28 Doesn't give any data on stdin, and keeps process stderr. |
20 Returns stdout. |
29 Returns stdout. |
21 """ |
30 """ |
22 |
31 |
23 log.debug("cmd={cmd}, args={args}".format(cmd=cmd, args=args)) |
32 log.debug("cmd={cmd}, args={args}".format(cmd=cmd, args=args)) |
24 |
33 |
25 p = subprocess.Popen([cmd] + args, stdin=subprocess.PIPE, stdout=subprocess.PIPE) |
34 if data is False : |
|
35 # keep process stdin/out |
|
36 io = None |
|
37 else : |
|
38 io = subprocess.PIPE |
|
39 |
|
40 p = subprocess.Popen([cmd] + args, stdin=io, stdout=io) |
26 |
41 |
27 # get output |
42 # get output |
28 stdout, stderr = p.communicate(input=None) |
43 stdout, stderr = p.communicate(input=data) |
29 |
44 |
30 if p.returncode : |
45 if p.returncode : |
31 raise Exception("{cmd} failed: {returncode}".format(cmd=cmd, returncode=p.returncode)) |
46 # failed |
|
47 raise InvokeError(cmd, p.returncode) |
32 |
48 |
33 return stdout |
49 return stdout |
34 |
50 |
35 def optargs (*args, **kwargs) : |
51 def optargs (*args, **kwargs) : |
36 """ |
52 """ |
326 finally: |
343 finally: |
327 # cleanup |
344 # cleanup |
328 log.debug("cleanup: %s", mount) |
345 log.debug("cleanup: %s", mount) |
329 mount.close() |
346 mount.close() |
330 |
347 |
|
348 class RSyncCommandFormatError (Exception) : |
|
349 """ |
|
350 Improper rsync command |
|
351 """ |
|
352 |
|
353 pass |
|
354 |
|
355 def parse_rsync (command, restrict_server=True, restrict_readonly=True) : |
|
356 """ |
|
357 Parse given rsync server command into bits. |
|
358 |
|
359 command - the command-string sent by rsync |
|
360 restrict_server - restrict to server-mode |
|
361 restrict_readonly - restrict to read/send-mode |
|
362 |
|
363 Returns: |
|
364 |
|
365 (cmd, options, source, dest) |
|
366 """ |
|
367 |
|
368 # split |
|
369 parts = shlex.split(command) |
|
370 |
|
371 cmd = None |
|
372 options = [] |
|
373 source = None |
|
374 dest = None |
|
375 |
|
376 # parse |
|
377 for part in parts : |
|
378 if cmd is None : |
|
379 cmd = part |
|
380 |
|
381 elif part.startswith('-') : |
|
382 options.append(part) |
|
383 |
|
384 elif source is None : |
|
385 source = part |
|
386 |
|
387 elif dest is None : |
|
388 dest = part |
|
389 |
|
390 # options |
|
391 have_server = ('--server' in options) |
|
392 have_sender = ('--sender' in options) |
|
393 |
|
394 # verify |
|
395 if not have_server : |
|
396 raise RSyncCommandFormatError("Missing --server") |
|
397 |
|
398 if restrict_readonly and not have_sender : |
|
399 raise RSyncCommandFormatError("Missing --sender for readonly") |
|
400 |
|
401 # parse path |
|
402 if have_sender : |
|
403 # read |
|
404 # XXX: which way does the dot go? |
|
405 if source != '.' : |
|
406 raise RSyncCommandFormatError("Invalid dest for sender") |
|
407 |
|
408 path = dest |
|
409 |
|
410 else : |
|
411 # write |
|
412 if source != '.' : |
|
413 raise RSyncCommandFormatError("Invalid source for reciever") |
|
414 |
|
415 path = dest |
|
416 |
|
417 # ok |
|
418 return cmd, options, source, dest |
|
419 |
|
420 class RSyncSource (object) : |
|
421 RSYNC = '/usr/bin/rsync' |
|
422 |
|
423 def _execute (self, options, path) : |
|
424 """ |
|
425 Underlying rsync just reads from filesystem. |
|
426 """ |
|
427 |
|
428 invoke(self.RSYNC, options + [path, '.'], data=False) |
|
429 |
|
430 class RSyncFSSource (RSyncSource) : |
|
431 """ |
|
432 Normal filesystem backup. |
|
433 """ |
|
434 |
|
435 def __init__ (self, path) : |
|
436 RSyncSource.__init__(self) |
|
437 |
|
438 self.path = path |
|
439 |
|
440 def execute (self, options) : |
|
441 return self._execute(options, self.path) |
|
442 |
|
443 class RSyncLVMSource (RSyncSource) : |
|
444 """ |
|
445 Backup LVM LV by snapshotting + mounting it. |
|
446 """ |
|
447 |
|
448 def __init__ (self, volume) : |
|
449 RSyncSource.__init__(self) |
|
450 |
|
451 self.volume = volume |
|
452 |
|
453 def execute (self, options) : |
|
454 """ |
|
455 Snapshot, mount, execute |
|
456 """ |
|
457 |
|
458 # backup target from LVM command |
|
459 lvm = self.volume.lvm |
|
460 volume = self.volume |
|
461 |
|
462 # XXX: generate |
|
463 path = '/mnt' |
|
464 |
|
465 # snapshot |
|
466 log.info("Open snapshot...") |
|
467 |
|
468 # XXX: generate snapshot nametag to be unique? |
|
469 with lvm.snapshot(volume, tag='backup') as snapshot: |
|
470 log.info("Snapshot opened: %s", snapshot.lvm_path) |
|
471 |
|
472 # mount |
|
473 log.info("Mounting snapshot: %s -> %s", snapshot, path) |
|
474 |
|
475 with mount(snapshot.dev_path, path) as mountpoint: |
|
476 log.info("Mounted snapshot: %s", mountpoint) |
|
477 |
|
478 # rsync! |
|
479 log.info("Running rsync: ...") |
|
480 |
|
481 return self._execute(options, mountpoint.path) |
|
482 |
|
483 # cleanup |
|
484 # cleanup |
|
485 |
|
486 def rsync_source (path, restrict_path=False) : |
|
487 """ |
|
488 Figure out source to rsync from, based on pseudo-path given in rsync command. |
|
489 """ |
|
490 |
|
491 # normalize |
|
492 path = os.path.normpath(path) |
|
493 |
|
494 # verify path |
|
495 if restrict_path : |
|
496 if not path.startswith(restrict_path) : |
|
497 raise RSyncCommandFormatError("Restricted path ({restrict})".format(restrict=restrict_path)) |
|
498 |
|
499 if path.startswith('/') : |
|
500 # direct filesystem path |
|
501 # XXX: how to handle= |
|
502 log.info("filesystem: %s", path) |
|
503 |
|
504 return RSyncFSSource(path) |
|
505 |
|
506 elif path.startswith('lvm:') : |
|
507 # LVM LV |
|
508 try : |
|
509 lvm, vg, lv = path.split(':') |
|
510 |
|
511 except ValueError, e: |
|
512 raise RSyncCommandFormatError("Invalid lvm pseudo-path: {error}".format(error=e)) |
|
513 |
|
514 # XXX: validate |
|
515 |
|
516 log.info("LVM: %s/%s", vg, lv) |
|
517 |
|
518 # open |
|
519 lvm = LVM(vg) |
|
520 volume = lvm.volume(lv) |
|
521 |
|
522 return RSyncLVMSource(volume) |
|
523 |
|
524 else : |
|
525 # invalid |
|
526 raise RSyncCommandFormatError("Unrecognized backup path") |
|
527 |
|
528 # command-line options |
|
529 options = None |
|
530 |
|
531 def parse_options (argv) : |
|
532 """ |
|
533 Parse command-line arguments. |
|
534 """ |
|
535 |
|
536 |
|
537 parser = optparse.OptionParser() |
|
538 |
|
539 # logging |
|
540 parser.add_option('-q', '--quiet', dest='loglevel', action='store_const', const=logging.WARNING, help="Less output") |
|
541 parser.add_option('-v', '--verbose', dest='loglevel', action='store_const', const=logging.INFO, help="More output") |
|
542 parser.add_option('-D', '--debug', dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output") |
|
543 |
|
544 # |
|
545 parser.add_option('-c', '--command', default=os.environ.get('SSH_ORIGINAL_COMMAND'), |
|
546 help="rsync command to execute") |
|
547 |
|
548 parser.add_option('-R', '--readonly', action='store_true', default=False, |
|
549 help="restrict to read operations") |
|
550 |
|
551 parser.add_option('-P', '--restrict-path', default=False, |
|
552 help="restrict to given path") |
|
553 |
|
554 # defaults |
|
555 parser.set_defaults( |
|
556 loglevel = logging.WARNING, |
|
557 ) |
|
558 |
|
559 # parse |
|
560 options, args = parser.parse_args(argv[1:]) |
|
561 |
|
562 # configure |
|
563 logging.basicConfig( |
|
564 format = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s', |
|
565 level = options.loglevel, |
|
566 ) |
|
567 |
|
568 return options, args |
|
569 |
|
570 |
|
571 def rsync_wrapper (command, restrict='lvm:') : |
|
572 """ |
|
573 Wrap given rsync command. |
|
574 |
|
575 Backups the LVM LV given in the rsync command. |
|
576 """ |
|
577 |
|
578 try : |
|
579 # parse |
|
580 rsync_cmd, rsync_options, source_path, dest_path = parse_rsync(command, |
|
581 restrict_readonly = options.readonly, |
|
582 ) |
|
583 |
|
584 except RSyncCommandFormatError, e: |
|
585 log.error("invalid rsync command: %r: %s", command, e) |
|
586 return 2 |
|
587 |
|
588 # XXX: the real path is always given second.. |
|
589 path = dest_path |
|
590 |
|
591 try : |
|
592 # parse source |
|
593 source = rsync_source(path, |
|
594 restrict_path = options.restrict_path, |
|
595 ) |
|
596 |
|
597 except RSyncCommandFormatError, e: |
|
598 log.error("invalid rsync source: %r: %s", path, e) |
|
599 return 2 |
|
600 |
|
601 try : |
|
602 # run |
|
603 source.execute(rsync_options) |
|
604 |
|
605 except InvokeError, e: |
|
606 log.error("%s failed: %d", e.cmd, e.exit) |
|
607 return e.exit |
|
608 |
|
609 # ok |
|
610 return 0 |
|
611 |
331 def main (argv) : |
612 def main (argv) : |
332 # LVM VolumeGroup to manipulate |
613 """ |
333 lvm = LVM('asdf') |
614 SSH authorized_keys command="..." wrapper for rsync. |
334 |
615 """ |
335 # XXX: get backup target from rsync command |
616 |
336 backup_lv = lvm.volume('test') |
617 global options |
337 backup_path = '/mnt' |
618 |
338 |
619 # global options + args |
339 # snapshot |
620 options, args = parse_options(argv) |
340 log.info("Open snapshot...") |
621 |
341 |
622 # args |
342 with lvm.snapshot(backup_lv, tag='backup') as snapshot: |
623 if args : |
343 log.info("Snapshot opened: {name}".format(name=snapshot.lvm_path)) |
624 log.error("No arguments are handled") |
344 |
625 return 2 |
345 # mount |
626 |
346 log.info("Mounting snapshot: %s -> %s", snapshot, backup_path) |
627 if not options.command: |
347 |
628 log.error("SSH_ORIGINAL_COMMAND not given") |
348 with mount(snapshot.dev_path, backup_path) as mountpoint: |
629 return 2 |
349 log.info("Mounted snapshot: %s", mountpoint) |
630 |
350 |
631 try : |
351 # ... |
632 # handle it |
352 print command('ls', '-l', mountpoint.path) |
633 return rsync_wrapper(options.command) |
353 |
634 |
354 return 1 |
635 except Exception, e: |
|
636 log.error("Internal error:", exc_info=e) |
|
637 return 3 |
|
638 |
|
639 # ok |
|
640 return 0 |
355 |
641 |
356 if __name__ == '__main__' : |
642 if __name__ == '__main__' : |
357 import sys |
643 import sys |
358 |
644 |
359 sys.exit(main(sys.argv)) |
645 sys.exit(main(sys.argv)) |