require 'rubygems' gem 'RequirePaths'; require 'require_paths' require_paths '.','..' require 'fileutils' require 'net/smtp' require 'ihl_ruby/misc_utils' require 'ihl_ruby/logging' require 'ihl_ruby/string_utils' require 'ihl_ruby/xml_utils' require 'ihl_ruby/extend_base_classes' require 'ihl_ruby/shell_extras' require 'ihl_ruby/config' THIS_FILE = __FILE__ THIS_DIR = File.dirname(THIS_FILE) module YoreCore class KeepDaily attr_reader :keep_age def initialize(aKeepAge=14) @keep_age = aKeepAge end def is? true end def age(aDate) end def keep?(aDate) end end class KeepWeekly attr_reader :keep_age def initialize(aKeepAge=14) @keep_age = aKeepAge end def is? end def age(aDate) end def keep?(aDate) end end class KeepMonthly attr_reader :keep_age def initialize(aKeepAge=14) @keep_age = aKeepAge end def is? end def age(aDate) end def keep?(aDate) end end class Yore DEFAULT_CONFIG = { :keep_daily => 14, :keep_weekly => 12, :keep_monthly => 12, :crypto_iv => "3A63775C1E3F291B0925578165EB917E", # apparently a string of up to 32 random hex digits :crypto_key => "07692FC8656F04AE5518B80D38681E038A3C12050DF6CC97CEEC33D800D5E2FE", # apparently a string of up to 64 random hex digits :first_hour => 4, :prefix => 'backup', :log_level => 'INFO', :bucket => '', :email_report => false, :mail_host => '', :mail_port => 25, :mail_helodomain => '', :mail_user => '', :mail_password => '', :mail_from => '', :mail_from_alias => '', :mail_to => '', :mail_to_alias => '', :mail_auth => :plain, :mysqldump => 'mysqldump' } attr_reader :config attr_reader :logger attr_reader :reporter attr_reader :keepers attr_reader :basepath def initialize(aConfig=nil) DEFAULT_CONFIG[:email_report] = false # fixes some bug where this was nil @config = ConfigClass.new(DEFAULT_CONFIG,aConfig) cons = ConsoleLogger.new() cons.level = Logger::Severity.const_get(config[:log_level]) rescue Logger::Severity::INFO report_file = MiscUtils::temp_file @reporter = Logger.new(report_file) @reporter.formatter = ConsoleLogger::ReportFormatter.new @reporter.level = cons.level @logger = MultiLogger.new([cons,@reporter]) #require 'ruby-debug'; debugger @logger.info "Yore file and database backup tool for Amazon S3 " @logger.info "(c) 2009 Buzzware Solutions (www.buzzware.com.au)" @logger.info "-------------------------------------------------" @logger.info "" @logger.info "report file: #{report_file}" configure(@config) end # read the config however its given and return a hash with values in their correct type, and either valid or nil # keys must be :symbols for aOptions. aConfig and aCmdOptions can be strings def configure(aConfig,aCmdOptions = nil,aOptions = nil) config_h = nil case aConfig when Hash,::ConfigClass then config_h = aConfig when REXML::Element then config_h = XmlUtils.read_simple_items(aConfig,'/Yore/SimpleItems') else raise StandardError.new('unsupported type') end config_i = {} config_h.each{|n,v| config_i[n.to_sym] = v} if config_h aCmdOptions.each{|k,v| config_i[k.to_sym] = v} if aCmdOptions config_i.merge!(aOptions) if aOptions config.read(config_i) @keepers = Array.new @keepers << KeepDaily.new(config[:keep_daily]) @keepers << KeepWeekly.new(config[:keep_weekly]) @keepers << KeepMonthly.new(config[:keep_monthly]) @basepath = config_h[:basepath] end def do_action(aAction,aArgs) logger.info "Executing command: #{aAction} ...\n" begin send(aAction,aArgs) rescue Exception => e logger.warn "#{e.class.to_s}: during #{aAction}(#{aArgs.inspect}): #{e.message}" end end def shell(aCommandline,&aBlock) #require 'ruby-debug'; debugger logger.debug "To shell: " + aCommandline result = block_given? ? POpen4::shell(aCommandline,nil,nil,&aBlock) : POpen4::shell(aCommandline) logger.debug "From shell: '#{result.inspect}'" return result[:stdout] end def s3shell(aCommandline) shell(aCommandline) do |r| r[:exitcode] = 1 if r[:stderr].length > 0 end end def get_log logger.close # read in log and return end def get_report MiscUtils::string_from_file(@reporter.logdev.filename) end def self.filemap_from_filelist(aFiles) ancestor_path = MiscUtils.file_list_ancestor(aFiles) filemap = {} aFiles.each do |fp| filemap[fp] = MiscUtils.path_debase(fp,ancestor_path) end filemap end def keep_file?(aFile) end #def self.nice_format(aNumber) # if aNumber >= 100 # sprintf('%.0f', aNumber) # else # sprintf('%.3f', aNumber).sub(/\.0{1,3}$/, '') # end #end # By default, GNU tar suppresses a leading slash on absolute pathnames while creating or reading a tar archive. (You can suppress this with the -p option.) # tar : http://my.safaribooksonline.com/0596102461/I_0596102461_CHP_3_SECT_9#snippet # get files from wherever they are into a single file def collect(aSourceFiles,aDestFile,aParentDir=nil) logger.info "Collecting files ..." logger.info aSourceFiles.join("\n") filelist = filemap = nil if aSourceFiles.is_a?(Hash) filelist = aSourceFiles.keys filemap = aSourceFiles else # assume array filelist = aSourceFiles filemap = Yore.filemap_from_filelist(aSourceFiles) end aParentDir ||= MiscUtils.file_list_ancestor(filelist) listfile = File.join(aParentDir,'.contents') MiscUtils.string_to_file( filelist.sort.map{|p| MiscUtils.path_debase(p, aParentDir)}.join("\n"), listfile ) tarfile = MiscUtils.file_change_ext(aDestFile, 'tar') shell("tar cv --directory=#{aParentDir} --file=#{tarfile} --files-from=#{listfile}") shell("tar --append --directory=#{aParentDir} --file=#{tarfile} .contents") logger.info "Compressing ..." tarfile_size = File.size(tarfile) shell("bzip2 #{tarfile}; mv #{tarfile}.bz2 #{aDestFile}") logger.info "Compressed #{'%.1f' % (tarfile_size*1.0/2**10)} KB to #{'%.1f' % (File.size(aDestFile)*1.0/2**10)} KB" end def pack(aFileIn,aFileOut) logger.info "Encrypting ..." shell "openssl enc -aes-256-cbc -K #{config[:crypto_key]} -iv #{config[:crypto_iv]} -in #{aFileIn} -out #{aFileOut}" end def unpack(aFileIn,aFileOut) shell "openssl enc -d -aes-256-cbc -K #{config[:crypto_key]} -iv #{config[:crypto_iv]} -in #{aFileIn} -out #{aFileOut}" end def ensure_bucket(aBucket=nil) aBucket ||= config[:bucket] logger.info "Ensuring S3 bucket #{aBucket} exists ..." s3shell "s3cmd createbucket #{aBucket}" end # uploads the given file to the current bucket as its basename def upload(aFile) ensure_bucket() logger.info "Uploading #{File.basename(aFile)} to S3 bucket #{config[:bucket]} ..." s3shell "s3cmd put #{config[:bucket]}:#{File.basename(aFile)} #{aFile}" end # downloads the given file from the current bucket as its basename def download(aFile) s3shell "s3cmd get #{config[:bucket]}:#{File.basename(aFile)} #{aFile}" end # calculate the date (with no time component) based on :day_begins_hour and the local time def backup_date(aTime) (aTime.localtime - (config[:first_hour]*3600)).date end # generates filename based on date and config # config : # :first_hour # : def encode_file_name(aTimeNow=Time.now) "#{config[:prefix]}-#{backup_date(aTimeNow).date_numeric}.yor" end # return date based on filename def decode_file_name(aFilename) prefix,date,ext = aFilename.scan(/(.*?)\-(.*?)\.(.*)/).flatten return Time.from_date_numeric(date) end #def set_file_name(aFile,aNewName)# # #end def backup_process(aSourceFiles,aTimeNow=Time.now,aTempDir=nil) aTempDir ||= MiscUtils.make_temp_dir('yore_') temp_file = File.expand_path('backup.tar',aTempDir) collect(aSourceFiles,temp_file) backup_file = File.expand_path(encode_file_name(aTimeNow),aTempDir) pack(temp_file,backup_file) upload(backup_file) end # aDb : Hash containing :db_host,db_user,db_password,db_name, def db_to_file(aDb,aFile) logger.info "Dumping database #{aDb[:db_name]} ..." shell "#{config[:mysqldump]} --host=#{aDb[:db_host]} --user=#{aDb[:db_user]} --password=#{aDb[:db_password]} --databases --skip-extended-insert --add-drop-database #{aDb[:db_name]} > #{aFile}" end def file_to_db(aFile,aDatabase) #run "mysql --user=root --password=prot123ection config[:mail_host], :port => config[:mail_port], :helodomain => config[:mail_helodomain], :user => config[:mail_user], :password => config[:mail_password], :from => config[:mail_from], :from_alias => config[:mail_from_alias], :to => config[:mail_to], :to_alias => config[:mail_to_alias], :auth => config[:mail_auth], :subject => 'backup report', :message => msg ) end def self.database_from_xml(aDatabaseNode) return { :db_host => aDatabaseNode.attributes['Host'], :db_user => aDatabaseNode.attributes['User'], :db_password => aDatabaseNode.attributes['Password'], :db_name => aDatabaseNode.attributes['Name'], :file => XmlUtils::peek_node_value(aDatabaseNode, "ToFile") } end def backup(aJobFiles) return unless job = aJobFiles.is_a?(Array) ? aJobFiles.first : aJobFiles # just use first job xmlRoot = XmlUtils.get_file_root(job) filelist = [] sourceFound = false REXML::XPath.each(xmlRoot, '/Yore/Sources/Source') do |xmlSource| case xmlSource.attributes['Type'] when 'File' then REXML::XPath.each(xmlSource, 'IncludePath') do |xmlPath| filelist += MiscUtils::recursive_file_list(MiscUtils::path_combine(config[:basepath],xmlPath.text)) sourceFound = true end when 'MySql' then # # # ~/dbdump.sql # # REXML::XPath.each(xmlSource, 'Database') do |xmlDb| args = Yore::database_from_xml(xmlDb) file = args.delete(:file) unless args[:db_host] && args[:db_user] && args[:db_password] && args[:db_name] && file raise StandardError.new("Invalid or missing parameter") end db_to_file(args,file) filelist << file sourceFound = true end end end raise StandardError.new("Backup source found but file list empty") if sourceFound && filelist.empty? filelist.uniq! filelist.sort! tempdir = MiscUtils.make_temp_dir('yore') time = Time.now backup_process(filelist,time,tempdir) #clean end def test_email(*aDb) args = { :host => config[:mail_host], :port => config[:mail_port], :helodomain => config[:mail_helodomain], :user => config[:mail_user], :password => config[:mail_password], :from => config[:mail_from], :from_alias => config[:mail_from_alias], :to => config[:mail_to], :to_alias => config[:mail_to_alias], :auth => config[:mail_auth], :subject => 'email test', :message => 'Just testing email sending' } logger.debug args.inspect MiscUtils::send_email(args) end def db_dump(aArgs) return nil unless aArgs return nil unless job = aArgs[0] xmlRoot = XmlUtils.get_file_root(job) xmlDb = nil if db_name = aArgs[1] xmlDb = XmlUtils::single_node(xmlRoot,"/Yore/Sources/Source[@Type='MySql']/Database[@Name='#{db_name}']") else xmlDb = XmlUtils::single_node(xmlRoot,"/Yore/Sources/Source[@Type='MySql']/Database") end raise StandardError.new("No database") unless xmlDb args = Yore.database_from_xml(xmlDb) file = args.delete(:file) unless args[:db_host] && args[:db_user] && args[:db_password] && args[:db_name] && file raise StandardError.new("Invalid or missing parameter") end db_to_file(args,file) end end end