acquia/moonshot

View on GitHub
lib/plugins/backup.rb

Summary

Maintainability
A
55 mins
Test Coverage
# frozen_string_literal: true

require 'rubygems/package'
require 'zlib'
require 'yaml'

module Moonshot
  module Plugins
    # Moonshot plugin class for deflating and uploading files on given hooks
    class Backup # rubocop:disable Metrics/ClassLength
      include Moonshot::CredsHelper

      attr_accessor :bucket,
                    :buckets,
                    :files,
                    :hooks,
                    :target_name,
                    :backup_parameters,
                    :backup_template,
                    :bucket_region

      def initialize
        yield self if block_given?
        validate_configuration
        @target_name ||= '%<app_name>s_%<timestamp>s_%<user>s.tar.gz'
      end

      # Factory method to create preconfigured Backup plugins. Uploads current
      # template and parameter files.
      # @param backup [String] target bucket name
      # @return [Backup] configured backup object
      def self.to_bucket(bucket)
        raise ArgumentError if bucket.nil? || bucket.empty?

        Moonshot::Plugins::Backup.new do |b|
          b.bucket = bucket
          b.backup_parameters = true
          b.backup_template = true
          b.hooks = %i[post_create post_update]
        end
      end

      # Main worker method, creates a tarball of the given files, and uploads
      # to an S3 bucket.
      #
      # @param resources [Resources] injected Moonshot resources
      def backup(resources)
        raise ArgumentError if resources.nil?

        @app_name = resources.controller.config.app_name
        @stack_name = resources.stack.name
        @target_name = render(@target_name)
        @target_bucket = define_bucket
        @parameters = resources.stack.parameters

        return if @target_bucket.nil?

        resources.ilog.start("#{log_message} in progress.") do |s|
          tar_out = tar(@files)
          zip_out = zip(tar_out)
          upload(zip_out)

          s.success("#{log_message} succeeded.")
        rescue StandardError => e
          s.failure("#{log_message} failed: #{e}")
        ensure
          tar_out&.close
          zip_out&.close
        end
      end

      # Dynamically responding to hooks supplied in the constructor
      def method_missing(method_name, *args, &block)
        @hooks.include?(method_name) ? backup(*args) : super
      end

      def respond_to?(method_name, include_private = false)
        @hooks.include?(method_name) || super
      end

      private

      attr_accessor :app_name,
                    :stack_name,
                    :target_bucket

      # Create a tar archive in memory, returning the IO object pointing at the
      # beginning of the archive.
      #
      # @param target_files [Array<String>]
      # @return tar_stream [IO]
      def tar(target_files)
        tar_stream = StringIO.new
        Gem::Package::TarWriter.new(tar_stream) do |writer|
          # adding user files
          unless target_files.nil? || target_files.empty?
            target_files.each do |file|
              file = render(file)
              add_file_to_tar(writer, file)
            end
          end

          # adding parameters
          if @backup_parameters && Moonshot.config.answer_file
            add_str_to_tar(
              writer,
              Moonshot.config.answer_file,
              @parameters
            )
          end

          # adding template file
          if @backup_template
            template_file_path = render('cloud_formation/%<app_name>s.json')
            add_file_to_tar(writer, template_file_path)
          end
        end
        tar_stream.seek(0)
        tar_stream
      end

      # Helper method to add a file to an inmemory tar archive.
      #
      # @param writer [TarWriter]
      # @param file_name [String]
      def add_file_to_tar(writer, file_name)
        writer.add_file(File.basename(file_name), 0o644) do |io|
          File.open(file_name, 'r') { |f| io.write(f.read) }
        rescue Errno::ENOENT
          warn "'#{file_name}' was not found."
        end
      end

      # Helper method to add a file based on an input String as content
      # to an inmemory tar archive.
      #
      # @param writer [TarWriter]
      # @param target_filename [String]
      # @param content [String]
      def add_str_to_tar(writer, target_filename, content)
        writer.add_file(File.basename(target_filename), 0o644) do |io|
          io.write(content.to_yaml)
        end
      end

      # Create a zip archive in memory, returning the IO object pointing at the
      # beginning of the zipfile.
      #
      # @param io_tar [IO] tar stream
      # @return zip_stream [IO] IO stream of zipped file
      def zip(io_tar)
        zip_stream = StringIO.new
        Zlib::GzipWriter.wrap(zip_stream) do |gz|
          gz.write(io_tar.read)
          gz.finish
        end
        zip_stream.seek(0)
        zip_stream
      end

      # Uploads an object from the passed IO stream to the specified bucket
      #
      # @param io_zip [IO] tar stream
      def upload(io_zip)
        opts = {}
        opts[:region] = @bucket_region if @bucket_region
        s3_client(opts).put_object(
          acl: 'private',
          bucket: @target_bucket,
          key: @target_name,
          body: io_zip
        )
      end

      # Renders string with the specified placeholders
      #
      # @param io_zip [String] raw string with placeholders
      # @return [String] rendered string
      def render(placeholder)
        format(
          placeholder,
          app_name: @app_name,
          stack_name: @stack_name,
          timestamp: Time.now.to_i.to_s,
          user: ENV['USER']
        )
      end

      def log_message
        "Uploading '#{@target_name}' to '#{@target_bucket}'"
      end

      def iam_account
        iam_client.list_account_aliases.account_aliases.first
      end

      def define_bucket
        # returning already calculated bucket name
        return @target_bucket if @target_bucket
        # single bucket for all accounts
        return @bucket if @bucket
        # calculating bucket based on account name
        return bucket_by_account(iam_account) if @buckets
      end

      def bucket_by_account(account)
        @buckets[account]
      end

      def validate_configuration
        validate_buckets
        validate_redundant_configuration
        validate_targets
        validate_hooks
      end

      def validate_buckets
        raise ArgumentError, 'You must specify a target bucket.' \
          if (@bucket.nil? || @bucket.empty?) \
          && (@buckets.nil? || @buckets.empty?)
      end

      def validate_redundant_configuration
        raise ArgumentError, 'You can not specify both `bucket` and `buckets`.' \
          if @bucket && @buckets
      end

      def validate_targets
        raise ArgumentError, 'You must specify files to back up.' \
          if (@files.nil? || @files.empty?) \
          && (!@backup_parameters && !@backup_template)
      end

      def validate_hooks
        raise ArgumentError, 'You must specify a hook / hooks to run the backup on.' \
          if hooks.nil? || hooks.empty?

        raise ArgumentError, '`pre_create` and `post_delete` hooks are not supported.' \
          if hooks.include?(:pre_create) || hooks.include?(:post_delete)
      end
    end
  end
end