# frozen_string_literal: true module Backup class Model class Error < Backup::Error; end class FatalError < Backup::FatalError; end class << self ## # The Backup::Model.all class method keeps track of all the models # that have been instantiated. It returns the @all class variable, # which contains an array of all the models def all @all ||= [] end ## # Return an Array of Models matching the given +trigger+. def find_by_trigger(trigger) trigger = trigger.to_s if trigger.include?("*") regex = %r{^#{trigger.gsub('*', '(.*)')}$} all.select { |model| regex =~ model.trigger } else all.select { |model| trigger == model.trigger } end end # Allows users to create preconfigured models. def preconfigure(&block) @preconfigure ||= block end private # used for testing def reset! @all = @preconfigure = nil end end ## # The trigger (stored as a String) is used as an identifier # for initializing the backup process attr_reader :trigger ## # The label (stored as a String) is used for a more friendly user output attr_reader :label ## # Array of configured Database objects. attr_reader :databases ## # Array of configured Archive objects. attr_reader :archives ## # Array of configured Notifier objects. attr_reader :notifiers ## # Array of configured Storage objects. attr_reader :storages ## # Array of configured Syncer objects. attr_reader :syncers ## # The configured Compressor, if any. attr_reader :compressor ## # The configured Encryptor, if any. attr_reader :encryptor ## # The configured Splitter, if any. attr_reader :splitter ## # The final backup Package this model will create. attr_reader :package ## # The time when the backup initiated (in format: 2011.02.20.03.29.59) attr_reader :time ## # The time when the backup initiated (as a Time object) attr_reader :started_at ## # The time when the backup finished (as a Time object) attr_reader :finished_at ## # Result of this model's backup process. # # 0 = Job was successful # 1 = Job was successful, but issued warnings # 2 = Job failed, additional triggers may be performed # 3 = Job failed, additional triggers will not be performed attr_reader :exit_status ## # Exception raised by either a +before+ hook or one of the model's # procedures that caused the model to fail. An exception raised by an # +after+ hook would not be stored here. Therefore, it is possible for # this to be +nil+ even if #exit_status is 2 or 3. attr_reader :exception def initialize(trigger, label, &block) @trigger = trigger.to_s @label = label.to_s @package = Package.new(self) @databases = [] @archives = [] @storages = [] @notifiers = [] @syncers = [] instance_eval(&self.class.preconfigure) if self.class.preconfigure instance_eval(&block) if block_given? # trigger all defined databases to generate their #dump_filename # so warnings may be logged if `backup perform --check` is used databases.each { |db| db.send(:dump_filename) } Model.all << self end ## # Adds an Archive. Multiple Archives may be added to the model. def archive(name, &block) @archives << Archive.new(self, name, &block) end ## # Adds an Database. Multiple Databases may be added to the model. def database(name, database_id = nil, &block) @databases << get_class_from_scope(Database, name) .new(self, database_id, &block) end ## # Adds an Storage. Multiple Storages may be added to the model. def store_with(name, storage_id = nil, &block) @storages << get_class_from_scope(Storage, name) .new(self, storage_id, &block) end ## # Adds an Syncer. Multiple Syncers may be added to the model. def sync_with(name, syncer_id = nil, &block) @syncers << get_class_from_scope(Syncer, name).new(syncer_id, &block) end ## # Adds an Notifier. Multiple Notifiers may be added to the model. def notify_by(name, &block) @notifiers << get_class_from_scope(Notifier, name).new(self, &block) end ## # Adds an Encryptor. Only one Encryptor may be added to the model. # This will be used to encrypt the final backup package. def encrypt_with(name, &block) @encryptor = get_class_from_scope(Encryptor, name).new(&block) end ## # Adds an Compressor. Only one Compressor may be added to the model. # This will be used to compress each individual Archive and Database # stored within the final backup package. def compress_with(name, &block) @compressor = get_class_from_scope(Compressor, name).new(&block) end ## # Adds a Splitter to split the final backup package into multiple files. # # +chunk_size+ is specified in MiB and must be given as an Integer. # +suffix_length+ controls the number of characters used in the suffix # (and the maximum number of chunks possible). # ie. 1 (-a, -b), 2 (-aa, -ab), 3 (-aaa, -aab) def split_into_chunks_of(chunk_size, suffix_length = 3) if chunk_size.is_a?(Integer) && suffix_length.is_a?(Integer) @splitter = Splitter.new(self, chunk_size, suffix_length) else raise Error, <<-EOS Invalid arguments for #split_into_chunks_of() +chunk_size+ (and optional +suffix_length+) must be Integers. EOS end end ## # Defines a block of code to run before the model's procedures. # # Warnings logged within the before hook will elevate the model's # exit_status to 1 and cause warning notifications to be sent. # # Raising an exception will abort the model and cause failure notifications # to be sent. If the exception is a StandardError, exit_status will be 2. # If the exception is not a StandardError, exit_status will be 3. # # If any exception is raised, any defined +after+ hook will be skipped. def before(&block) @before = block if block @before end ## # Defines a block of code to run after the model's procedures. # # This code is ensured to run, even if the model failed, **unless** a # +before+ hook raised an exception and aborted the model. # # The code block will be passed the model's current exit_status: # # `0`: Success, no warnings. # `1`: Success, but warnings were logged. # `2`: Failure, but additional models/triggers will still be processed. # `3`: Failure, no additional models/triggers will be processed. # # The model's exit_status may be elevated based on the after hook's # actions, but will never be decreased. # # Warnings logged within the after hook may elevate the model's # exit_status to 1 and cause warning notifications to be sent. # # Raising an exception may elevate the model's exit_status and cause # failure notifications to be sent. If the exception is a StandardError, # the exit_status will be elevated to 2. If the exception is not a # StandardError, the exit_status will be elevated to 3. def after(&block) @after = block if block @after end ## # Performs the backup process # # Once complete, #exit_status will indicate the result of this process. # # If any errors occur during the backup process, all temporary files will # be left in place. If the error occurs before Packaging, then the # temporary folder (tmp_path/trigger) will remain and may contain all or # some of the configured Archives and/or Database dumps. If the error # occurs after Packaging, but before the Storages complete, then the final # packaged files (located in the root of tmp_path) will remain. # # *** Important *** # If an error occurs and any of the above mentioned temporary files remain, # those files *** will be removed *** before the next scheduled backup for # the same trigger. def perform! @started_at = Time.now.utc @time = package.time = started_at.strftime("%Y.%m.%d.%H.%M.%S") log!(:started) before_hook procedures.each do |procedure| procedure.is_a?(Proc) ? procedure.call : procedure.each(&:perform!) end syncers.each(&:perform!) rescue Interrupt @interrupted = true raise rescue Exception => err @exception = err ensure unless @interrupted set_exit_status @finished_at = Time.now.utc log!(:finished) after_hook end end ## # The duration of the backup process (in format: HH:MM:SS) def duration return unless finished_at elapsed_time(started_at, finished_at) end private ## # Returns an array of procedures that will be performed if any # Archives or Databases are configured for the model. def procedures return [] unless databases.any? || archives.any? [-> { prepare! }, databases, archives, -> { package! }, -> { store! }, -> { clean! }] end ## # Clean any temporary files and/or package files left over # from the last time this model/trigger was performed. # Logs warnings if files exist and are cleaned. def prepare! Cleaner.prepare(self) end ## # After all the databases and archives have been dumped and stored, # these files will be bundled in to a .tar archive (uncompressed), # which may be optionally Encrypted and/or Split into multiple "chunks". # All information about this final archive is stored in the @package. # Once complete, the temporary folder used during packaging is removed. def package! Packager.package!(self) Cleaner.remove_packaging(self) end ## # Attempts to use all configured Storages, even if some of them result in # exceptions. Returns true or raises first encountered exception. def store! storage_results = storages.map do |storage| begin storage.perform! rescue => err err end end first_exception, *other_exceptions = storage_results.select do |result| result.is_a? Exception end if first_exception other_exceptions.each do |exception| Logger.error exception.to_s Logger.error exception.backtrace.join('\n') end raise first_exception else true end end ## # Removes the final package file(s) once all configured Storages have run. def clean! Cleaner.remove_package(package) end ## # Returns the class/model specified by +name+ inside of +scope+. # +scope+ should be a Class/Module. # +name+ may be Class/Module or String representation # of any namespace which exists under +scope+. # # The 'Backup::Config::DSL' namespace is stripped from +name+, # since this is the namespace where we define module namespaces # for use with Model's DSL methods. # # Examples: # get_class_from_scope(Backup::Database, 'MySQL') # returns the class Backup::Database::MySQL # # get_class_from_scope(Backup::Syncer, Backup::Config::RSync::Local) # returns the class Backup::Syncer::RSync::Local # def get_class_from_scope(scope, name) klass = scope name = name.to_s.sub(%r{^Backup::Config::DSL::}, "") name.split("::").each do |chunk| klass = klass.const_get(chunk) end klass end ## # Sets or updates the model's #exit_status. def set_exit_status @exit_status = if exception exception.is_a?(StandardError) ? 2 : 3 else Logger.has_warnings? ? 1 : 0 end end ## # Runs the +before+ hook. # Any exception raised will be wrapped and re-raised, where it will be # handled by #perform the same as an exception raised while performing # the model's #procedures. Only difference is that an exception raised # here will prevent any +after+ hook from being run. def before_hook return unless before Logger.info "Before Hook Starting..." before.call Logger.info "Before Hook Finished." rescue Exception => err @before_hook_failed = true ex = err.is_a?(StandardError) ? Error : FatalError raise ex.wrap(err, "Before Hook Failed!") end ## # Runs the +after+ hook. # Any exception raised here will be logged only and the model's # #exit_status will be elevated if neccessary. def after_hook return unless after && !@before_hook_failed Logger.info "After Hook Starting..." after.call(exit_status) Logger.info "After Hook Finished." set_exit_status # in case hook logged warnings rescue Exception => err fatal = !err.is_a?(StandardError) ex = fatal ? FatalError : Error Logger.error ex.wrap(err, "After Hook Failed!") # upgrade exit_status if needed (@exit_status = fatal ? 3 : 2) unless exit_status == 3 end ## # Logs messages when the model starts and finishes. # # #exception will be set here if #exit_status is > 1, # since log(:finished) is called before the +after+ hook. def log!(action) case action when :started Logger.info "Performing Backup for '#{label} (#{trigger})'!\n" \ "[ backup #{VERSION} : #{RUBY_DESCRIPTION} ]" when :finished if exit_status > 1 ex = exit_status == 2 ? Error : FatalError err = ex.wrap(exception, "Backup for #{label} (#{trigger}) Failed!") Logger.error err Logger.error "\nBacktrace:\n\s\s" + err.backtrace.join("\n\s\s") + "\n\n" Cleaner.warnings(self) else msg = "Backup for '#{label} (#{trigger})' ".dup if exit_status == 1 msg << "Completed Successfully (with Warnings) in #{duration}" Logger.warn msg else msg << "Completed Successfully in #{duration}" Logger.info msg end end end end ## # Returns a string representing the elapsed time in HH:MM:SS. def elapsed_time(start_time, finish_time) duration = finish_time.to_i - start_time.to_i hours = duration / 3600 remainder = duration - (hours * 3600) minutes = remainder / 60 seconds = remainder - (minutes * 60) sprintf "%02d:%02d:%02d", hours, minutes, seconds end end end