onddo/chef-encrypted-attributes

View on GitHub
lib/chef/encrypted_attribute/search_helper.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# encoding: UTF-8
#
# Author:: Xabier de Zuazo (<xabier@zuazo.org>)
# Copyright:: Copyright (c) 2014-2015 Onddo Labs, SL.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require 'chef/server_api'
require 'chef/search/query'
require 'chef/encrypted_attribute/exceptions'

class Chef
  class EncryptedAttribute
    # Search Helpers to do normal or partial searches.
    module SearchHelper
      extend self

      # Gets a Chef Search Query object.
      #
      # @return [Chef::Search::Query] search query object instance.
      # @api private
      def query
        Chef::Search::Query.new
      end

      # Escapes a search query string to be used in URLs.
      #
      # @param str [String] query to escape.
      # @return [String] escaped query string.
      # @api private
      def escape(str)
        URI.escape(str.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
      end

      # Escapes a search query array.
      #
      # When multiple queries are provided, the result will be *OR*-ed.
      #
      # @param query [Array<String>, String] search query.
      # @return [String] escaped query string.
      def escape_query(query)
        query_s =
          if query.is_a?(Array)
            query.map { |item| "( #{item} )" }.compact.join(' OR ')
          else
            query.to_s
          end
        escape(query_s)
      end

      # Checks if a Hash key from a search keys structure format is correct.
      #
      # @param k [Mixed] hash key to check.
      # @return [Boolean] `true` if key is a `String` or a `Symbol`.
      # @api private
      def valid_search_keys_key?(k)
        k.is_a?(String) || k.is_a?(Symbol)
      end

      # Checks if a Hash value from a search keys structure format is correct.
      #
      # @param v [Mixed] hash value to check.
      # @return [Boolean] `true` if value is a `Array<String>`.
      # @api private
      def valid_search_keys_value?(v)
        return false unless v.is_a?(Array)
        v.reduce(true) { |a, e| a && e.is_a?(String) }
      end

      # Checks if a search keys structure format is correct.
      #
      # This is an example of a correct search structure:
      #
      # ```ruby
      # {
      #   ipaddress: %w(ipaddress),
      #   mysql_version: %w(mysql version)
      # }
      # ```
      #
      # @param keys [Hash] search keys structure.
      # @return [Boolean] `true` if search keys structure format is correct.
      # @api private
      def valid_search_keys?(keys)
        return false unless keys.is_a?(Hash)
        keys.reduce(true) do |r, (k, v)|
          r && valid_search_keys_key?(k) && valid_search_keys_value?(v)
        end
      end

      # Assert that the search keys structure format is correct.
      #
      # @return void
      # @raise [InvalidSearchKeys] if search keys structure is wrong.
      # @api private
      def assert_search_keys(keys)
        return if valid_search_keys?(keys)
        fail InvalidSearchKeys, "Invalid search keys: #{keys.inspect}"
      end

      # Check if search query is empty.
      #
      # @param query [Array<String>, String] search query.
      # @return [Boolean] `true` if search query is empty.
      # @api private
      def empty_search?(query)
        query.is_a?(String) && query.empty? ||
          query.is_a?(Array) && query.count == 0
      end

      # Translates Chef HTTP exceptions to search exceptions.
      #
      # @yield [] the block doing the Chef Search.
      # @return [Mixed] the value returned by the block.
      # @api private
      def catch_search_exceptions(&block)
        block.call
      rescue Net::HTTPServerException => e
        unless e.response.is_a?(Net::HTTPResponse) && e.response.code == '404'
          raise SearchFailure, "Search exception #{e.class}: #{e}"
        end
        return []
      rescue Net::HTTPFatalError => e
        raise SearchFailure, "Search exception #{e.class}: #{e}"
      end

      # Does a search in the Chef Server.
      #
      # @param type [Symbol] search index to use. See [Chef Search Indexes]
      #   (http://docs.chef.io/chef_search.html#search-indexes).
      # @param query [Array<String>, String] search query. For example:
      #   `%w(admin:true)`. Results will be *OR*-ed when multiple string queries
      #   are provided.
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @param rows [Fixnum, String] maximum number of rows to return.
      # @param partial_search [Boolean] whether to use partial search.
      # @return [Array<Hash>] An array with the response, for example:
      #   `[{ 'ipaddress' => '192.168.1.1' }]`
      # @raise [SearchFailure] if there is a Chef search error.
      # @raise [SearchFatalError] if the Chef search response is wrong.
      # @raise [InvalidSearchKeys] if search keys structure is wrong.
      def search(type, query, keys, rows = 1000, partial_search = true)
        return [] if empty_search?(query) # avoid empty searches
        search_method = partial_search ? :partial_search : :normal_search
        catch_search_exceptions do
          send(search_method, type, nil, query, keys, rows)
        end
      end

      # Does a search in the Chef Server by node or client name.
      #
      # @param type [Symbol] search index to use. See [Chef Search Indexes]
      #   (http://docs.chef.io/chef_search.html#search-indexes).
      # @param name [String] node name to search.
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @param rows [Fixnum, String] maximum number of rows to return.
      # @param partial_search [Boolean] whether to use partial search.
      # @return [Array<Hash>] An array with the response, for example:
      #   `[{ 'ipaddress' => '192.168.1.1' }]`
      # @raise [SearchFailure] if there is a Chef search error.
      # @raise [SearchFatalError] if the Chef search response is wrong.
      # @raise [InvalidSearchKeys] if search keys structure is wrong.
      def search_by_name(type, name, keys, rows = 1000, partial_search = true)
        search_method = partial_search ? :partial_search : :normal_search
        catch_search_exceptions do
          send(search_method, type, name, "name:#{name}", keys, rows)
        end
      end

      # Assert that the normal (no partial) search response is correct.
      #
      # @param resp [Array] normal search result.
      # @return void
      # @raise [SearchFatalError] if the Chef search response is wrong.
      # @api private
      def assert_normal_search_response(resp)
        return if resp.is_a?(Array)
        fail SearchFatalError,
             "Wrong response received from Normal Search: #{resp.inspect}"
      end

      # Parses a normal (no partial) search response row.
      #
      # @param row [Array] the normal search result row.
      # @param attr_ary [Array<String>] key path as Array.
      # @return [Hash] A hash with the response row, for example:
      #   `[ 'ipaddress' => '192.168.1.1' }`
      # @api private
      def parse_normal_search_row_attribute(row, attr_ary)
        attr_ary.reduce(row) do |r, attr|
          if r.respond_to?(attr)
            r.send(attr)
          elsif r.respond_to?(:key?)
            r[attr.to_s] if r.key?(attr.to_s)
          end
        end
      end

      # Filters normal search results that do not correspond to the searched
      # node.
      #
      # Used when searching by node name.
      #
      # @param resp [Array] normal search result.
      # @param name [String, nil] searched node name.
      # @return [Array] The search result removing the filtered results.
      # @raise [SearchFatalError] if more than one result is returned when
      #   searching by node name.
      # @api private
      def filter_normal_search_response(resp, name)
        return resp if name.nil?
        resp.select { |row| row.name == name }.tap do |r|
          fail SearchFatalError,
               'Multiple responses received from Partial Search:'\
               " #{r.inspect}" if r.count > 1
        end
      end

      # Parses a normal (no partial) full search search response.
      #
      # @param resp [Array] normal search result.
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @param name [String, nil] searched node name.
      # @return [Array<Hash>] An array with the response, for example:
      #   `[{ 'ipaddress' => '192.168.1.1' }]`
      # @raise [SearchFatalError] if more than one result is returned when
      #   searching by node name.
      # @api private
      def parse_normal_search_response(resp, keys, name)
        filter_normal_search_response(resp, name).map do |row|
          Hash[keys.map do |key_name, attr_ary|
            value = parse_normal_search_row_attribute(row, attr_ary)
            [key_name, value]
          end]
        end
      end

      # Does a normal (no partial) search in the Chef Server.
      #
      # @param type [Symbol] search index to use. See [Chef Search Indexes]
      #   (http://docs.chef.io/chef_search.html#search-indexes).
      # @param name [String, nil] searched node name.
      # @param query [String, Array<String>] search query. For example:
      #   `%w(admin:true)`. Results will be *OR*-ed when multiple string queries
      #   are provided.
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @param rows [Fixnum, String] maximum number of rows to return.
      # @return [Array<Hash>] An array with the response, for example:
      #   `[{ 'ipaddress' => '192.168.1.1' }]`
      # @raise [InvalidSearchKeys] if search keys structure is wrong.
      # @raise [SearchFatalError] if more than one result is returned when
      #   searching by node name.
      def normal_search(type, name, query, keys, rows = 1000)
        escaped_query = escape_query(query)
        Chef::Log.info(
          "Normal Search query: #{escaped_query}, keys: #{keys.inspect}"
        )
        assert_search_keys(keys)

        resp = self.query.search(type, escaped_query, nil, 0, rows)[0]
        assert_normal_search_response(resp)
        parse_normal_search_response(resp, keys, name)
      end

      # Assert that the partial search response is correct.
      #
      # @param resp [Hash] partial search result. For example:
      #   `{ 'rows' => [ 'data' => { 'ipaddress' => '192.168.1.1' } }] }`.
      # @return void
      # @raise [SearchFatalError] if the Chef search response is wrong.
      # @api private
      def assert_partial_search_response(resp)
        return if resp.is_a?(Hash) && resp.key?('rows') &&
                  resp['rows'].is_a?(Array)
        fail SearchFatalError,
             "Wrong response received from Partial Search: #{resp.inspect}"
      end

      # Adds the `name` key to the search keys structure.
      #
      # Used to get the node name when searching nodes by name.
      #
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @return [Hash] the search keys structure including the `name` key. For
      #   example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version),
      #     name: %w(name) }`.
      # @api private
      def generate_partial_search_keys(keys)
        keys.merge('name' => %w(name))
      end

      # Filters partial search results that do not correspond to the searched
      # node.
      #
      # Used when searching by node name.
      #
      # @param resp [Hash] partial search result. For example:
      #   `{ 'rows' => [ 'data' => { 'ipaddress' => '192.168.1.1' } }] }`.
      # @param name [String, nil] searched node name.
      # @return [Hash] The search result removing the filtered results.
      # @raise [SearchFatalError] if more than one result is returned when
      #   searching by node name.
      # @api private
      def filter_partial_search_response(resp, name)
        return resp if name.nil?
        filtered_resp = resp.select do |row|
          row['data']['name'] == name
        end
        filtered_resp.tap do |r|
          fail SearchFatalError,
               'Multiple responses received from Partial Search:'\
               " #{r.inspect}" if r.count > 1
        end
      end

      # Parses a partial full search search response.
      #
      # @param resp [Hash] partial search result. For example:
      #   `{ 'rows' => [ 'data' => { 'ipaddress' => '192.168.1.1' } }] }`.
      # @param name [String, nil] searched node name.
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @return [Array<Hash>] An array with the response, for example:
      #   `[{ 'ipaddress' => '192.168.1.1' }]`
      # @raise [SearchFatalError] if the Chef search response is wrong.
      # @api private
      def parse_partial_search_response(resp, name, keys)
        filter_partial_search_response(resp['rows'], name).map do |row|
          if row.is_a?(Hash) && row['data'].is_a?(Hash)
            row['data'].tap { |r| r.delete('name') unless keys.key?('name') }
          else
            fail SearchFatalError,
                 "Wrong row format received from Partial Search: #{row.inspect}"
          end
        end.compact
      end

      # Does a partial search in the Chef Server.
      #
      # @param type [Symbol] search index to use. See [Chef Search Indexes]
      #   (http://docs.chef.io/chef_search.html#search-indexes).
      # @param name [String, nil] searched node name.
      # @param query [String, Array<String>] search query. For example:
      #   `%w(admin:true)`. Results will be *OR*-ed when multiple string queries
      #   are provided.
      # @param keys [Hash] search keys structure. For example:
      #   `{ipaddress: %w(ipaddress), mysql_version: %w(mysql version) }`.
      # @param rows [Fixnum, String] maximum number of rows to return.
      # @return [Array<Hash>] An array with the response, for example:
      #   `[{ 'ipaddress' => '192.168.1.1' }]`
      # @raise [InvalidSearchKeys] if search keys structure is wrong.
      # @raise [SearchFatalError] if the Chef search response is wrong.
      def partial_search(type, name, query, keys, rows = 1000)
        escaped_query =
          "search/#{escape(type)}?q=#{escape_query(query)}&start=0&rows=#{rows}"
        Chef::Log.info(
          "Partial Search query: #{escaped_query}, keys: #{keys.inspect}"
        )
        assert_search_keys(keys)

        rest = Chef::ServerAPI.new(Chef::Config[:chef_server_url])
        resp = rest.post(escaped_query, generate_partial_search_keys(keys))
        assert_partial_search_response(resp)
        parse_partial_search_response(resp, name, keys)
      end
    end
  end
end