class PXCBackup::Backupper

Public Class Methods

new(options) click to toggle source
# File lib/pxcbackup/backupper.rb, line 15
def initialize(options)
  @threads = options[:threads] || 1
  @memory = options[:memory] || '100M'

  @defaults_file = options[:defaults_file] || nil
  @throttle = options[:throttle] || nil
  @encrypt = options[:encrypt] || nil
  @encrypt_key = options[:encrypt_key] || nil

  @which = PathResolver.new(options)

  local_repo_path = options[:backup_dir]
  @local_repo = local_repo_path ? Repo.new(local_repo_path, options) : nil

  remote_repo_path = options[:remote]
  @remote_repo = remote_repo_path && !options[:local] ? RemoteRepo.new(remote_repo_path, options) : nil

  @mysql = MySQL.new(options)
end

Public Instance Methods

list_backups() click to toggle source
# File lib/pxcbackup/backupper.rb, line 249
def list_backups
  all_backups.each { |backup| puts backup }
end
make_backup(options = {}) click to toggle source
# File lib/pxcbackup/backupper.rb, line 35
def make_backup(options = {})
  type = options[:type] || :full
  stream = options[:stream] || :xbstream
  compress = options[:compress] || false
  compact = options[:compact] || false
  desync_wait = options[:desync_wait] || 60
  retention = options[:retention] || 100

  raise 'cannot find backup dir' unless @local_repo && File.directory?(@local_repo.path)
  raise 'cannot enable encryption without encryption key' if @encrypt && !@encrypt_key

  arguments = [
    @mysql.auth,
    '--no-timestamp',
    "--extra-lsndir=#{@local_repo.path}",
    "--stream=#{stream.to_s}",
    '--galera-info',
  ]

  if compress
    arguments << '--compress'
  end

  if compact
    arguments << '--compact'
  end

  if @encrypt
    arguments << "--encrypt=#{@encrypt.shellescape}"
    arguments << "--encrypt-key=#{@encrypt_key.shellescape}"
  end

  filename = "#{Time.now.to_i}"
  if type == :incremental
    last_info = read_backup_info(File.join(@local_repo.path, 'xtrabackup_checkpoints'))
    arguments << '--incremental'
    arguments << "--incremental-lsn=#{last_info[:to_lsn]}"
    filename << "_incr"
  else
    filename << '_full'
  end
  filename << ".#{stream.to_s}"
  filename << '.xbcrypt' if @encrypt

  desync_enable(desync_wait)

  Dir.mktmpdir('pxcbackup-') do |dir|
    arguments << dir.shellescape
    Logger.action "Creating backup #{filename}" do
      innobackupex(arguments, File.join(@local_repo.path, filename))
    end
  end

  desync_disable
  rotate(retention)

  if @remote_repo
    Logger.action 'Syncing backups to remote repository' do
      @remote_repo.sync(@local_repo)
    end
  end
