blob: 2f3a2311c0379338a417ae6325eef8f9748ffbeb [file] [log] [blame]
#!/usr/bin/env python
#
# svn-backup-dumps.py -- Create dumpfiles to backup a subversion repository.
#
# ====================================================================
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ====================================================================
#
# This script creates dump files from a subversion repository.
# It is intended for use in cron jobs and post-commit hooks.
#
# 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).
# 4. Create incremental dumps containing everything since last dump.
#
# 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:
# 5. Create gzipped dump files.
# 6. Create bzipped dump files.
# 7. Transfer the dumpfile to another host using ftp.
# 8. 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 <repos> <dumpdir>
#
# <repos> Path to the repository.
# <dumpdir> 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> <repos> <dumpdir>
#
# <count> Count of revisions per dump file.
# <repos> Path to the repository.
# <dumpdir> 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 <revnr> <repos> <dumpdir>
#
# <revnr> A revision number.
# <repos> Path to the repository.
# <dumpdir> 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 incremental dumps relative to last dump
#
# svn-backup-dumps.py -i <repos> <dumpdir>
#
# <repos> Path to the repository.
# <dumpdir> Directory for storing the dump file.
#
# When if dumps are performed when HEAD is 2923,
# then when HEAD is 3045, is creates these files:
#
# src.000000-002923.svndmp.gz
# src.002924-003045.svndmp.gz
#
#
# 5. Create gzipped dump files.
#
# svn-backup-dumps.py -z ...
#
# ... More options, see 1-4, 7, 8.
#
#
# 6. Create bzipped dump files.
#
# svn-backup-dumps.py -b ...
#
# ... More options, see 1-4, 7, 8.
#
#
# 7. Transfer the dumpfile to another host using ftp.
#
# svn-backup-dumps.py -t ftp:<host>:<user>:<password>:<path> ...
#
# <host> Name of the FTP host.
# <user> Username on the remote host.
# <password> Password for the user.
# <path> Subdirectory on the remote host.
# ... More options, see 1-6.
#
# If <path> contains the string '%r' it is replaced by the
# repository name (basename of the repository path).
#
#
# 8. Transfer the dumpfile to another host using smb.
#
# svn-backup-dumps.py -t smb:<share>:<user>:<password>:<path> ...
#
# <share> Name of an SMB share in the form '//host/share'.
# <user> Username on the remote host.
# <password> Password for the user.
# <path> Subdirectory of the share.
# ... More options, see 1-6.
#
# If <path> 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.6"
import sys
import os
if os.name != "nt":
import fcntl
import select
import gzip
import os.path
import re
from optparse import OptionParser
from ftplib import FTP
from subprocess import Popen, PIPE
try:
import bz2
have_bz2 = True
except ImportError:
have_bz2 = False
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 SvnBackupOutputCommand(SvnBackupOutput):
def __init__(self, abspath, filename, file_extension, cmd_path,
cmd_options):
SvnBackupOutput.__init__(self, abspath, filename + file_extension)
self.__cmd_path = cmd_path
self.__cmd_options = cmd_options
def open(self):
cmd = [ self.__cmd_path, self.__cmd_options ]
self.__ofd = open(self.get_absfilename(), "wb")
try:
proc = Popen(cmd, stdin=PIPE, stdout=self.__ofd, shell=False)
except:
print((256, "", "Popen failed (%s ...):\n %s" % (cmd[0],
str(sys.exc_info()[1]))))
sys.exit(256)
self.__proc = proc
self.__stdin = proc.stdin
def write(self, data):
self.__stdin.write(data)
def close(self):
self.__stdin.close()
rc = self.__proc.wait()
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.\nuse -h or"
" --help option to see help.")
else:
raise SvnBackupException("too many arguments, specify"
" repospath and dumpdir only.\nuse"
" -h or --help option to see help.")
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, subdir)
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.__relative_incremental = options.relative_incremental
# svnadmin/svnlook path
self.__svnadmin_path = "svnadmin"
if options.svnadmin_path:
self.__svnadmin_path = options.svnadmin_path
self.__svnlook_path = "svnlook"
if options.svnlook_path:
self.__svnlook_path = options.svnlook_path
# check compress option
self.__gzip_path = options.gzip_path
self.__bzip2_path = options.bzip2_path
self.__zip = None
compress_options = 0
if options.gzip_path != None:
compress_options = compress_options + 1
if options.bzip2_path != None:
compress_options = compress_options + 1
if options.bzip2:
compress_options = compress_options + 1
self.__zip = "bzip2"
if options.gzip:
compress_options = compress_options + 1
self.__zip = "gzip"
if compress_options > 1:
raise SvnBackupException("--bzip2-path, --gzip-path, -b, -z are "
"mutually exclusive.")
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:
sys.stdout.write("%s " % 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 = [ self.__svnlook_path, "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 get_last_dumped_rev(self):
filename_regex = re.compile("(.+)\.\d+-(\d+)\.svndmp.*")
# start with -1 so the next one will be rev 0
highest_rev = -1
for filename in os.listdir(self.__dumpdir):
m = filename_regex.match( filename )
if m and (m.group(1) == self.__reposname):
rev_end = int(m.group(2))
if rev_end > highest_rev:
# determine the latest revision dumped
highest_rev = rev_end
return highest_rev
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 as 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.__bzip2_path:
output = SvnBackupOutputCommand(self.__dumpdir, filename, ".bz2",
self.__bzip2_path, "-cz" )
elif self.__gzip_path:
output = SvnBackupOutputCommand(self.__dumpdir, filename, ".gz",
self.__gzip_path, "-cf" )
elif 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 = [ self.__svnadmin_path, "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 export_relative_incremental(self):
headrev = self.get_head_rev()
if headrev == -1:
return False
last_dumped_rev = self.get_last_dumped_rev();
if headrev < last_dumped_rev:
# that should not happen...
return False
if headrev == last_dumped_rev:
# already up-to-date
return True
return self.create_dump(False, False, last_dumped_rev + 1, headrev)
def execute(self):
if self.__rev_nr != None:
return self.export_single_rev()
elif self.__relative_incremental:
return self.export_relative_incremental()
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_true",
dest="bzip2", default=False,
help="compress the dump using python bzip2 library.")
parser.add_option("-i",
action="store_true",
dest="relative_incremental", default=False,
help="perform incremental relative to last dump.")
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_true",
dest="gzip", default=False,
help="compress the dump using python gzip library.")
parser.add_option("--bzip2-path",
action="store", type="string",
dest="bzip2_path", default=None,
help="compress the dump using bzip2 custom command.")
parser.add_option("--gzip-path",
action="store", type="string",
dest="gzip_path", default=None,
help="compress the dump using gzip custom command.")
parser.add_option("--svnadmin-path",
action="store", type="string",
dest="svnadmin_path", default=None,
help="svnadmin command path.")
parser.add_option("--svnlook-path",
action="store", type="string",
dest="svnlook_path", default=None,
help="svnlook command path.")
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:<host>:<user>:<password>:<dest-path>")
print("")
print(" SMB (using smbclient):")
print(" -t smb:<share>:<user>:<password>:<dest-path>")
print("")
sys.exit(0)
rc = False
try:
backup = SvnBackup(options, args)
rc = backup.execute()
except SvnBackupException as e:
print("svn-backup-dumps.py: %s" % e)
if rc:
print("Everything OK.")
sys.exit(0)
else:
print("An error occurred!")
sys.exit(1)
# vim:et:ts=4:sw=4