--- a/rsync-lvm-server.py Tue Feb 14 15:08:12 2012 +0200
+++ b/rsync-lvm-server.py Tue Feb 14 16:44:56 2012 +0200
@@ -1,20 +1,29 @@
#!/usr/bin/python
+import optparse, shlex
+
import subprocess
import os, os.path
import contextlib
import logging
-logging.basicConfig(
- format = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s',
- level = logging.DEBUG,
-)
log = logging.getLogger()
-def invoke (cmd, args) :
+class InvokeError (Exception) :
+ def __init__ (self, cmd, exit) :
+ self.cmd = cmd
+ self.exit = exit
+
+ def __str__ (self) :
+ raise Exception("{cmd} failed: {exit}".format(cmd=self.cmd, exit=self.exit))
+
+def invoke (cmd, args, data=None) :
"""
Invoke a command directly.
+
+ data: data to pass in on stdin, returning stdout.
+ if given as False, passes through our process stdin/out
Doesn't give any data on stdin, and keeps process stderr.
Returns stdout.
@@ -22,13 +31,20 @@
log.debug("cmd={cmd}, args={args}".format(cmd=cmd, args=args))
- p = subprocess.Popen([cmd] + args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
+ if data is False :
+ # keep process stdin/out
+ io = None
+ else :
+ io = subprocess.PIPE
+
+ p = subprocess.Popen([cmd] + args, stdin=io, stdout=io)
# get output
- stdout, stderr = p.communicate(input=None)
+ stdout, stderr = p.communicate(input=data)
if p.returncode :
- raise Exception("{cmd} failed: {returncode}".format(cmd=cmd, returncode=p.returncode))
+ # failed
+ raise InvokeError(cmd, p.returncode)
return stdout
@@ -119,11 +135,12 @@
snapshot = LVMSnapshot.create(self, base, **kwargs)
try :
- log.debug("got snapshot={0}".format(snapshot))
+ log.debug("got: {0}".format(snapshot))
yield snapshot
finally:
# cleanup
+ # XXX: do we need to wait for it to get closed after mount?
log.debug("cleanup: {0}".format(snapshot))
snapshot.close()
@@ -328,30 +345,299 @@
log.debug("cleanup: %s", mount)
mount.close()
-def main (argv) :
- # LVM VolumeGroup to manipulate
- lvm = LVM('asdf')
-
- # XXX: get backup target from rsync command
- backup_lv = lvm.volume('test')
- backup_path = '/mnt'
-
- # snapshot
- log.info("Open snapshot...")
+class RSyncCommandFormatError (Exception) :
+ """
+ Improper rsync command
+ """
- with lvm.snapshot(backup_lv, tag='backup') as snapshot:
- log.info("Snapshot opened: {name}".format(name=snapshot.lvm_path))
-
- # mount
- log.info("Mounting snapshot: %s -> %s", snapshot, backup_path)
+ pass
- with mount(snapshot.dev_path, backup_path) as mountpoint:
- log.info("Mounted snapshot: %s", mountpoint)
+def parse_rsync (command, restrict_server=True, restrict_readonly=True) :
+ """
+ Parse given rsync server command into bits.
- # ...
- print command('ls', '-l', mountpoint.path)
+ command - the command-string sent by rsync
+ restrict_server - restrict to server-mode
+ restrict_readonly - restrict to read/send-mode
+
+ Returns:
- return 1
+ (cmd, options, source, dest)
+ """
+
+ # split
+ parts = shlex.split(command)
+
+ cmd = None
+ options = []
+ source = None
+ dest = None
+
+ # parse
+ for part in parts :
+ if cmd is None :
+ cmd = part
+
+ elif part.startswith('-') :
+ options.append(part)
+
+ elif source is None :
+ source = part
+
+ elif dest is None :
+ dest = part
+
+ # options
+ have_server = ('--server' in options)
+ have_sender = ('--sender' in options)
+
+ # verify
+ if not have_server :
+ raise RSyncCommandFormatError("Missing --server")
+
+ if restrict_readonly and not have_sender :
+ raise RSyncCommandFormatError("Missing --sender for readonly")
+
+ # parse path
+ if have_sender :
+ # read
+ # XXX: which way does the dot go?
+ if source != '.' :
+ raise RSyncCommandFormatError("Invalid dest for sender")
+
+ path = dest
+
+ else :
+ # write
+ if source != '.' :
+ raise RSyncCommandFormatError("Invalid source for reciever")
+
+ path = dest
+
+ # ok
+ return cmd, options, source, dest
+
+class RSyncSource (object) :
+ RSYNC = '/usr/bin/rsync'
+
+ def _execute (self, options, path) :
+ """
+ Underlying rsync just reads from filesystem.
+ """
+
+ invoke(self.RSYNC, options + [path, '.'], data=False)
+
+class RSyncFSSource (RSyncSource) :
+ """
+ Normal filesystem backup.
+ """
+
+ def __init__ (self, path) :
+ RSyncSource.__init__(self)
+
+ self.path = path
+
+ def execute (self, options) :
+ return self._execute(options, self.path)
+
+class RSyncLVMSource (RSyncSource) :
+ """
+ Backup LVM LV by snapshotting + mounting it.
+ """
+
+ def __init__ (self, volume) :
+ RSyncSource.__init__(self)
+
+ self.volume = volume
+
+ def execute (self, options) :
+ """
+ Snapshot, mount, execute
+ """
+
+ # backup target from LVM command
+ lvm = self.volume.lvm
+ volume = self.volume
+
+ # XXX: generate
+ path = '/mnt'
+
+ # snapshot
+ log.info("Open snapshot...")
+
+ # XXX: generate snapshot nametag to be unique?
+ with lvm.snapshot(volume, tag='backup') as snapshot:
+ log.info("Snapshot opened: %s", snapshot.lvm_path)
+
+ # mount
+ log.info("Mounting snapshot: %s -> %s", snapshot, path)
+
+ with mount(snapshot.dev_path, path) as mountpoint:
+ log.info("Mounted snapshot: %s", mountpoint)
+
+ # rsync!
+ log.info("Running rsync: ...")
+
+ return self._execute(options, mountpoint.path)
+
+ # cleanup
+ # cleanup
+
+def rsync_source (path, restrict_path=False) :
+ """
+ Figure out source to rsync from, based on pseudo-path given in rsync command.
+ """
+
+ # normalize
+ path = os.path.normpath(path)
+
+ # verify path
+ if restrict_path :
+ if not path.startswith(restrict_path) :
+ raise RSyncCommandFormatError("Restricted path ({restrict})".format(restrict=restrict_path))
+
+ if path.startswith('/') :
+ # direct filesystem path
+ # XXX: how to handle=
+ log.info("filesystem: %s", path)
+
+ return RSyncFSSource(path)
+
+ elif path.startswith('lvm:') :
+ # LVM LV
+ try :
+ lvm, vg, lv = path.split(':')
+
+ except ValueError, e:
+ raise RSyncCommandFormatError("Invalid lvm pseudo-path: {error}".format(error=e))
+
+ # XXX: validate
+
+ log.info("LVM: %s/%s", vg, lv)
+
+ # open
+ lvm = LVM(vg)
+ volume = lvm.volume(lv)
+
+ return RSyncLVMSource(volume)
+
+ else :
+ # invalid
+ raise RSyncCommandFormatError("Unrecognized backup path")
+
+# command-line options
+options = None
+
+def parse_options (argv) :
+ """
+ Parse command-line arguments.
+ """
+
+
+ parser = optparse.OptionParser()
+
+ # logging
+ parser.add_option('-q', '--quiet', dest='loglevel', action='store_const', const=logging.WARNING, help="Less output")
+ parser.add_option('-v', '--verbose', dest='loglevel', action='store_const', const=logging.INFO, help="More output")
+ parser.add_option('-D', '--debug', dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
+
+ #
+ parser.add_option('-c', '--command', default=os.environ.get('SSH_ORIGINAL_COMMAND'),
+ help="rsync command to execute")
+
+ parser.add_option('-R', '--readonly', action='store_true', default=False,
+ help="restrict to read operations")
+
+ parser.add_option('-P', '--restrict-path', default=False,
+ help="restrict to given path")
+
+ # defaults
+ parser.set_defaults(
+ loglevel = logging.WARNING,
+ )
+
+ # parse
+ options, args = parser.parse_args(argv[1:])
+
+ # configure
+ logging.basicConfig(
+ format = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s',
+ level = options.loglevel,
+ )
+
+ return options, args
+
+
+def rsync_wrapper (command, restrict='lvm:') :
+ """
+ Wrap given rsync command.
+
+ Backups the LVM LV given in the rsync command.
+ """
+
+ try :
+ # parse
+ rsync_cmd, rsync_options, source_path, dest_path = parse_rsync(command,
+ restrict_readonly = options.readonly,
+ )
+
+ except RSyncCommandFormatError, e:
+ log.error("invalid rsync command: %r: %s", command, e)
+ return 2
+
+ # XXX: the real path is always given second..
+ path = dest_path
+
+ try :
+ # parse source
+ source = rsync_source(path,
+ restrict_path = options.restrict_path,
+ )
+
+ except RSyncCommandFormatError, e:
+ log.error("invalid rsync source: %r: %s", path, e)
+ return 2
+
+ try :
+ # run
+ source.execute(rsync_options)
+
+ except InvokeError, e:
+ log.error("%s failed: %d", e.cmd, e.exit)
+ return e.exit
+
+ # ok
+ return 0
+
+def main (argv) :
+ """
+ SSH authorized_keys command="..." wrapper for rsync.
+ """
+
+ global options
+
+ # global options + args
+ options, args = parse_options(argv)
+
+ # args
+ if args :
+ log.error("No arguments are handled")
+ return 2
+
+ if not options.command:
+ log.error("SSH_ORIGINAL_COMMAND not given")
+ return 2
+
+ try :
+ # handle it
+ return rsync_wrapper(options.command)
+
+ except Exception, e:
+ log.error("Internal error:", exc_info=e)
+ return 3
+
+ # ok
+ return 0
if __name__ == '__main__' :
import sys