#!/usr/bin/ruby # # Back up this system to a byteback-enabled server (just some command line # tools and SSH setup). We aim to make sure this backups are easy, complete # and safe for most types of hosting customer. # # See 'man byteback' for more information. require 'getoptlong' require 'resolv' def remove_lockfile! begin File.unlink(@lockfile) rescue Errno::ENOENT end end def error(message) STDERR.print "*** #{message}\n" exit 1 end def verbose(message) print "#{message}\n" end def help puts <: Backup destination (i.e. user@host:/path) --source, -s : Source paths (default: /) --exclude, -x : Exclude paths (defaults: /swap.file, /var/backups/localhost, /var/cache/apt/archives, /var/lib/php5, /tmp, /var/tmp) --verbose, -v: Show rsync command and progress --retry-number, -r : Number of retries on error (default: 3) --retry-delay, -e : Wait number of seconds between retries (default: 1800) --ssh-key, -k : SSH key for connection (default: /etc/byteback/key) --help, -h: Show this message Additional excludes can be specified using /etc/byteback/rsync_filter, which is an rsync filter file. See the rsync man page for information on how this works. EOF remove_lockfile! exit 0 end opts = GetoptLong.new( [ '--help', '-h', GetoptLong::NO_ARGUMENT ], [ '--verbose', '-v', GetoptLong::NO_ARGUMENT ], [ '--source', '-s', GetoptLong::REQUIRED_ARGUMENT ], [ '--destination', '-d', GetoptLong::REQUIRED_ARGUMENT ], [ '--retry-number', '-r', GetoptLong::REQUIRED_ARGUMENT ], [ '--retry-delay', '-e', GetoptLong::REQUIRED_ARGUMENT ], [ '--ssh-key' ,'-k', GetoptLong::REQUIRED_ARGUMENT ] ) @ssh_key = nil @destination = nil @retry_number = 3 @retry_delay = 1800 @sources = nil @excludes = nil @lockfile = "/var/run/byteback/byteback.lock" # Read the default destination if File.exists?("/etc/byteback/destination") @destination = File.read("/etc/byteback/destination").chomp end # Set the default SSH key if File.exists?("/etc/byteback/key") @ssh_key = "/etc/byteback/key" end begin opts.each do |opt,arg| case opt when '--help' help = true when '--verbose' $VERBOSE = true when "--source" @sources ||= [] @sources << arg when "--exclude" @excludes ||= [] @excludes << arg when "--destination" @destination = arg when "--retry-number" @retry_number = arg.to_i when "--retry-delay" @retry_delay = arg.to_i when "--ssh-key" @ssh_key = arg end end rescue => err # any errors, show the help warn err.to_s help = true end # Check the lockfile first if File.directory?(File.dirname(@lockfile)) if File.exists? @lockfile # check the lockfile is sane exist_pid = File.read(@lockfile).to_i if exist_pid > 1 and exist_pid < (File.read("/proc/sys/kernel/pid_max").to_i) begin Process.getpgid(exist_pid) # if no exception, process is running, abort error("Process is running (#{exist_pid} from #{@lockfile})! Exiting now.") rescue Errno::ESRCH # no process running with that pid, pidfile is stale remove_lockfile! end else # lockfile isn't sane, remove it and continue remove_lockfile! end end else Dir.mkdir(File.dirname(@lockfile)) # lockfile didn't exist so just carry on end # Own the pidfile ourselves File.open(@lockfile, "w") do |lockfile| lockfile.puts Process::pid end # # Check our destination # if @destination =~ /^(?:(.+)@)?([^@:]+):(.+)?$/ @destination_user, @destination_host, @destination_path = [$1, $2, $3] else error("Destination must be a remote path, e.g. ssh@host.com:/store/backups") end # # Validate & normalise source directories # @sources = ["/"] if @sources.nil? error("No sources specified") if @sources.empty? @sources = @sources.map do |s| s = s.gsub(/\/+/,"/") error("Can't read directory #{s}") unless File.readable?(s) s end # # Validate and normalise excludes # if @excludes.nil? @excludes = %w( /swap.file /tmp /var/backups/localhost /var/cache/apt/archives /var/lib/php5 /var/tmp ).select do |x| File.exists?(x) end end # # Always add these filesystems # @excludes += %w(/dev /proc /run /sys) @excludes = @excludes.map do |e| e.gsub(/\/+/,"/") end error("Must suply --destination or put it into /etc/bytebackup/destination") unless @destination # # Test ssh connection is good before we start # error("Could not read ssh key #{@ssh_key}") unless File.readable?(@ssh_key) def ssh(*ssh_args) args = ["ssh", "-o", "BatchMode=yes", "-x", "-a", "-i", @ssh_key, "-l", @destination_user, @destination_host ] + ssh_args. map { |a| a ? a : "" } print args.map { |a| / /.match(a) ? "\"#{a}\"" : a }.join(" ")+"\n" if $VERBOSE system(*args) end error("Could not connect to #{@destination}") unless ssh("byteback-receive", "--ping", ($VERBOSE ? "--verbose" : "" )) # # Call rsync to copy certain sources, returns exit status (see man rsync) # def rsync(*sources) # Default options include --inplace because we only care about consistency # at the end of the job, and rsync will do more work for big files without # it. # args = %w(rsync --archive --numeric-ids --delete-delay --inplace --relative) args += [ "--rsync-path", "rsync --fake-super"] args += [ "--rsh", "ssh -o BatchMode=yes -x -a -i #{@ssh_key} -l #{@destination_user}"] args << "--verbose" if $VERBOSE args += @excludes.map { |x| ["--exclude", x] }.flatten # # Add in the rsync excludes and sources files, if present. # if File.exists?("/etc/byteback/excludes") args += ["--exclude-from", "/etc/byteback/excludes"] end # # Add in an rsync_filter if required. # if File.exists?("/etc/byteback/rsync_filter") args += ["--filter", "merge /etc/byteback/rsync_filter"] end args += sources args << @destination print args.map { |a| / /.match(a) ? "\"#{a}\"" : a }.join(" ")+"\n" if $VERBOSE system(*args) return $?.exitstatus end # # We treat exit statuses 0 and 24 as success; 0 is "Success"; 24 is "Partial # transfer due to vanished source files", which we treat as success otherwise # on some hosts the backup process never finishes. # RSYNC_EXIT_STATUSES_TO_ACCEPT = [0, 24] RSYNC_EXIT_STATUSES_TO_RETRY_ON = [10,11,20,21,22,23,30] # Run the file copy, retrying if necessary # loop do status = rsync(*@sources) if RSYNC_EXIT_STATUSES_TO_ACCEPT.any?{|s| s === status} break elsif RSYNC_EXIT_STATUSES_TO_RETRY_ON.any?{|s| s === status} if @retry_number > 0 @retry_number -= 1 sleep @retry_delay redo else error("Maximum number of rsync retries reached") end else error("Fatal rsync error occurred (#{status})") end end # Mark the backup as done on the other end # error("Backup could not be marked complete") unless ssh("sudo", "byteback-snapshot", "--snapshot", ($VERBOSE ? "--verbose" : "")) remove_lockfile!