Follow-up to r29036: Now that the "mergeinfo" transaction file is no
[svn.git] / tools / server-side / svn-backup-dumps.py
blob3f74ea37902a81b3a1fb2bb5f8ccf6c0683cd378
1 #!/usr/bin/env python
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).
128 # TODO:
129 # - find out how to report smbclient errors
130 # - improve documentation
133 __version = "0.5"
135 import sys
136 import os
137 if os.name != "nt":
138 import fcntl
139 import select
140 import gzip
141 import os.path
142 from optparse import OptionParser
143 from ftplib import FTP
145 try:
146 import bz2
147 have_bz2 = True
148 except ImportError:
149 have_bz2 = False
152 class Popen24Compat:
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):
160 args = tuple(args)
161 elif not isinstance(args, tuple):
162 raise RipperException, "Popen24Compat: args is not tuple or list"
164 self.stdin = None
165 self.stdout = None
166 self.stderr = None
167 self.returncode = None
169 if executable == None:
170 executable = args[0]
172 if stdin == PIPE:
173 stdin, stdin_fd = os.pipe()
174 self.stdin = os.fdopen(stdin_fd)
175 elif stdin == None:
176 stdin = 0
177 else:
178 stdin = stdin.fileno()
179 if stdout == PIPE:
180 stdout_fd, stdout = os.pipe()
181 self.stdout = os.fdopen(stdout_fd)
182 elif stdout == None:
183 stdout = 1
184 else:
185 stdout = stdout.fileno()
186 if stderr == PIPE:
187 stderr_fd, stderr = os.pipe()
188 self.stderr = os.fdopen(stderr_fd)
189 elif stderr == None:
190 stderr = 2
191 else:
192 stderr = stderr.fileno()
194 # error pipe
195 err_read, err_write = os.pipe()
196 fcntl.fcntl(err_write, fcntl.F_SETFD, fcntl.FD_CLOEXEC)
198 self.pid = os.fork()
199 if self.pid < 0:
200 raise Exception, "Popen24Compat: fork"
201 if self.pid == 0:
202 # child
203 os.close(err_read)
204 fcntl.fcntl(err_write, fcntl.F_SETFD, fcntl.FD_CLOEXEC)
205 if self.stdin:
206 self.stdin.close()
207 if self.stdout:
208 self.stdout.close()
209 if self.stderr:
210 self.stderr.close()
211 if stdin != 0:
212 os.dup2(stdin, 0)
213 os.close(stdin)
214 if stdout != 1:
215 os.dup2(stdout, 1)
216 os.close(stdout)
217 if stderr != 2:
218 os.dup2(stderr, 2)
219 os.close(stderr)
220 try:
221 if shell:
222 # should spawn a shell here...
223 os.execvp(executable, args)
224 else:
225 os.execvp(executable, args)
226 except:
227 err = sys.exc_info()[1]
228 # exec error
229 os.write(err_write, str(err))
230 os._exit(255)
231 else:
232 # parent
233 os.close(err_write)
234 if stdin != 0:
235 os.close(stdin)
236 if stdout != 0:
237 os.close(stdout)
238 if stderr != 0:
239 os.close(stderr)
240 sr, sw, se = select.select([ err_read ], [], [ err_read ])
241 if len(se) == 1:
242 os.close(err_read)
243 raise Exception, "Popen24Compat: err pipe read error"
244 if len(sr) == 1:
245 err = os.read(err_read, 1024)
246 os.close(err_read)
247 if len(err) != 0:
248 raise Exception, "Popen24Compat: exec error: " + err
250 def poll(self):
251 self.__wait(os.WNOHANG)
252 return self.returncode
254 def wait(self):
255 self.__wait(0)
256 return self.returncode
258 def __wait(self, options):
259 pid, rc = os.waitpid(self.pid, options)
260 if pid != 0:
261 self.returncode = rc
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,
266 creationflags=0):
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)
273 try:
274 from subprocess import Popen, PIPE
275 except ImportError:
276 Popen = PopenConstr
277 PIPE = -1
279 class SvnBackupOutput:
281 def __init__(self, abspath, filename):
282 self.__filename = filename
283 self.__absfilename = os.path.join(abspath, filename)
285 def open(self):
286 pass
288 def write(self, data):
289 pass
291 def close(self):
292 pass
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)
306 def open(self):
307 self.__ofd = open(self.get_absfilename(), "wb")
309 def write(self, data):
310 self.__ofd.write(data)
312 def close(self):
313 self.__ofd.close()
316 class SvnBackupOutputGzip(SvnBackupOutput):
318 def __init__(self, abspath, filename):
319 SvnBackupOutput.__init__(self, abspath, filename + ".gz")
321 def open(self):
322 self.__compressor = gzip.GzipFile(filename=self.get_absfilename(),
323 mode="wb")
325 def write(self, data):
326 self.__compressor.write(data)
328 def close(self):
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")
338 def open(self):
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))
345 def close(self):
346 self.__ofd.write(self.__compressor.flush())
347 self.__ofd.close()
350 class SvnBackupException(Exception):
352 def __init__(self, errortext):
353 self.errortext = errortext
355 def __str__(self):
356 return self.errortext
358 class SvnBackup:
360 def __init__(self, options, args):
361 # need 3 args: progname, reposname, dumpdir
362 if len(args) != 3:
363 if len(args) < 3:
364 raise SvnBackupException, \
365 "too few arguments, specify repospath and dumpdir."
366 else:
367 raise SvnBackupException, \
368 "too many arguments, specify repospath and dumpdir only."
369 self.__repospath = args[1]
370 self.__dumpdir = args[2]
371 # check repospath
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
392 # check dumpdir
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
399 # set options
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
418 else:
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):
431 if os.name == "nt":
432 return self.exec_cmd_nt(cmd, output, printerr)
433 else:
434 return self.exec_cmd_unix(cmd, output, printerr)
436 def exec_cmd_unix(self, cmd, output=None, printerr=False):
437 try:
438 proc = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=False)
439 except:
440 return (256, "", "Popen failed (%s ...):\n %s" % (cmd[0],
441 str(sys.exc_info()[1])))
442 stdout = proc.stdout
443 stderr = proc.stderr
444 self.set_nonblock(stdout)
445 self.set_nonblock(stderr)
446 readfds = [ stdout, stderr ]
447 selres = select.select(readfds, [], [])
448 bufout = ""
449 buferr = ""
450 while len(selres[0]) > 0:
451 for fd in selres[0]:
452 buf = fd.read(16384)
453 if len(buf) == 0:
454 readfds.remove(fd)
455 elif fd == stdout:
456 if output:
457 output.write(buf)
458 else:
459 bufout += buf
460 else:
461 if printerr:
462 print buf,
463 else:
464 buferr += buf
465 if len(readfds) == 0:
466 break
467 selres = select.select(readfds, [], [])
468 rc = proc.wait()
469 if printerr:
470 print ""
471 return (rc, bufout, buferr)
473 def exec_cmd_nt(self, cmd, output=None, printerr=False):
474 try:
475 proc = Popen(cmd, stdout=PIPE, stderr=None, shell=False)
476 except:
477 return (256, "", "Popen failed (%s ...):\n %s" % (cmd[0],
478 str(sys.exc_info()[1])))
479 stdout = proc.stdout
480 bufout = ""
481 buferr = ""
482 buf = stdout.read(16384)
483 while len(buf) > 0:
484 if output:
485 output.write(buf)
486 else:
487 bufout += buf
488 buf = stdout.read(16384)
489 rc = proc.wait()
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())
497 else:
498 print r[2]
499 return -1
501 def transfer_ftp(self, absfilename, filename):
502 rc = False
503 try:
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)
509 ftp.cwd(destdir)
510 ifd = open(absfilename, "rb")
511 ftp.storbinary("STOR %s" % filename, ifd)
512 ftp.quit()
513 rc = len(ifd.read(1)) == 0
514 ifd.close()
515 except Exception, e:
516 raise SvnBackupException, \
517 "ftp transfer failed:\n file: '%s'\n error: %s" % \
518 (absfilename, str(e))
519 return rc
521 def transfer_smb(self, absfilename, filename):
522 share = self.__transfer[1]
523 user = self.__transfer[2]
524 passwd = self.__transfer[3]
525 if passwd == "":
526 passwd = "-N"
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)
531 rc = r[0] == 0
532 if not rc:
533 print r[2]
534 return rc
536 def transfer(self, absfilename, filename):
537 if self.__transfer == None:
538 return
539 elif self.__transfer[0] == "ftp":
540 self.transfer_ftp(absfilename, filename)
541 elif self.__transfer[0] == "smb":
542 self.transfer_smb(absfilename, filename)
543 else:
544 print "unknown transfer method '%s'." % self.__transfer[0]
546 def create_dump(self, checkonly, overwrite, fromrev, torev=None):
547 revparam = "%d" % fromrev
548 r = "%06d" % fromrev
549 if torev != None:
550 revparam += ":%d" % torev
551 r += "-%06d" % torev
552 filename = "%s.%s.svndmp" % (self.__reposname, r)
553 output = None
554 if self.__zip:
555 if self.__zip == "gzip":
556 output = SvnBackupOutputGzip(self.__dumpdir, filename)
557 else:
558 output = SvnBackupOutputBzip2(self.__dumpdir, filename)
559 else:
560 output = SvnBackupOutputPlain(self.__dumpdir, filename)
561 absfilename = output.get_absfilename()
562 realfilename = output.get_filename()
563 if checkonly:
564 return os.path.exists(absfilename)
565 elif os.path.exists(absfilename):
566 if overwrite:
567 print "overwriting " + absfilename
568 else:
569 print "%s already exists." % absfilename
570 return True
571 else:
572 print "writing " + absfilename
573 cmd = [ "svnadmin", "dump",
574 "--incremental", "-r", revparam, self.__repospath ]
575 if self.__quiet:
576 cmd[2:2] = [ "-q" ]
577 if self.__deltas:
578 cmd[2:2] = [ "--deltas" ]
579 output.open()
580 r = self.exec_cmd(cmd, output, True)
581 output.close()
582 rc = r[0] == 0
583 if rc:
584 self.transfer(absfilename, realfilename)
585 return rc
587 def export_single_rev(self):
588 return self.create_dump(False, self.__overwrite, self.__rev_nr)
590 def export(self):
591 headrev = self.get_head_rev()
592 if headrev == -1:
593 return False
594 if self.__count is None:
595 return self.create_dump(False, self.__overwrite, 0, headrev)
596 baserev = headrev - (headrev % self.__count)
597 rc = True
598 cnt = self.__count
599 fromrev = baserev - cnt
600 torev = baserev - 1
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,
605 fromrev, torev)
606 fromrev -= cnt
607 torev -= cnt
608 else:
609 fromrev = -1
610 if rc:
611 rc = self.create_dump(False, self.__overwrite, baserev, headrev)
612 return rc
614 def execute(self):
615 if self.__rev_nr != None:
616 return self.export_single_rev()
617 else:
618 return self.export()
621 if __name__ == "__main__":
622 usage = "usage: svn-backup-dumps.py [options] repospath dumpdir"
623 parser = OptionParser(usage=usage, version="%prog "+__version)
624 if have_bz2:
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",
630 action="store_true",
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",
646 action="store_true",
647 dest="quiet", default=False,
648 help="quiet.")
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",
660 dest="zip",
661 help="compress the dump using gzip.")
662 parser.add_option("--help-transfer",
663 action="store_true",
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:"
669 print ""
670 print " FTP:"
671 print " -t ftp:<host>:<user>:<password>:<dest-path>"
672 print ""
673 print " SMB (using smbclient):"
674 print " -t smb:<share>:<user>:<password>:<dest-path>"
675 print ""
676 sys.exit(0)
677 rc = False
678 try:
679 backup = SvnBackup(options, args)
680 rc = backup.execute()
681 except SvnBackupException, e:
682 print "svn-backup-dumps.py:", e
683 if rc:
684 print "Everything OK."
685 sys.exit(0)
686 else:
687 print "An error occured!"
688 sys.exit(1)
690 # vim:et:ts=4:sw=4