end
restore_backup(time, options = {}) click to toggle source
# File lib/pxcbackup/backupper.rb, line 98
def restore_backup(time, options = {})
  skip_confirmation = options[:skip_confirmation] || false
  mysql_start_command = options[:mysql_start_command] || "#{@which.service.shellescape} mysql start"
  mysql_stop_command = options[:mysql_stop_command] || "#{@which.service.shellescape} mysql stop"

  incremental_backups = []
  all_backups.reverse_each do |backup|
    incremental_backups.unshift(backup) if backup.time <= time
    break if incremental_backups.any? && backup.full?
  end
  raise "cannot find any backup before #{time}" if incremental_backups.empty?
  raise "cannot find a full backup before #{time}" unless incremental_backups.first.full?
  restore_time = incremental_backups.last.time

  full_backup = incremental_backups.shift

  Logger.info "[1/#{incremental_backups.size + 1}] Processing #{full_backup.type.to_s} backup from #{full_backup.time}"
  Logger.increase_indentation
  with_extracted_backup(full_backup) do |full_backup_path, full_backup_info|
    raise 'unexpected backup type' unless full_backup_info[:backup_type] == full_backup.type
    raise 'unexpected start LSN' unless full_backup_info[:from_lsn] == 0

    compact = full_backup_info[:compact]

    if full_backup_info[:compress]
      Logger.action 'Decompressing' do
        innobackupex(['--decompress', full_backup_path.shellescape])
      end
    end

    if incremental_backups.any?
      Logger.action "Preparing base backup (LSN #{full_backup_info[:to_lsn]})" do
        innobackupex(['--apply-log', '--redo-only', full_backup_path.shellescape])
      end

      current_lsn = full_backup_info[:to_lsn]

      index = 2
      incremental_backups.each do |incremental_backup|
        Logger.decrease_indentation
        Logger.info "[#{index}/#{incremental_backups.size + 1}] Processing #{incremental_backup.type.to_s} backup from #{incremental_backup.time}"
        Logger.increase_indentation
        with_extracted_backup(incremental_backup) do |incremental_backup_path, incremental_backup_info|
          raise 'unexpected backup type' unless incremental_backup_info[:backup_type] == incremental_backup.type
          raise 'unexpected start LSN' unless incremental_backup_info[:from_lsn] == current_lsn

          compact ||= incremental_backup_info[:compact]

          if incremental_backup_info[:compress]
            Logger.action 'Decompressing' do
              innobackupex(['--decompress', incremental_backup_path.shellescape])
            end
          end

          Logger.action "Applying increment (LSN #{incremental_backup_info[:from_lsn]} -> #{incremental_backup_info[:to_lsn]})" do
            innobackupex(['--apply-log', '--redo-only', full_backup_path.shellescape, "--incremental-dir=#{incremental_backup_path.shellescape}"])
          end

          current_lsn = incremental_backup_info[:to_lsn]
        end
        index += 1
      end
    end

    Logger.decrease_indentation

    action = 'Final prepare'
    arguments = [
      '--apply-log',
    ]

    if compact
      action << ' + rebuild indexes'
      arguments << '--rebuild-indexes'
    end

    Logger.action "#{action}" do
      arguments << full_backup_path.shellescape
      innobackupex(arguments)
    end

    Logger.action 'Attempting to restore Galera info' do
      restore_galera_info(full_backup_path)
    end

    mysql_datadir = @mysql.datadir.chomp('/')
    mysql_datadir_old = mysql_datadir + '_YYYYMMDDhhmmss'

    unless skip_confirmation
      puts
      puts '    BACKUP IS NOW READY TO BE RESTORED'
      puts "    BACKUP TIMESTAMP: #{restore_time}"
      puts '    PLEASE CONFIRM THIS ACTION'
      puts
      puts '    This will:'
      puts '    - stop the MySQL server'
      puts "    - move the current datadir to #{mysql_datadir_old}"
      puts "    - restore the backup to #{mysql_datadir}"
      puts '    - start the MySQL server'
      puts
      puts '    Afterwards you will have to:'
      puts '    - confirm everything is working and synced correctly'
      puts '    - manually create a new full backup (to re-allow incremental backups)'
      puts
      puts '    If MySQL server cannot be started, this might be because this is the'
      puts '    only (remaining) Galera node. If so, manually bootstrap the cluster:'
      puts '    # service mysql bootstrap-pxc'
      puts
      print '    Please type "yes" to continue: '
      confirmation = STDIN.gets.chomp
      puts
      raise 'did not confirm restore' unless confirmation == 'yes'
    end

    Logger.action 'Stopping MySQL server' do
      Command.run(mysql_stop_command)
    end

    stat = File.stat(mysql_datadir)
    uid = stat.uid
    gid = stat.gid

    mysql_datadir_old = mysql_datadir + '_' + Time.now.strftime('%Y%m%d%H%M%S')
    Logger.action "Moving current datadir to #{mysql_datadir_old}" do
      File.rename(mysql_datadir, mysql_datadir_old)
    end

    Logger.action "Restoring backup to #{mysql_datadir}" do
      Dir.mkdir(mysql_datadir)
      innobackupex(['--move-back', full_backup_path.shellescape])
    end

    Logger.action "Chowning #{mysql_datadir}" do
      FileUtils.chown_R(uid, gid, mysql_datadir)
    end

    if @local_repo
      xtrabackup_checkpoints_file = File.join(@local_repo.path, 'xtrabackup_checkpoints')
      if File.file?(xtrabackup_checkpoints_file)
        Logger.action "Removing last backup info" do
          File.delete(xtrabackup_checkpoints_file)
        end
      end
    end

    Logger.action 'Starting MySQL server' do
      Command.run(mysql_start_command)
    end
  end
end

Private Instance Methods

all_backups() click to toggle source
# File lib/pxcbackup/backupper.rb, line 255
def all_backups
  backups = []
  backups += @local_repo.backups if @local_repo
  backups += @remote_repo.backups if @remote_repo
  backups = backups.uniq_by { |backup| backup.time }
  backups.sort
end
desync_disable() click to toggle source
# File lib/pxcbackup/backupper.rb, line 271
def desync_disable
  Logger.action 'Waiting until wsrep_local_recv_queue is empty' do
    sleep(2) until @mysql.get_status('wsrep_local_recv_queue') == '0'
  end
  Logger.info 'Setting wsrep_desync=OFF'
  @mysql.set_variable('wsrep_desync', 'OFF')
end
desync_enable(wait = 60) click to toggle source
# File lib/pxcbackup/backupper.rb, line 263
def desync_enable(wait = 60)
  Logger.info 'Setting wsrep_desync=ON'
  @mysql.set_variable('wsrep_desync', 'ON')
  Logger.action "Waiting for #{wait} seconds" do
    sleep(wait)
  end
