backup/backup

View on GitHub
lib/backup/model.rb

Summary

Maintainability
C
1 day
Test Coverage
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 = /^#{ 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 => ex
          ex
        end
      end

      first_exception, *other_exceptions = storage_results.select { |result| result.is_a? Exception }

      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(/^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})' "
          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