serverkit/serverkit

View on GitHub
lib/serverkit/resources/base.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require "active_model"
require "active_model/errors"
require "active_support/core_ext/module/delegation"
require "at_least_one_of_validator"
require "readable_validator"
require "regexp_validator"
require "required_validator"
require "serverkit/errors/attribute_validation_error"
require "type_validator"

module Serverkit
  module Resources
    class Base
      class << self
        attr_writer :abstract_class

        def abstract_class?
          !!@abstract_class
        end

        # @note DSL method to define attribute with its validations
        def attribute(name, options = {})
          default = options.delete(:default)
          define_method(name) do
            @attributes[name.to_s] || default
          end
          validates name, options unless options.empty?
        end
      end

      include ActiveModel::Validations

      attr_accessor :backend

      attr_reader :attributes, :check_result, :recheck_result, :recipe

      attribute :check_script, type: String
      attribute :cwd, type: String
      attribute :id, type: String
      attribute :notify, type: Array
      attribute :recheck_script, type: String
      attribute :type, type: String
      attribute :user, type: String

      delegate(
        :send_file,
        to: :backend,
      )

      self.abstract_class = true

      # @param [Serverkit::Recipe] recipe
      # @param [Hash] attributes
      def initialize(recipe, attributes)
        @attributes = attributes
        @recipe = recipe
      end

      # @note For override
      # @return [Array<Serverkit::Errors::Base>]
      def all_errors
        attribute_validation_errors
      end

      # @return [true, false]
      def check_command(*args)
        run_command(*args).success?
      end

      # @return [true, false]
      def check_command_from_identifier(*args)
        run_command_from_identifier(*args).success?
      end

      # @return [String]
      def get_command_from_identifier(*args)
        backend.command.get(*args)
      end

      # @return [Array<Serverkit::Resource>]
      def handlers
        @handlers ||= Array(notify).map do |id|
          recipe.handlers.find do |handler|
            handler.id == id
          end
        end.compact
      end

      # @note For logging and notifying
      # @return [String]
      def id
        @attributes["id"] || default_id
      end

      # @return [true, false] True if this resource should call any handler
      def notifiable?
        @recheck_result == true && !handlers.nil?
      end

      # @note #check and #apply wrapper
      def run_apply
        unless run_check
          apply
          @recheck_result = !!recheck_with_script
        end
      end

      # @note #check wrapper
      # @return [true, false]
      def run_check
        @check_result = !!check_with_script
      end

      def successful?
        successful_on_check? || successful_on_recheck?
      end

      def successful_on_check?
        @check_result == true
      end

      def successful_on_recheck?
        @recheck_result == true
      end

      # @note recipe resource will override to replace itself with multiple resources
      # @return [Array<Serverkit::Resources::Base>]
      def to_a
        [self]
      end

      private

      # @return [Array<Serverkit::Errors::AttributeValidationError>]
      def attribute_validation_errors
        valid?
        errors.map do |attribute_name, message|
          Serverkit::Errors::AttributeValidationError.new(self, attribute_name, message)
        end
      end

      # @note Prior check_script attribute to resource's #check implementation
      def check_with_script
        if check_script
          check_command(check_script)
        else
          check
        end
      end

      # Create a file on remote side
      # @param [String] content
      # @param [String] path
      def create_remote_file(content: nil, path: nil)
        ::Tempfile.open("") do |file|
          file.write(content)
          file.close
          backend.send_file(file.path, path)
        end
      end

      # Create temp file on remote side with given content
      # @param [String] content
      # @return [String] Path to remote temp file
      def create_remote_temp_file(content)
        make_remote_temp_path.tap do |path|
          update_remote_file_content(content: content, path: path)
        end
      end

      # @note For override
      # @return [String]
      def default_id
        required_values.join(" ")
      end

      # @note GNU mktemp doesn't require -t option, but BSD mktemp does
      # @return [String]
      def make_remote_temp_path
        run_command("mktemp -u 2>/dev/null || mktemp -u -t tmp").stdout.rstrip
      end

      # @note "metadata" means a set of group, mode, and owner
      # @param [String] destination
      # @param [String] source
      def move_remote_file_keeping_destination_metadata(source, destination)
        group = run_command_from_identifier(:get_file_owner_group, destination).stdout.rstrip
        mode = run_command_from_identifier(:get_file_mode, destination).stdout.rstrip
        owner = run_command_from_identifier(:get_file_owner_user, destination).stdout.rstrip
        run_command_from_identifier(:change_file_group, source, group) unless group.empty?
        run_command_from_identifier(:change_file_mode, source, mode) unless mode.empty?
        run_command_from_identifier(:change_file_owner, source, owner) unless owner.empty?
        run_command_from_identifier(:move_file, source, destination)
      end

      # @note For override
      # @return [true, false]
      def recheck
        check
      end

      # @note Prior recheck_script attribute to resource's #recheck implementation
      def recheck_with_script
        if recheck_script
          check_command(recheck_script)
        else
          recheck
        end
      end

      # @return [Array<Symbol>]
      def required_attribute_names
        _validators.select do |key, validators|
          validators.grep(::RequiredValidator).any?
        end.keys
      end

      # @return [Array]
      def required_values
        required_attribute_names.map do |required_attribute_name|
          send(required_attribute_name)
        end
      end

      # @note Wraps backend.run_command for `cwd` and `user` attributes
      # @param [String] command one-line shell script to be executed on remote machine
      # @return [Specinfra::CommandResult]
      def run_command(command)
        if cwd
          command = "cd #{Shellwords.escape(cwd)} && #{command}"
        elsif user
          command = "cd && #{command}"
        end
        unless user.nil?
          command = "sudo -H -u #{user} -i -- /bin/bash -i -c #{Shellwords.escape(command)}"
        end
        backend.run_command(command)
      end

      # @return [Specinfra::CommandResult]
      def run_command_from_identifier(*args)
        run_command(get_command_from_identifier(*args))
      end

      # Update remote file content on given path with given content
      # @param [String] content
      # @param [String] path
      def update_remote_file_content(content: nil, path: nil)
        group = run_command_from_identifier(:get_file_owner_group, path).stdout.rstrip
        mode = run_command_from_identifier(:get_file_mode, path).stdout.rstrip
        owner = run_command_from_identifier(:get_file_owner_user, path).stdout.rstrip
        create_remote_file(content: content, path: path)
        run_command_from_identifier(:change_file_group, path, group) unless group.empty?
        run_command_from_identifier(:change_file_mode, path, mode) unless mode.empty?
        run_command_from_identifier(:change_file_owner, path, owner) unless owner.empty?
      end
    end
  end
end