end
innobackupex(arguments, output_file = nil) click to toggle source
# File lib/pxcbackup/backupper.rb, line 290
def innobackupex(arguments, output_file = nil)
  command = @which.innobackupex.shellescape
  # --defaults-file has to be the first option passed!
  command << " --defaults-file=#{@defaults_file.shellescape}" if @defaults_file

  arguments.unshift(
    "--ibbackup=#{@which.xtrabackup.shellescape}",
    "--parallel=#{@threads}",
    "--compress-threads=#{@threads}",
    "--rebuild-threads #{@threads}",
    "--use-memory=#{@memory}",
    "--tmpdir=#{Dir.tmpdir.shellescape}",
  )
  arguments << "--throttle=#{@throttle.shellescape}" if @throttle

  command << ' ' + arguments.join(' ')
  command << " > #{output_file.shellescape}" if output_file
  result = Command.run(command)

  unless result[:stderr].lines.to_a.last.match(/ completed OK!$/)
    # Uncomment next line to see what's going on
    #puts result
    raise 'unexpected output from innobackupex'
  end
end
read_backup_info(file) click to toggle source
# File lib/pxcbackup/backupper.rb, line 316
def read_backup_info(file)
  raise "cannot open #{file}" unless File.file?(file)
  result = {}
  File.open(file, 'r') do |file|
    file.each_line do |line|
      key, value = line.chomp.split(/\s*=\s*/, 2)
      case key
      when 'backup_type'
        value = 'full' if value == 'full-backuped'
        value = value.to_sym
      when /_lsn$/
        value = value.to_i
      when 'compact'
        value = (value == '1')
      end
      result[key.to_sym] = value
    end
  end
  result
end
restore_galera_info(dir) click to toggle source
# File lib/pxcbackup/backupper.rb, line 373
def restore_galera_info(dir)
  galera_info_file = File.join(dir, 'xtrabackup_galera_info')
  return unless File.file?(galera_info_file)
  uuid, seqno = nil
  File.open(galera_info_file, 'r') do |file|
    uuid, seqno = file.gets.chomp.split(':')
  end

  version = @mysql.get_status('wsrep_provider_version')
  if version
    version = version.split('(').first
  else
    current_grastate_file = File.join(@mysql.datadir, 'grastate.dat')
    if File.file?(current_grastate_file)
      File.open(current_grastate_file, 'r') do |file|
        file.each_line do |line|
          match = line.match(/^version:\s+(.*)$/)
          if match
            version = match[1]
            break
          end
        end
      end
    end
  end
  return unless version

  File.open(File.join(dir, 'grastate.dat'), 'w') do |file|
    file.write("# GALERA saved state\n")
    file.write("version: #{version}\n")
    file.write("uuid:    #{uuid}\n")
    file.write("seqno:   #{seqno}\n")
    file.write("cert_index:\n")
  end
end
rotate(retention) click to toggle source
# File lib/pxcbackup/backupper.rb, line 279
def rotate(retention)
  Logger.action 'Checking if we have old backups to remove' do
    @local_repo.backups.each do |backup|
      days = (Time.now - backup.time) / 86400
      break if days < retention && backup.full?
      Logger.info "Deleting backup from #{backup.time}"
      backup.delete
    end
  end
end
with_extracted_backup(backup) { |dir, info| ... } click to toggle source
# File lib/pxcbackup/backupper.rb, line 337
def with_extracted_backup(backup)
  Dir.mktmpdir('pxcbackup-') do |dir|
    command = backup.stream_command
    action = 'Extracting'
    if backup.encrypted?
      raise 'need encryption algorithm and key to decrypt this backup' unless @encrypt && @encrypt_key
      command << " | #{@which.xbcrypt.shellescape} -d --encrypt-algo=#{@encrypt.shellescape} --encrypt-key=#{@encrypt_key.shellescape}"
      action << ' + decrypting'
    end
    command <<
      case backup.stream
      when :xbstream
        " | #{@which.xbstream.shellescape} -x -C #{dir.shellescape}"
      when :tar
        " | #{@which.tar.shellescape} -ixf - -C #{dir.shellescape}"
      end
    Logger.action action do
      Command.run(command)
    end

    checkpoints_file = File.join(dir, 'xtrabackup_checkpoints')
    unless File.file?(checkpoints_file)
      Logger.warning 'Could not find xtrabackup_checkpoints: trying to skip faulty backup'
      return
    end

    xtrabackup_binary_file = File.join(dir, 'xtrabackup_binary')
    File.delete(xtrabackup_binary_file) if File.file?(xtrabackup_binary_file)

    info = read_backup_info(checkpoints_file)
    info[:compress] = Dir.glob(File.join(dir, '**', '*.qp')).any?

    yield(dir, info)
  end
end