rsync-lvm-server.py
changeset 3 d9e3a4ed5569
parent 2 59f75daeb093
child 4 8de81df59019
equal deleted inserted replaced
2:59f75daeb093 3:d9e3a4ed5569
     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     """
   117 
   133 
   118         log.debug("creating snapshot from {base}: {opts}".format(base=base, opts=kwargs))
   134         log.debug("creating snapshot from {base}: {opts}".format(base=base, opts=kwargs))
   119         snapshot = LVMSnapshot.create(self, base, **kwargs)
   135         snapshot = LVMSnapshot.create(self, base, **kwargs)
   120 
   136 
   121         try :
   137         try :
   122             log.debug("got snapshot={0}".format(snapshot))
   138             log.debug("got: {0}".format(snapshot))
   123             yield snapshot
   139             yield snapshot
   124 
   140 
   125         finally:
   141         finally:
   126             # cleanup
   142             # cleanup
       
   143             # XXX: do we need to wait for it to get closed after mount?
   127             log.debug("cleanup: {0}".format(snapshot))
   144             log.debug("cleanup: {0}".format(snapshot))
   128             snapshot.close()
   145             snapshot.close()
   129 
   146 
   130     def __repr__ (self) :
   147     def __repr__ (self) :
   131         return "LVM(name={name})".format(name=repr(self.name))
   148         return "LVM(name={name})".format(name=repr(self.name))
   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))