hawkular/hawkular-client-ruby

View on GitHub
lib/hawkular/operations/operations_api.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'timeout'
require 'monitor'

require 'hawkular/base_client'
require 'websocket-client-simple'
require 'json'

require 'hawkular/inventory/entities'

# Adding a method `perform` for each block so that we can write nice callbacks for this client
class Proc
  class PerformMethodMissing
  end
  def perform(callable, result)
    call(Class.new do
      method_name = callable.to_sym
      define_method(method_name) { |&block| block.nil? ? true : block.call(result) }
      define_method("#{method_name}?") { true }
      define_method(:method_missing) { |*| PerformMethodMissing }
    end.new)
  end
end

# Operations module allows invoking operation on the WildFly agent.
module Hawkular::Operations
  # Client class to interact with the agent via websockets
  class Client < Hawkular::BaseClient
    include WebSocket::Client
    include MonitorMixin

    attr_accessor :ws, :logger

    # helper for parsing the "OperationName=json_payload" messages
    class WebSocket::Frame::Data
      def to_msg_hash
        operation_name, json = split('=', 2)

        # Check if there is a zip file following JSON.
        # This check is done only in the first 100KB, hoping it's unlikely to
        # have such large amount of JSON before a zip file is attached.
        magic_bits = [
          "\x50\x4B\x03\x04", # "PK" + 0x03 + 0x04 = Regular ZIP file
          "\x50\x4B\x05\x06", # "PK" + 0x05 + 0x06 = Empty ZIP
          "\x50\x4B\x07\x08"  # "PK" + 0x07 + 0x08 = Spanned ZIP
        ]
        search_chunk = json[0, 102_400]
        zip_file = nil

        magic_bits.each do |bits|
          idx = search_chunk.index(bits)

          next unless idx

          zip_file = json[idx..-1]
          json = json[0, idx]
          break
        end

        # Parse JSON and, if received, attach zip file
        json = JSON.parse(json)
        json[:attachments] = zip_file

        # Return processed data
        { operationName: operation_name, data: json }
      rescue
        {}
      end
    end

    # Initialize new Client
    #
    # @param [Hash] args Arguments for client.
    # There are two ways of passing in the target host/port: via :host and via :entrypoint. If
    # both are given, then :entrypoint will be used.
    #
    # @option args [String] :entrypoint Base URL of the hawkular server e.g. http://localhost:8080.
    # @option args [String] :host base host:port pair of Hawkular - e.g localhost:8080
    # @option args [Boolean] :use_secure_connection if no entrypoint is provided, determines if use a secure connection
    #                        defaults to false
    # @option args [Hash{String=>String}]  :credentials Hash of (username password) or token
    # @option args [Hash{String=>String}] :options Additional rest client options
    # @option args [Fixnum]  :wait_time Time in seconds describing how long the constructor should block - handshake
    #
    # @example
    #   Hawkular::Operations::Client.new(credentials: {username: 'jdoe', password: 'password'})
    def initialize(args)
      args = {
        credentials: {},
        options: {},
        wait_time: 0.5,
        use_secure_connection: false,
        entrypoint: nil
      }.merge(args)

      if args[:entrypoint]
        uri = URI.parse(args[:entrypoint].to_s)
        args[:host] = "#{uri.host}:#{uri.port}"
        args[:use_secure_connection] = %w[https wss].include?(uri.scheme) ? true : false
      end

      fail Hawkular::ArgumentError, 'no parameter ":host" or ":entrypoint" given' if args[:host].nil?

      super(args[:host], args[:credentials], args[:options])

      @logger = Hawkular::Logger.new

      @url = "ws#{args[:use_secure_connection] ? 's' : ''}://#{args[:host]}/hawkular/command-gateway/ui/ws"
      @credentials = args[:credentials]
      @tenant = args[:options][:tenant]
      @wait_time = args[:wait_time]
    end

    def base64_credentials
      ["#{@credentials[:username]}:#{@credentials[:password]}"].pack('m').delete("\r\n")
    end

    def connect
      return if @connecting || (@ws && @ws.open?)

      @connecting = true

      ws_options = {
        headers:  {
          'Authorization' => 'Basic ' + base64_credentials,
          'Hawkular-Tenant' => @tenant,
          'Accept' => 'application/json'
        }
      }

      @ws = Simple.connect @url, ws_options do |client|
        client.on(:message, once: true) do |msg|
          parsed_message = msg.data.to_msg_hash

          logger = Hawkular::Logger.new
          logger.log("Sent WebSocket message: #{parsed_message}")
        end
      end

      Timeout.timeout(@wait_time) { sleep 0.1 until @ws.open? }
    ensure
      @connecting = false
    end

    # Closes the WebSocket connection
    def close_connection!
      @ws && @ws.close
    end

    # Invokes a generic operation on the WildFly agent
    # (the operation name must be specified in the hash)
    # Note: if success and failure callbacks are omitted, the client will not wait for the Response message
    # @param hash [Hash{String=>Object}] a hash containing: resourceId [String] denoting the resource on
    # which the operation is about to run, feedId [String], operationName [String]
    # @param callback [Block] callback that is run after the operation is done
    def invoke_generic_operation(hash, &callback)
      required = %i[resourceId feedId operationName]
      check_pre_conditions hash, required, &callback

      invoke_operation_helper(hash, &callback)
    end

    # Invokes operation on the WildFly agent that has it's own message type
    # @param operation_payload [Hash{String=>Object}] a hash containing: resourceId [String] denoting
    # the resource on which the operation is about to run, feedId [String]
    # @param operation_name [String] the name of the operation. This must correspond with the message type, they can be
    # found here https://git.io/v2h1a (Use only the first part of the name without the Request/Response suffix), e.g.
    # RemoveDatasource (and not RemoveDatasourceRequest)
    # @param callback [Block] callback that is run after the operation is done
    def invoke_specific_operation(operation_payload, operation_name, &callback)
      fail Hawkular::ArgumentError, 'Operation must be specified' if operation_name.nil?
      required = %i[resourceId feedId]
      check_pre_conditions operation_payload, required, &callback

      invoke_operation_helper(operation_payload, operation_name, &callback)
    end

    # Deploys an archive file into WildFly
    #
    # @param [Hash] hash Arguments for deployment
    # @option hash [String]  :resource_id ID of the WildFly server into which we deploy
    #                        or of the domain controller if we deploy into a server group (in case of domain mode)
    # @option hash [String]  :feed_id feed containing this resource
    # @option hash [String]  :destination_file_name resulting file name
    # @option hash [String]  :binary_content binary content representing the war file
    # @option hash [String]  :enabled whether the deployment should be enabled immediately, or not (default = true)
    # @option hash [String]  :force_deploy whether to replace existing content or not (default = false)
    # @option hash [String]  :server_groups comma-separated list of server groups for the operation (default = ignored)
    #
    # @param callback [Block] callback that is run after the operation is done
    def add_deployment(hash, &callback)
      hash[:enabled] = hash.key?(:enabled) ? hash[:enabled] : true
      hash[:force_deploy] = hash.key?(:force_deploy) ? hash[:force_deploy] : false
      required = %i[resource_id feed_id destination_file_name binary_content]
      check_pre_conditions hash, required, &callback

      operation_payload = prepare_payload_hash([:binary_content], hash)
      invoke_operation_helper(operation_payload, 'DeployApplication', hash[:binary_content], &callback)
    end

    # Undeploy a WildFly deployment
    #
    # @param [Hash] hash Arguments for deployment removal
    # @option hash [String]  :resource_id ID of the WildFly server from which to undeploy the deployment
    # @option hash [String]  :feed_id feed containing this resource
    # @option hash [String]  :deployment_name name of deployment to undeploy
    # @option hash [String]  :remove_content whether to remove the deployment content or not (default = true)
    # @option hash [String]  :server_groups comma-separated list of server groups for the operation (default = ignored)
    #
    # @param callback [Block] callback that is run after the operation is done
    def undeploy(hash, &callback)
      hash[:remove_content] = hash.key?(:remove_content) ? hash[:remove_content] : true
      required = %i[resource_id feed_id deployment_name]
      check_pre_conditions hash, required, &callback

      hash[:destination_file_name] = hash[:deployment_name]

      operation_payload = prepare_payload_hash([:deployment_name], hash)
      invoke_operation_helper(operation_payload, 'UndeployApplication', &callback)
    end

    # Enable a WildFly deployment
    #
    # @param [Hash] hash Arguments for enable deployment
    # @option hash [String]  :resource_id ID of the WildFly server from which to enable the deployment
    # @option hash [String]  :feed_id feed containing this resource
    # @option hash [String]  :deployment_name name of deployment to enable
    # @option hash [String]  :server_groups comma-separated list of server groups for the operation (default = ignored)
    #
    # @param callback [Block] callback that is run after the operation is done
    def enable_deployment(hash, &callback)
      required = %i[resource_id feed_id deployment_name]
      check_pre_conditions hash, required, &callback

      hash[:destination_file_name] = hash[:deployment_name]

      operation_payload = prepare_payload_hash([:deployment_name], hash)
      invoke_operation_helper(operation_payload, 'EnableApplication', &callback)
    end

    # Disable a WildFly deployment
    #
    # @param [Hash] hash Arguments for disable deployment
    # @option hash [String]  :resource_id ID of the WildFly server from which to disable the deployment
    # @option hash [String]  :feed_id feed containing this resource
    # @option hash [String]  :deployment_name name of deployment to disable
    # @option hash [String]  :server_groups comma-separated list of server groups for the operation (default = ignored)
    #
    # @param callback [Block] callback that is run after the operation is done
    def disable_deployment(hash, &callback)
      required = %i[resource_id feed_id deployment_name]
      check_pre_conditions hash, required, &callback

      hash[:destination_file_name] = hash[:deployment_name]

      operation_payload = prepare_payload_hash([:deployment_name], hash)
      invoke_operation_helper(operation_payload, 'DisableApplication', &callback)
    end

    # Restart a WildFly deployment
    #
    # @param [Hash] hash Arguments for restart deployment
    # @option hash [String]  :resource_id ID of the WildFly server from which to restart the deployment
    # @option hash [String]  :feed_id feed containing this resource
    # @option hash [String]  :deployment_name name of deployment to restart
    # @option hash [String]  :server_groups comma-separated list of server groups for the operation (default = ignored)
    #
    # @param callback [Block] callback that is run after the operation is done
    def restart_deployment(hash, &callback)
      required = %i[resource_id feed_id deployment_name]
      check_pre_conditions hash, required, &callback

      hash[:destination_file_name] = hash[:deployment_name]

      operation_payload = prepare_payload_hash([:deployment_name], hash)
      invoke_operation_helper(operation_payload, 'RestartApplication', &callback)
    end

    # Adds a new datasource
    #
    # @param [Hash] hash Arguments for the datasource
    # @option hash [String]  :resourceId ID of the WildFly server into which we add datasource
    # @option hash [String]  :feedId ID of the feed containing the WildFly server
    # @option hash [String]  :xaDatasource XA DS or normal
    # @option hash [String]  :datasourceName name of the datasource
    # @option hash [String]  :jndiName JNDI name
    # @option hash [String]  :driverName this is internal name of the driver in Hawkular
    # @option hash [String]  :driverClass class of driver
    # @option hash [String]  :connectionUrl jdbc connection string
    # @option hash [String]  :datasourceProperties optional properties
    # @option hash [String]  :username username to DB
    # @option hash [String]  :password password to DB
    #
    # @param callback [Block] callback that is run after the operation is done
    def add_datasource(hash, &callback)
      required = %i[resourceId feedId xaDatasource datasourceName jndiName
                    driverName driverClass connectionUrl]
      check_pre_conditions hash, required, &callback

      invoke_specific_operation(hash, 'AddDatasource', &callback)
    end

    # Adds a new datasource
    #
    # @param [Hash] hash Arguments for the datasource
    # @option hash [String]  :resource_id ID of the WildFly server into which we add driver
    # @option hash [String]  :feed_id ID of the feed containing the WildFly server
    # @option hash [String]  :driver_jar_name name of the jar file
    # @option hash [String]  :driver_name name of the jdbc driver (when adding datasource, this is the driverName)
    # @option hash [String]  :module_name name of the JBoss module into which the driver will be installed - 'foo.bar'
    # @option hash [String]  :driver_class fully specified java class of the driver - e.q. 'com.mysql.jdbc.Driver'
    # @option hash [String]  :binary_content driver jar file bits
    #
    # @param callback [Block] callback that is run after the operation is done
    def add_jdbc_driver(hash, &callback)
      required = %i[resource_id feed_id driver_jar_name driver_name module_name
                    driver_class binary_content]
      check_pre_conditions hash, required, &callback

      operation_payload = prepare_payload_hash([:binary_content], hash)
      invoke_operation_helper(operation_payload, 'AddJdbcDriver', hash[:binary_content], &callback)
    end

    # Exports the JDR report
    #
    # @param [String] resource_id ID of the WildFly server
    # @param [String] feed_id ID of the feed containing the WildFly server
    # @param [Boolean] delete_immediately specifies whether the temporary file at the remote
    # server should be deleted. False, by default.
    # @param callback [Block] callback that is run after the operation is done
    def export_jdr(resource_id, feed_id, delete_immediately = false, sender_request_id = nil, &callback)
      fail Hawkular::ArgumentError, 'resource_id must be specified' if resource_id.nil?
      fail Hawkular::ArgumentError, 'feed_id must be specified' if feed_id.nil?
      check_pre_conditions(&callback)

      invoke_specific_operation({ resourceId: resource_id,
                                  feedId: feed_id,
                                  deleteImmediately: delete_immediately,
                                  senderRequestId: sender_request_id },
                                'ExportJdr', &callback)
    end

    # Updates the collection intervals.
    #
    # @param [Hash] hash Arguments for update collection intervals
    # @option hash {resourceId} a resource managed by the target agent
    # @option hash {feedId} the related feed ID
    # @option hash {metricTypes} A map with key=MetricTypeId, value=interval (seconds).
    # MetricTypeId must be of form MetricTypeSet~MetricTypeName
    # @option hash {availTypes} A map with key=AvailTypeId, value=interval (seconds).
    # AvailTypeId must be of form AvailTypeSet~AvailTypeName
    #
    # @param callback [Block] callback that is run after the operation is done
    def update_collection_intervals(hash, &callback)
      required = %i[resourceId feedId metricTypes availTypes]
      check_pre_conditions hash, required, &callback
      invoke_specific_operation(hash, 'UpdateCollectionIntervals', &callback)
    end

    private

    def invoke_operation_helper(operation_payload, operation_name = nil, binary_content = nil, &callback)
      synchronize { connect }

      # fallback to generic 'ExecuteOperation' if nothing is specified
      operation_name ||= 'ExecuteOperation'
      add_credentials! operation_payload

      # if unset, set the :senderRequestId
      operation_payload[:senderRequestId] = SecureRandom.uuid unless operation_payload[:senderRequestId]

      handle_message(operation_name, operation_payload, &callback) unless callback.nil?

      # sends a message that will actually run the operation
      payload = "#{operation_name}Request=#{operation_payload.to_json}"
      payload += binary_content unless binary_content.nil?
      @ws.send payload, type: binary_content.nil? ? :text : :binary
    rescue => e
      callback.perform(:failure, "#{e.class} - #{e.message}")
    end

    def check_pre_conditions(hash = {}, params = [], &callback)
      fail Hawkular::ArgumentError, 'Hash cannot be nil.' if hash.nil?
      fail Hawkular::ArgumentError, 'callback must have the perform method defined. include Hawkular::Operations' unless
          callback.nil? || callback.respond_to?('perform')

      params.each do |property|
        next unless hash[property].nil?
        err_callback = 'You need to specify error callback'
        err_message = "Hash property #{property} must be specified"

        if !callback || callback.perform(:failure, err_message) == Proc::PerformMethodMissing
          fail(Hawkular::ArgumentError, err_callback)
        end
      end
    end

    def add_credentials!(hash)
      hash[:authentication] = @credentials.delete_if { |_, v| v.nil? }
    end

    def handle_message(operation_name, operation_payload, &callback)
      client = @ws
      # register a callback handler
      @ws.on :message do |msg|
        parsed = msg.data.to_msg_hash

        logger = Hawkular::Logger.new
        logger.log("Received WebSocket msg: #{parsed}")

        case parsed[:operationName]
        when "#{operation_name}Response"
          if parsed[:data]['senderRequestId'] == operation_payload[:senderRequestId]
            success = parsed[:data]['status'] == 'OK'
            success ? callback.perform(:success, parsed[:data]) : callback.perform(:failure, parsed[:data]['message'])
            client.remove_listener :message
          end
        when 'GenericErrorResponse'
          Client.handle_error parsed, &callback
          client.remove_listener :message
        end
      end
    end

    def self.handle_error(parsed_message, &callback)
      callback.perform(:failure, parsed_message == {} ? 'error' : parsed_message[:data]['errorMessage'])
    end
    private_class_method :handle_error

    def prepare_payload_hash(ignored_params, hash)
      # it filters out ignored params and convert keys from snake_case to camelCase
      Hash[hash.reject { |k, _| ignored_params.include? k }.map { |k, v| [to_camel_case(k.to_s).to_sym, v] }]
    end

    def to_camel_case(str)
      subs = str.split('_')
      ret = subs.length > 1 ? subs.collect(&:capitalize).join : subs[0]
      ret[0] = ret[0].downcase
      ret
    end
  end
end