#!/usr/bin/env python # # svn-backup-dumps.py -- Create dumpfiles to backup a subversion repository. # # ==================================================================== # Copyright (c) 2006 CollabNet. All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. The terms # are also available at http://subversion.tigris.org/license-1.html. # If newer versions of this license are posted there, you may use a # newer version instead, at your option. # # This software consists of voluntary contributions made by many # individuals. For exact contribution history, see the revision # history and logs, available at http://subversion.tigris.org/. # ==================================================================== # # This script creates dump files from a subversion repository. # It is intended for use in cron jobs and post-commit hooks. # # Tested on UNIX with python 2.3 and 2.4, on Windows with python 2.4. # # The basic operation modes are: # 1. Create a full dump (revisions 0 to HEAD). # 2. Create incremental dumps containing at most N revisions. # 3. Create incremental single revision dumps (for use in post-commit). # # All dump files are prefixed with the basename of the repository. All # examples below assume that the repository '/srv/svn/repos/src' is # dumped so all dumpfiles start with 'src'. # # Optional functionality: # 4. Create gzipped dump files. # 5. Create bzipped dump files. # 6. Transfer the dumpfile to another host using ftp. # 7. Transfer the dumpfile to another host using smb. # # See also 'svn-backup-dumps.py -h'. # # # 1. Create a full dump (revisions 0 to HEAD). # # svn-backup-dumps.py # # Path to the repository. # Directory for storing the dump file. # # This creates a dump file named 'src.000000-NNNNNN.svndmp.gz' # where NNNNNN is the revision number of HEAD. # # 2. Create incremental dumps containing at most N revisions. # # svn-backup-dumps.py -c # # Count of revisions per dump file. # Path to the repository. # Directory for storing the dump file. # # When started the first time with a count of 1000 and if HEAD is # at 2923 it creates the following files: # # src.000000-000999.svndmp.gz # src.001000-001999.svndmp.gz # src.002000-002923.svndmp.gz # # Say the next time HEAD is at 3045 it creates these two files: # # src.002000-002999.svndmp.gz # src.003000-003045.svndmp.gz # # # 3. Create incremental single revision dumps (for use in post-commit). # # svn-backup-dumps.py -r # # A revision number. # Path to the repository. # Directory for storing the dump file. # # This creates a dump file named 'src.NNNNNN.svndmp.gz' where # NNNNNN is the revision number of HEAD. # # # 4. Create gzipped dump files. # # svn-backup-dumps.py -z ... # # ... More options, see 1-3, 6, 7. # # # 5. Create bzipped dump files. # # svn-backup-dumps.py -b ... # # ... More options, see 1-3, 6, 7. # # # 6. Transfer the dumpfile to another host using ftp. # # svn-backup-dumps.py -t ftp:::: ... # # Name of the FTP host. # Username on the remote host. # Password for the user. # Subdirectory on the remote host. # ... More options, see 1-5. # # If contains the string '%r' it is replaced by the # repository name (basename of the repository path). # # # 7. Transfer the dumpfile to another host using smb. # # svn-backup-dumps.py -t smb:::: ... # # Name of an SMB share in the form '//host/share'. # Username on the remote host. # Password for the user. # Subdirectory of the share. # ... More options, see 1-5. # # If contains the string '%r' it is replaced by the # repository name (basename of the repository path). # # # # TODO: # - find out how to report smbclient errors # - improve documentation # __version = "0.5" import sys import os if os.name != "nt": import fcntl import select import gzip import os.path from optparse import OptionParser from ftplib import FTP try: import bz2 have_bz2 = True except ImportError: have_bz2 = False class Popen24Compat: def __init__(self, args, bufsize=0, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=False, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0): if isinstance(args, list): args = tuple(args) elif not isinstance(args, tuple): raise RipperException("Popen24Compat: args is not tuple or list") self.stdin = None self.stdout = None self.stderr = None self.returncode = None if executable == None: executable = args[0] if stdin == PIPE: stdin, stdin_fd = os.pipe() self.stdin = os.fdopen(stdin_fd) elif stdin == None: stdin = 0 else: stdin = stdin.fileno() if stdout == PIPE: stdout_fd, stdout = os.pipe() self.stdout = os.fdopen(stdout_fd) elif stdout == None: stdout = 1 else: stdout = stdout.fileno() if stderr == PIPE: stderr_fd, stderr = os.pipe() self.stderr = os.fdopen(stderr_fd) elif stderr == None: stderr = 2 else: stderr = stderr.fileno() # error pipe err_read, err_write = os.pipe() fcntl.fcntl(err_write, fcntl.F_SETFD, fcntl.FD_CLOEXEC) self.pid = os.fork() if self.pid < 0: raise Exception("Popen24Compat: fork") if self.pid == 0: # child os.close(err_read) fcntl.fcntl(err_write, fcntl.F_SETFD, fcntl.FD_CLOEXEC) if self.stdin: self.stdin.close() if self.stdout: self.stdout.close() if self.stderr: self.stderr.close() if stdin != 0: os.dup2(stdin, 0) os.close(stdin) if stdout != 1: os.dup2(stdout, 1) os.close(stdout) if stderr != 2: os.dup2(stderr, 2) os.close(stderr) try: if shell: # should spawn a shell here... os.execvp(executable, args) else: os.execvp(executable, args) except: err = sys.exc_info()[1] # exec error os.write(err_write, str(err)) os._exit(255) else: # parent os.close(err_write) if stdin != 0: os.close(stdin) if stdout != 0: os.close(stdout) if stderr != 0: os.close(stderr) sr, sw, se = select.select([ err_read ], [], [ err_read ]) if len(se) == 1: os.close(err_read) raise Exception("Popen24Compat: err pipe read error") if len(sr) == 1: err = os.read(err_read, 1024) os.close(err_read) if len(err) != 0: raise Exception("Popen24Compat: exec error: " + err) def poll(self): self.__wait(os.WNOHANG) return self.returncode def wait(self): self.__wait(0) return self.returncode def __wait(self, options): pid, rc = os.waitpid(self.pid, options) if pid != 0: self.returncode = rc def PopenConstr(args, bufsize=0, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=False, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0): return Popen24Compat(args, bufsize=bufsize, executable=executable, stdin=stdin, stdout=stdout, stderr=stderr, preexec_fn=preexec_fn, close_fds=close_fds, shell=shell, cwd=cwd, env=env, universal_newlines=universal_newlines, startupinfo=startupinfo, creationflags=creationflags) try: from subprocess import Popen, PIPE except ImportError: Popen = PopenConstr PIPE = -1 class SvnBackupOutput: def __init__(self, abspath, filename): self.__filename = filename self.__absfilename = os.path.join(abspath, filename) def open(self): pass def write(self, data): pass def close(self): pass def get_filename(self): return self.__filename def get_absfilename(self): return self.__absfilename class SvnBackupOutputPlain(SvnBackupOutput): def __init__(self, abspath, filename): SvnBackupOutput.__init__(self, abspath, filename) def open(self): self.__ofd = open(self.get_absfilename(), "wb") def write(self, data): self.__ofd.write(data) def close(self): self.__ofd.close() class SvnBackupOutputGzip(SvnBackupOutput): def __init__(self, abspath, filename): SvnBackupOutput.__init__(self, abspath, filename + ".gz") def open(self): self.__compressor = gzip.GzipFile(filename=self.get_absfilename(), mode="wb") def write(self, data): self.__compressor.write(data) def close(self): self.__compressor.flush() self.__compressor.close() class SvnBackupOutputBzip2(SvnBackupOutput): def __init__(self, abspath, filename): SvnBackupOutput.__init__(self, abspath, filename + ".bz2") def open(self): self.__compressor = bz2.BZ2Compressor() self.__ofd = open(self.get_absfilename(), "wb") def write(self, data): self.__ofd.write(self.__compressor.compress(data)) def close(self): self.__ofd.write(self.__compressor.flush()) self.__ofd.close() class SvnBackupException(Exception): def __init__(self, errortext): self.errortext = errortext def __str__(self): return self.errortext class SvnBackup: def __init__(self, options, args): # need 3 args: progname, reposname, dumpdir if len(args) != 3: if len(args) < 3: raise SvnBackupException("too few arguments, specify repospath and dumpdir.") else: raise SvnBackupException("too many arguments, specify repospath and dumpdir only.") self.__repospath = args[1] self.__dumpdir = args[2] # check repospath rpathparts = os.path.split(self.__repospath) if len(rpathparts[1]) == 0: # repospath without trailing slash self.__repospath = rpathparts[0] if not os.path.exists(self.__repospath): raise SvnBackupException("repos '%s' does not exist." % self.__repospath) if not os.path.isdir(self.__repospath): raise SvnBackupException("repos '%s' is not a directory." % self.__repospath) for subdir in [ "db", "conf", "hooks" ]: dir = os.path.join(self.__repospath, "db") if not os.path.isdir(dir): raise SvnBackupException("repos '%s' is not a repository." % self.__repospath) rpathparts = os.path.split(self.__repospath) self.__reposname = rpathparts[1] if self.__reposname in [ "", ".", ".." ]: raise SvnBackupException("couldn't extract repos name from '%s'." % self.__repospath) # check dumpdir if not os.path.exists(self.__dumpdir): raise SvnBackupException("dumpdir '%s' does not exist." % self.__dumpdir) elif not os.path.isdir(self.__dumpdir): raise SvnBackupException("dumpdir '%s' is not a directory." % self.__dumpdir) # set options self.__rev_nr = options.rev self.__count = options.cnt self.__quiet = options.quiet self.__deltas = options.deltas self.__zip = options.zip self.__overwrite = False self.__overwrite_all = False if options.overwrite > 0: self.__overwrite = True if options.overwrite > 1: self.__overwrite_all = True self.__transfer = None if options.transfer != None: self.__transfer = options.transfer.split(":") if len(self.__transfer) != 5: if len(self.__transfer) < 5: raise SvnBackupException("too few fields for transfer '%s'." % self.__transfer) else: raise SvnBackupException("too many fields for transfer '%s'." % self.__transfer) if self.__transfer[0] not in [ "ftp", "smb" ]: raise SvnBackupException("unknown transfer method '%s'." % self.__transfer[0]) def set_nonblock(self, fileobj): fd = fileobj.fileno() n = fcntl.fcntl(fd, fcntl.F_GETFL) fcntl.fcntl(fd, fcntl.F_SETFL, n|os.O_NONBLOCK) def exec_cmd(self, cmd, output=None, printerr=False): if os.name == "nt": return self.exec_cmd_nt(cmd, output, printerr) else: return self.exec_cmd_unix(cmd, output, printerr) def exec_cmd_unix(self, cmd, output=None, printerr=False): try: proc = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=False) except: return (256, "", "Popen failed (%s ...):\n %s" % (cmd[0], str(sys.exc_info()[1]))) stdout = proc.stdout stderr = proc.stderr self.set_nonblock(stdout) self.set_nonblock(stderr) readfds = [ stdout, stderr ] selres = select.select(readfds, [], []) bufout = "" buferr = "" while len(selres[0]) > 0: for fd in selres[0]: buf = fd.read(16384) if len(buf) == 0: readfds.remove(fd) elif fd == stdout: if output: output.write(buf) else: bufout += buf else: if printerr: print buf, else: buferr += buf if len(readfds) == 0: break selres = select.select(readfds, [], []) rc = proc.wait() if printerr: print "" return (rc, bufout, buferr) def exec_cmd_nt(self, cmd, output=None, printerr=False): try: proc = Popen(cmd, stdout=PIPE, stderr=None, shell=False) except: return (256, "", "Popen failed (%s ...):\n %s" % (cmd[0], str(sys.exc_info()[1]))) stdout = proc.stdout bufout = "" buferr = "" buf = stdout.read(16384) while len(buf) > 0: if output: output.write(buf) else: bufout += buf buf = stdout.read(16384) rc = proc.wait() return (rc, bufout, buferr) def get_head_rev(self): cmd = [ "svnlook", "youngest", self.__repospath ] r = self.exec_cmd(cmd) if r[0] == 0 and len(r[2]) == 0: return int(r[1].strip()) else: print r[2] return -1 def transfer_ftp(self, absfilename, filename): rc = False try: host = self.__transfer[1] user = self.__transfer[2] passwd = self.__transfer[3] destdir = self.__transfer[4].replace("%r", self.__reposname) ftp = FTP(host, user, passwd) ftp.cwd(destdir) ifd = open(absfilename, "rb") ftp.storbinary("STOR %s" % filename, ifd) ftp.quit() rc = len(ifd.read(1)) == 0 ifd.close() except Exception, e: raise SvnBackupException("ftp transfer failed:\n file: '%s'\n error: %s" % \ (absfilename, str(e))) return rc def transfer_smb(self, absfilename, filename): share = self.__transfer[1] user = self.__transfer[2] passwd = self.__transfer[3] if passwd == "": passwd = "-N" destdir = self.__transfer[4].replace("%r", self.__reposname) cmd = ("smbclient", share, "-U", user, passwd, "-D", destdir, "-c", "put %s %s" % (absfilename, filename)) r = self.exec_cmd(cmd) rc = r[0] == 0 if not rc: print r[2] return rc def transfer(self, absfilename, filename): if self.__transfer == None: return elif self.__transfer[0] == "ftp": self.transfer_ftp(absfilename, filename) elif self.__transfer[0] == "smb": self.transfer_smb(absfilename, filename) else: print "unknown transfer method '%s'." % self.__transfer[0] def create_dump(self, checkonly, overwrite, fromrev, torev=None): revparam = "%d" % fromrev r = "%06d" % fromrev if torev != None: revparam += ":%d" % torev r += "-%06d" % torev filename = "%s.%s.svndmp" % (self.__reposname, r) output = None if self.__zip: if self.__zip == "gzip": output = SvnBackupOutputGzip(self.__dumpdir, filename) else: output = SvnBackupOutputBzip2(self.__dumpdir, filename) else: output = SvnBackupOutputPlain(self.__dumpdir, filename) absfilename = output.get_absfilename() realfilename = output.get_filename() if checkonly: return os.path.exists(absfilename) elif os.path.exists(absfilename): if overwrite: print "overwriting " + absfilename else: print "%s already exists." % absfilename return True else: print "writing " + absfilename cmd = [ "svnadmin", "dump", "--incremental", "-r", revparam, self.__repospath ] if self.__quiet: cmd[2:2] = [ "-q" ] if self.__deltas: cmd[2:2] = [ "--deltas" ] output.open() r = self.exec_cmd(cmd, output, True) output.close() rc = r[0] == 0 if rc: self.transfer(absfilename, realfilename) return rc def export_single_rev(self): return self.create_dump(False, self.__overwrite, self.__rev_nr) def export(self): headrev = self.get_head_rev() if headrev == -1: return False if self.__count is None: return self.create_dump(False, self.__overwrite, 0, headrev) baserev = headrev - (headrev % self.__count) rc = True cnt = self.__count fromrev = baserev - cnt torev = baserev - 1 while fromrev >= 0 and rc: if self.__overwrite_all or \ not self.create_dump(True, False, fromrev, torev): rc = self.create_dump(False, self.__overwrite_all, fromrev, torev) fromrev -= cnt torev -= cnt else: fromrev = -1 if rc: rc = self.create_dump(False, self.__overwrite, baserev, headrev) return rc def execute(self): if self.__rev_nr != None: return self.export_single_rev() else: return self.export() if __name__ == "__main__": usage = "usage: svn-backup-dumps.py [options] repospath dumpdir" parser = OptionParser(usage=usage, version="%prog "+__version) if have_bz2: parser.add_option("-b", action="store_const", const="bzip2", dest="zip", default=None, help="compress the dump using bzip2.") parser.add_option("--deltas", action="store_true", dest="deltas", default=False, help="pass --deltas to svnadmin dump.") parser.add_option("-c", action="store", type="int", dest="cnt", default=None, help="count of revisions per dumpfile.") parser.add_option("-o", action="store_const", const=1, dest="overwrite", default=0, help="overwrite files.") parser.add_option("-O", action="store_const", const=2, dest="overwrite", default=0, help="overwrite all files.") parser.add_option("-q", action="store_true", dest="quiet", default=False, help="quiet.") parser.add_option("-r", action="store", type="int", dest="rev", default=None, help="revision number for single rev dump.") parser.add_option("-t", action="store", type="string", dest="transfer", default=None, help="transfer dumps to another machine "+ "(s.a. --help-transfer).") parser.add_option("-z", action="store_const", const="gzip", dest="zip", help="compress the dump using gzip.") parser.add_option("--help-transfer", action="store_true", dest="help_transfer", default=False, help="shows detailed help for the transfer option.") (options, args) = parser.parse_args(sys.argv) if options.help_transfer: print "Transfer help:" print "" print " FTP:" print " -t ftp::::" print "" print " SMB (using smbclient):" print " -t smb::::" print "" sys.exit(0) rc = False try: backup = SvnBackup(options, args) rc = backup.execute() except SvnBackupException, e: print "svn-backup-dumps.py:", e if rc: print "Everything OK." sys.exit(0) else: print "An error occured!" sys.exit(1) # vim:et:ts=4:sw=4