3 # svn-backup-dumps.py -- Create dumpfiles to backup a subversion repository.
5 # ====================================================================
6 # Copyright (c) 2006 CollabNet. All rights reserved.
8 # This software is licensed as described in the file COPYING, which
9 # you should have received as part of this distribution. The terms
10 # are also available at http://subversion.tigris.org/license-1.html.
11 # If newer versions of this license are posted there, you may use a
12 # newer version instead, at your option.
14 # This software consists of voluntary contributions made by many
15 # individuals. For exact contribution history, see the revision
16 # history and logs, available at http://subversion.tigris.org/.
17 # ====================================================================
19 # This script creates dump files from a subversion repository.
20 # It is intended for use in cron jobs and post-commit hooks.
22 # Tested on UNIX with python 2.3 and 2.4, on Windows with python 2.4.
24 # The basic operation modes are:
25 # 1. Create a full dump (revisions 0 to HEAD).
26 # 2. Create incremental dumps containing at most N revisions.
27 # 3. Create incremental single revision dumps (for use in post-commit).
29 # All dump files are prefixed with the basename of the repository. All
30 # examples below assume that the repository '/srv/svn/repos/src' is
31 # dumped so all dumpfiles start with 'src'.
33 # Optional functionality:
34 # 4. Create gzipped dump files.
35 # 5. Create bzipped dump files.
36 # 6. Transfer the dumpfile to another host using ftp.
37 # 7. Transfer the dumpfile to another host using smb.
39 # See also 'svn-backup-dumps.py -h'.
42 # 1. Create a full dump (revisions 0 to HEAD).
44 # svn-backup-dumps.py <repos> <dumpdir>
46 # <repos> Path to the repository.
47 # <dumpdir> Directory for storing the dump file.
49 # This creates a dump file named 'src.000000-NNNNNN.svndmp.gz'
50 # where NNNNNN is the revision number of HEAD.
52 # 2. Create incremental dumps containing at most N revisions.
54 # svn-backup-dumps.py -c <count> <repos> <dumpdir>
56 # <count> Count of revisions per dump file.
57 # <repos> Path to the repository.
58 # <dumpdir> Directory for storing the dump file.
60 # When started the first time with a count of 1000 and if HEAD is
61 # at 2923 it creates the following files:
63 # src.000000-000999.svndmp.gz
64 # src.001000-001999.svndmp.gz
65 # src.002000-002923.svndmp.gz
67 # Say the next time HEAD is at 3045 it creates these two files:
69 # src.002000-002999.svndmp.gz
70 # src.003000-003045.svndmp.gz
73 # 3. Create incremental single revision dumps (for use in post-commit).
75 # svn-backup-dumps.py -r <revnr> <repos> <dumpdir>
77 # <revnr> A revision number.
78 # <repos> Path to the repository.
79 # <dumpdir> Directory for storing the dump file.
81 # This creates a dump file named 'src.NNNNNN.svndmp.gz' where
82 # NNNNNN is the revision number of HEAD.
85 # 4. Create gzipped dump files.
87 # svn-backup-dumps.py -z ...
89 # ... More options, see 1-3, 6, 7.
92 # 5. Create bzipped dump files.
94 # svn-backup-dumps.py -b ...
96 # ... More options, see 1-3, 6, 7.
99 # 6. Transfer the dumpfile to another host using ftp.
101 # svn-backup-dumps.py -t ftp:<host>:<user>:<password>:<path> ...
103 # <host> Name of the FTP host.
104 # <user> Username on the remote host.
105 # <password> Password for the user.
106 # <path> Subdirectory on the remote host.
107 # ... More options, see 1-5.
109 # If <path> contains the string '%r' it is replaced by the
110 # repository name (basename of the repository path).
113 # 7. Transfer the dumpfile to another host using smb.
115 # svn-backup-dumps.py -t smb:<share>:<user>:<password>:<path> ...
117 # <share> Name of an SMB share in the form '//host/share'.
118 # <user> Username on the remote host.
119 # <password> Password for the user.
120 # <path> Subdirectory of the share.
121 # ... More options, see 1-5.
123 # If <path> contains the string '%r' it is replaced by the
124 # repository name (basename of the repository path).
129 # - find out how to report smbclient errors
130 # - improve documentation
142 from optparse
import OptionParser
143 from ftplib
import FTP
154 def __init__(self
, args
, bufsize
=0, executable
=None, stdin
=None,
155 stdout
=None, stderr
=None, preexec_fn
=None, close_fds
=False,
156 shell
=False, cwd
=None, env
=None, universal_newlines
=False,
157 startupinfo
=None, creationflags
=0):
159 if isinstance(args
, list):
161 elif not isinstance(args
, tuple):
162 raise RipperException
, "Popen24Compat: args is not tuple or list"
167 self
.returncode
= None
169 if executable
== None:
173 stdin
, stdin_fd
= os
.pipe()
174 self
.stdin
= os
.fdopen(stdin_fd
)
178 stdin
= stdin
.fileno()
180 stdout_fd
, stdout
= os
.pipe()
181 self
.stdout
= os
.fdopen(stdout_fd
)
185 stdout
= stdout
.fileno()
187 stderr_fd
, stderr
= os
.pipe()
188 self
.stderr
= os
.fdopen(stderr_fd
)
192 stderr
= stderr
.fileno()
195 err_read
, err_write
= os
.pipe()
196 fcntl
.fcntl(err_write
, fcntl
.F_SETFD
, fcntl
.FD_CLOEXEC
)
200 raise Exception, "Popen24Compat: fork"
204 fcntl
.fcntl(err_write
, fcntl
.F_SETFD
, fcntl
.FD_CLOEXEC
)
222 # should spawn a shell here...
223 os
.execvp(executable
, args
)
225 os
.execvp(executable
, args
)
227 err
= sys
.exc_info()[1]
229 os
.write(err_write
, str(err
))
240 sr
, sw
, se
= select
.select([ err_read
], [], [ err_read
])
243 raise Exception, "Popen24Compat: err pipe read error"
245 err
= os
.read(err_read
, 1024)
248 raise Exception, "Popen24Compat: exec error: " + err
251 self
.__wait
(os
.WNOHANG
)
252 return self
.returncode
256 return self
.returncode
258 def __wait(self
, options
):
259 pid
, rc
= os
.waitpid(self
.pid
, options
)
263 def PopenConstr(args
, bufsize
=0, executable
=None, stdin
=None, stdout
=None,
264 stderr
=None, preexec_fn
=None, close_fds
=False, shell
=False,
265 cwd
=None, env
=None, universal_newlines
=False, startupinfo
=None,
267 return Popen24Compat(args
, bufsize
=bufsize
, executable
=executable
,
268 stdin
=stdin
, stdout
=stdout
, stderr
=stderr
,
269 preexec_fn
=preexec_fn
, close_fds
=close_fds
, shell
=shell
,
270 cwd
=cwd
, env
=env
, universal_newlines
=universal_newlines
,
271 startupinfo
=startupinfo
, creationflags
=creationflags
)
274 from subprocess
import Popen
, PIPE
279 class SvnBackupOutput
:
281 def __init__(self
, abspath
, filename
):
282 self
.__filename
= filename
283 self
.__absfilename
= os
.path
.join(abspath
, filename
)
288 def write(self
, data
):
294 def get_filename(self
):
295 return self
.__filename
297 def get_absfilename(self
):
298 return self
.__absfilename
301 class SvnBackupOutputPlain(SvnBackupOutput
):
303 def __init__(self
, abspath
, filename
):
304 SvnBackupOutput
.__init
__(self
, abspath
, filename
)
307 self
.__ofd
= open(self
.get_absfilename(), "wb")
309 def write(self
, data
):
310 self
.__ofd
.write(data
)
316 class SvnBackupOutputGzip(SvnBackupOutput
):
318 def __init__(self
, abspath
, filename
):
319 SvnBackupOutput
.__init
__(self
, abspath
, filename
+ ".gz")
322 self
.__compressor
= gzip
.GzipFile(filename
=self
.get_absfilename(),
325 def write(self
, data
):
326 self
.__compressor
.write(data
)
329 self
.__compressor
.flush()
330 self
.__compressor
.close()
333 class SvnBackupOutputBzip2(SvnBackupOutput
):
335 def __init__(self
, abspath
, filename
):
336 SvnBackupOutput
.__init
__(self
, abspath
, filename
+ ".bz2")
339 self
.__compressor
= bz2
.BZ2Compressor()
340 self
.__ofd
= open(self
.get_absfilename(), "wb")
342 def write(self
, data
):
343 self
.__ofd
.write(self
.__compressor
.compress(data
))
346 self
.__ofd
.write(self
.__compressor
.flush())
350 class SvnBackupException(Exception):
352 def __init__(self
, errortext
):
353 self
.errortext
= errortext
356 return self
.errortext
360 def __init__(self
, options
, args
):
361 # need 3 args: progname, reposname, dumpdir
364 raise SvnBackupException
, \
365 "too few arguments, specify repospath and dumpdir."
367 raise SvnBackupException
, \
368 "too many arguments, specify repospath and dumpdir only."
369 self
.__repospath
= args
[1]
370 self
.__dumpdir
= args
[2]
372 rpathparts
= os
.path
.split(self
.__repospath
)
373 if len(rpathparts
[1]) == 0:
374 # repospath without trailing slash
375 self
.__repospath
= rpathparts
[0]
376 if not os
.path
.exists(self
.__repospath
):
377 raise SvnBackupException
, \
378 "repos '%s' does not exist." % self
.__repospath
379 if not os
.path
.isdir(self
.__repospath
):
380 raise SvnBackupException
, \
381 "repos '%s' is not a directory." % self
.__repospath
382 for subdir
in [ "db", "conf", "hooks" ]:
383 dir = os
.path
.join(self
.__repospath
, "db")
384 if not os
.path
.isdir(dir):
385 raise SvnBackupException
, \
386 "repos '%s' is not a repository." % self
.__repospath
387 rpathparts
= os
.path
.split(self
.__repospath
)
388 self
.__reposname
= rpathparts
[1]
389 if self
.__reposname
in [ "", ".", ".." ]:
390 raise SvnBackupException
, \
391 "couldn't extract repos name from '%s'." % self
.__repospath
393 if not os
.path
.exists(self
.__dumpdir
):
394 raise SvnBackupException
, \
395 "dumpdir '%s' does not exist." % self
.__dumpdir
396 elif not os
.path
.isdir(self
.__dumpdir
):
397 raise SvnBackupException
, \
398 "dumpdir '%s' is not a directory." % self
.__dumpdir
400 self
.__rev
_nr
= options
.rev
401 self
.__count
= options
.cnt
402 self
.__quiet
= options
.quiet
403 self
.__deltas
= options
.deltas
404 self
.__zip
= options
.zip
405 self
.__overwrite
= False
406 self
.__overwrite
_all
= False
407 if options
.overwrite
> 0:
408 self
.__overwrite
= True
409 if options
.overwrite
> 1:
410 self
.__overwrite
_all
= True
411 self
.__transfer
= None
412 if options
.transfer
!= None:
413 self
.__transfer
= options
.transfer
.split(":")
414 if len(self
.__transfer
) != 5:
415 if len(self
.__transfer
) < 5:
416 raise SvnBackupException
, \
417 "too few fields for transfer '%s'." % self
.__transfer
419 raise SvnBackupException
, \
420 "too many fields for transfer '%s'." % self
.__transfer
421 if self
.__transfer
[0] not in [ "ftp", "smb" ]:
422 raise SvnBackupException
, \
423 "unknown transfer method '%s'." % self
.__transfer
[0]
425 def set_nonblock(self
, fileobj
):
426 fd
= fileobj
.fileno()
427 n
= fcntl
.fcntl(fd
, fcntl
.F_GETFL
)
428 fcntl
.fcntl(fd
, fcntl
.F_SETFL
, n|os
.O_NONBLOCK
)
430 def exec_cmd(self
, cmd
, output
=None, printerr
=False):
432 return self
.exec_cmd_nt(cmd
, output
, printerr
)
434 return self
.exec_cmd_unix(cmd
, output
, printerr
)
436 def exec_cmd_unix(self
, cmd
, output
=None, printerr
=False):
438 proc
= Popen(cmd
, stdout
=PIPE
, stderr
=PIPE
, shell
=False)
440 return (256, "", "Popen failed (%s ...):\n %s" % (cmd
[0],
441 str(sys
.exc_info()[1])))
444 self
.set_nonblock(stdout
)
445 self
.set_nonblock(stderr
)
446 readfds
= [ stdout
, stderr
]
447 selres
= select
.select(readfds
, [], [])
450 while len(selres
[0]) > 0:
465 if len(readfds
) == 0:
467 selres
= select
.select(readfds
, [], [])
471 return (rc
, bufout
, buferr
)
473 def exec_cmd_nt(self
, cmd
, output
=None, printerr
=False):
475 proc
= Popen(cmd
, stdout
=PIPE
, stderr
=None, shell
=False)
477 return (256, "", "Popen failed (%s ...):\n %s" % (cmd
[0],
478 str(sys
.exc_info()[1])))
482 buf
= stdout
.read(16384)
488 buf
= stdout
.read(16384)
490 return (rc
, bufout
, buferr
)
492 def get_head_rev(self
):
493 cmd
= [ "svnlook", "youngest", self
.__repospath
]
494 r
= self
.exec_cmd(cmd
)
495 if r
[0] == 0 and len(r
[2]) == 0:
496 return int(r
[1].strip())
501 def transfer_ftp(self
, absfilename
, filename
):
504 host
= self
.__transfer
[1]
505 user
= self
.__transfer
[2]
506 passwd
= self
.__transfer
[3]
507 destdir
= self
.__transfer
[4].replace("%r", self
.__reposname
)
508 ftp
= FTP(host
, user
, passwd
)
510 ifd
= open(absfilename
, "rb")
511 ftp
.storbinary("STOR %s" % filename
, ifd
)
513 rc
= len(ifd
.read(1)) == 0
516 raise SvnBackupException
, \
517 "ftp transfer failed:\n file: '%s'\n error: %s" % \
518 (absfilename
, str(e
))
521 def transfer_smb(self
, absfilename
, filename
):
522 share
= self
.__transfer
[1]
523 user
= self
.__transfer
[2]
524 passwd
= self
.__transfer
[3]
527 destdir
= self
.__transfer
[4].replace("%r", self
.__reposname
)
528 cmd
= ("smbclient", share
, "-U", user
, passwd
, "-D", destdir
,
529 "-c", "put %s %s" % (absfilename
, filename
))
530 r
= self
.exec_cmd(cmd
)
536 def transfer(self
, absfilename
, filename
):
537 if self
.__transfer
== None:
539 elif self
.__transfer
[0] == "ftp":
540 self
.transfer_ftp(absfilename
, filename
)
541 elif self
.__transfer
[0] == "smb":
542 self
.transfer_smb(absfilename
, filename
)
544 print "unknown transfer method '%s'." % self
.__transfer
[0]
546 def create_dump(self
, checkonly
, overwrite
, fromrev
, torev
=None):
547 revparam
= "%d" % fromrev
550 revparam
+= ":%d" % torev
552 filename
= "%s.%s.svndmp" % (self
.__reposname
, r
)
555 if self
.__zip
== "gzip":
556 output
= SvnBackupOutputGzip(self
.__dumpdir
, filename
)
558 output
= SvnBackupOutputBzip2(self
.__dumpdir
, filename
)
560 output
= SvnBackupOutputPlain(self
.__dumpdir
, filename
)
561 absfilename
= output
.get_absfilename()
562 realfilename
= output
.get_filename()
564 return os
.path
.exists(absfilename
)
565 elif os
.path
.exists(absfilename
):
567 print "overwriting " + absfilename
569 print "%s already exists." % absfilename
572 print "writing " + absfilename
573 cmd
= [ "svnadmin", "dump",
574 "--incremental", "-r", revparam
, self
.__repospath
]
578 cmd
[2:2] = [ "--deltas" ]
580 r
= self
.exec_cmd(cmd
, output
, True)
584 self
.transfer(absfilename
, realfilename
)
587 def export_single_rev(self
):
588 return self
.create_dump(False, self
.__overwrite
, self
.__rev
_nr
)
591 headrev
= self
.get_head_rev()
594 if self
.__count
is None:
595 return self
.create_dump(False, self
.__overwrite
, 0, headrev
)
596 baserev
= headrev
- (headrev
% self
.__count
)
599 fromrev
= baserev
- cnt
601 while fromrev
>= 0 and rc
:
602 if self
.__overwrite
_all
or \
603 not self
.create_dump(True, False, fromrev
, torev
):
604 rc
= self
.create_dump(False, self
.__overwrite
_all
,
611 rc
= self
.create_dump(False, self
.__overwrite
, baserev
, headrev
)
615 if self
.__rev
_nr
!= None:
616 return self
.export_single_rev()
621 if __name__
== "__main__":
622 usage
= "usage: svn-backup-dumps.py [options] repospath dumpdir"
623 parser
= OptionParser(usage
=usage
, version
="%prog "+__version
)
625 parser
.add_option("-b",
626 action
="store_const", const
="bzip2",
627 dest
="zip", default
=None,
628 help="compress the dump using bzip2.")
629 parser
.add_option("--deltas",
631 dest
="deltas", default
=False,
632 help="pass --deltas to svnadmin dump.")
633 parser
.add_option("-c",
634 action
="store", type="int",
635 dest
="cnt", default
=None,
636 help="count of revisions per dumpfile.")
637 parser
.add_option("-o",
638 action
="store_const", const
=1,
639 dest
="overwrite", default
=0,
640 help="overwrite files.")
641 parser
.add_option("-O",
642 action
="store_const", const
=2,
643 dest
="overwrite", default
=0,
644 help="overwrite all files.")
645 parser
.add_option("-q",
647 dest
="quiet", default
=False,
649 parser
.add_option("-r",
650 action
="store", type="int",
651 dest
="rev", default
=None,
652 help="revision number for single rev dump.")
653 parser
.add_option("-t",
654 action
="store", type="string",
655 dest
="transfer", default
=None,
656 help="transfer dumps to another machine "+
657 "(s.a. --help-transfer).")
658 parser
.add_option("-z",
659 action
="store_const", const
="gzip",
661 help="compress the dump using gzip.")
662 parser
.add_option("--help-transfer",
664 dest
="help_transfer", default
=False,
665 help="shows detailed help for the transfer option.")
666 (options
, args
) = parser
.parse_args(sys
.argv
)
667 if options
.help_transfer
:
668 print "Transfer help:"
671 print " -t ftp:<host>:<user>:<password>:<dest-path>"
673 print " SMB (using smbclient):"
674 print " -t smb:<share>:<user>:<password>:<dest-path>"
679 backup
= SvnBackup(options
, args
)
680 rc
= backup
.execute()
681 except SvnBackupException
, e
:
682 print "svn-backup-dumps.py:", e
684 print "Everything OK."
687 print "An error occured!"