#!/usr/bin/env ruby require 'optparse' require 'yaml' require 'tempfile' require 'timeout' def opt_parse(argv) options = {} @optparse = OptionParser.new do |opts| opts.banner = "Usage: #{__FILE__} --env --database --action [options]" opts.separator '' opts.separator "Wrapper for eybackup used for listing, downloading and restoring database backups. The main purpose for this tool is to" opts.separator "simplify the process of accessing backups from one environment in a different environment (e.g. access Production backups" opts.separator "from Staging). This can also be used for restoring backups within the current environment." opts.separator '' opts.on('-h', '--help', 'Displays this Help message') { puts opts; exit} opts.separator '' opts.separator 'Common Options:' opts.on('-e', '--env environment_name', "Specifies the source environment name for the backups you will be working with.") { |env| options[:env] = env } opts.on('-d', '--database database_name', "Name of the source database to list backups for.") { |dbname| options[:databases] = [dbname] } actions=['list', 'restore', 'download'] opts.on('-a', '--action action_name', "The action you want to perform using the backup #{actions}.") do |action| unless actions.include?(action) puts "Invalid action: '#{action}' must be one of #{actions}\n\n" puts opts exit 1 end options[:action] = action end opts.on('-i', '--index BACKUP_INDEX', 'Download/Restore the backup specified by index','BACKUP_INDEX uses the format #{index_number}:#{db_name}', "The string 'last' will automatically reference the most recent available backup.") do |index| options[:index] = index end opts.on('-f', '--force', 'Force mode, skips all confirmation prompts for use with automated restores.') { options[:force] = true } opts.separator '' opts.separator 'Additional Options:' opts.on('-u', '--aws_secret_id key', 'AWS S3 Key') { |key| options[:aws_key_id] = key } opts.on('-p', '--aws_secret_key secret', 'AWS S3 Secret Key') { |secret| options[:aws_secret_key] = secret } opts.on('-b', '--backup_bucket bucket', 'AWS S3 bucket identifier') { |bucket| options[:backup_bucket] = bucket } opts.on('-v', '--verbose', "Enable debug info for this wrapper.") { $verbose = true } opts.on('-c', '--config', "Alternate config file for eyrestore (default ~/.eyrestore.confg.yml)") do |path| unless File.exists?(path) puts "Invalid config file path '#{path}', no file at that location." exit 1 end options[:def_config] = path end opts.separator '' opts.separator 'Examples:' opts.separator '* List backups from an environment named production for a database named todo' opts.separator " sudo -i #{__FILE__} --env production --database todo --action list" opts.separator '' opts.separator '* Download the most recent backup from an environment named production for a database named todo' opts.separator " sudo -i #{__FILE__} --env production --database todo --action download --index last" opts.separator '' opts.separator "* Download a backup from an environment named production for a database named todo, prompt with a list of available backups" opts.separator " sudo -i #{__FILE__} --env production --database todo --action download" opts.separator '' opts.separator '* Restore the most recent backup from an environment named production for a database named todo' opts.separator " sudo -i #{__FILE__} --env production --database todo --action restore --index last" opts.separator '' end @optparse.parse! options end def debug(msg) puts '[' + Time.now.strftime("%D %T") + ']: DEBUG: ' + msg if $verbose end def find_engine debug("Searching for database engine based on file '/etc/.*.backups.yml'") main_config = Dir['/etc/.*.backups.yml'].first abort "Failed to find eybackup config at '/etc/.*.backups.yml', this needs to run on a database instance." unless main_config debug("Found file '#{main_config}'") File.basename(main_config).split('.')[1] end def extra_validations(options) mandatory = [:databases, :env, :action] mandatory << :index if %w(download restore).include? options[:action] missing = mandatory.select{ |param| options[param].nil? } raise OptionParser::MissingArgument, missing.join(',') unless missing.empty? end def list_backups(engine, db, path) command = "eybackup -e #{engine} -l #{db} -c #{path}" debug("Listing backups with command: #{command}") res = %x{#{command}} last = res.chomp.split("\n").last if last.match(/0 backup\(s\) found/) abort "No Backups Found for that environment and database.\n Tips:\n - double check the source environment name is correct\n - you may have a legacy bucket (grep backup_bucket /etc/.*.backups.yml) in the source environment, pass to eyrestore with --backup_bucket" end res end def gets_timeout( prompt, secs ) print prompt + "[timeout=#{secs}secs]: " Timeout::timeout( secs ) { gets.chomp } rescue Timeout::Error puts "*timeout" '' # return nil if timeout end def backup_idx(listing, index) if index == 'last' last_backup_idx(listing) else index end end def last_backup_idx(listing) last = listing.chomp.split("\n").last if last.split.first.match(/\d+:\w+/) index = last.split.first else index = last.split[3] end end options = opt_parse(ARGV) options[:def_config] = '~/.eyrestore.config.yml' unless options[:def_config] # auto-detect the type of database running in the given environment engine = find_engine # parse eybackup config file config = YAML.load_file("/etc/.#{engine}.backups.yml") this_env = config[:env] # delete these just in case config.delete(:env) config.delete(:databases) # override config from eyrestore config file eyrestore = YAML.load_file(options[:def_config]) if File.exists?(options[:def_config]) config.merge!(eyrestore) unless eyrestore.nil? # override config from command line options config.merge!(options) indexdb = config[:index].split(':')[1] if not config[:index].nil? and config[:index].include? ':' config[:databases] = [indexdb] if config[:databases].nil? and not indexdb.nil? and indexdb.match(/^\w+$/) begin extra_validations(config) rescue OptionParser::MissingArgument => e puts e.message puts @optparse exit end debug("Config Options: '#{config}'") # create a temporary configuration file from ~/.eyrestore.config.yml temp_config = Tempfile.new("eyrestore") temp_config.write(config.to_yaml) temp_config.rewind debug("Generated temporary config file: '#{temp_config.path}'") db = config[:databases].first temp = temp_config.path if config[:action] == 'list' or ! config[:index] puts list_backups(engine, db, temp) end # confirm environment overwrite if restoring if config[:action] == 'restore' and ! config[:force] response = gets_timeout("You are restoring the backup for '#{db}' from '#{config[:env]}' into '#{this_env}', THIS MAY BE DESTRUCTIVE; are you sure you want to proceed (Y/n) ? ", 30) exit unless response.downcase == 'y' end if ['download','restore'].include?(config[:action]) config[:index] = gets_timeout("Enter the backup index to use for #{config[:action]} (e.g. 9:#{db}) ", 30) unless config[:index] config[:index] = backup_idx(list_backups(engine, db, temp), config[:index]) # config[:index] = last_backup_idx(list_backups(engine, db, temp)) if config[:index] == 'last' config[:index] = config[:index] + ":#{config[:databases].first}" if config[:index].match(/^\d+$/) and config[:databases] and config[:databases].size == 1 abort "Invalid backup index '#{config[:index]}'; proper format is :." unless config[:index].match(/^\d+:\w+$/) action = config[:action] == 'restore' ? '-r' : '-d' debug("Set action flag for eybackup to '#{action}' based on action of '#{config[:action]}'") # test eybackup version for force option ey_cloud_server_version = Gem::Version.new(%x{/usr/local/ey_resin/ruby/bin/gem list ey_cloud_server}.split[1].gsub('(','').gsub(')','').gsub(',','')) force = ey_cloud_server_version >= Gem::Version.new('1.4.51') ? '--force' : '' debug("eybackup version '#{ey_cloud_server_version}' so force is set to '#{force}'") command="eybackup -e #{engine} -c #{temp} #{action} #{config[:index]} #{force}" puts "Running '#{config[:action]}' on index '#{config[:index]}'." debug("Running command: #{command}") res=%x{#{command}} puts res last_line = res.split("\n").last if last_line.nil? # No-op, eybackup messaging is adequate. elsif last_line.match(/^Filename/) and config[:action] == 'restore' and not last_line.match(/.gpz$/) puts "Restore complete!" else puts "Download complete!" end end # cleanup temporary config file temp_config.close temp_config.unlink