HewlettPackard/oneview-sdk-ruby

View on GitHub
lib/oneview-sdk/cli.rb

Summary

Maintainability
F
5 days
Test Coverage
# (c) Copyright 2016 Hewlett Packard Enterprise Development LP
#
# 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 'thor'
require 'json'
require 'yaml'
require 'highline/import'

module OneviewSDK
  # command-line-interface for oneview-sdk
  # When you install this gem, this cli should be available to you by running: `$ oneview-sdk-ruby`
  class Cli < Thor
    # Runner class to enable testing
    class Runner
      def initialize(argv, stdin = STDIN, stdout = STDOUT, stderr = STDERR, kernel = Kernel)
        @argv = argv
        @stdin = stdin
        @stdout = stdout
        @stderr = stderr
        @kernel = kernel
      end

      def execute!
        exit_code = begin
          $stderr = @stderr
          $stdin = @stdin
          $stdout = @stdout

          OneviewSDK::Cli.start(@argv)
          0
        rescue StandardError => e
          b = e.backtrace
          @stderr.puts("#{b.shift}: #{e.message} (#{e.class})")
          @stderr.puts(b.map { |s| "\tfrom #{s}" }.join("\n"))
          1
        rescue SystemExit => e
          e.status
        end

        # Proxy our exit code back to the injected kernel.
        @kernel.exit(exit_code)
      end
    end

    SUPPORTED_VARIANTS = OneviewSDK::API300::SUPPORTED_VARIANTS

    class_option :ssl_verify,
      type: :boolean,
      desc: 'Enable/Disable SSL verification for requests. Uses ENV[\'ONEVIEWSDK_SSL_ENABLED\']',
      default: nil

    class_option :url,
      desc: "URL of OneView appliance. Uses ENV['ONEVIEWSDK_URL']",
      aliases: '-u'

    class_option :api_version,
      type: :numeric,
      banner: 'VERSION',
      desc: "API version to use. Uses ENV['ONEVIEWSDK_API_VERSION']"

    class_option :variant,
      desc: "API variant to use. Uses ENV['ONEVIEWSDK_VARIANT']",
      enum: SUPPORTED_VARIANTS

    class_option :log_level,
      desc: 'Log level to use',
      aliases: '-l',
      enum: %w[debug info warn error],
      default: :warn

    map ['-v', '--version'] => :version


    desc 'console', 'Open a Ruby console with a connection to OneView'
    # Open a Ruby console with a connection to OneView
    def console
      client_setup({}, true, true)
      puts "Console Connected to #{@client.url}"
      puts "HINT: The @client object is available to you\n\n"
    rescue
      puts "WARNING: Couldn't connect to #{@options['url'] || ENV['ONEVIEWSDK_URL']}\n\n"
    ensure
      require 'pry'
      Pry.config.prompt = proc { '> ' }
      Pry.plugins['stack_explorer'] && Pry.plugins['stack_explorer'].disable!
      Pry.plugins['byebug'] && Pry.plugins['byebug'].disable!
      Pry.start(OneviewSDK::Console.new(@client))
    end

    desc 'version', 'Print gem and OneView appliance versions'
    # Print gem and OneView appliance versions
    def version
      puts "Gem Version: #{OneviewSDK::VERSION}"
      client_setup({ 'log_level' => :error }, true)
      puts "OneView appliance API version at '#{@client.url}' = #{@client.max_api_version}"
    rescue StandardError, SystemExit
      puts 'OneView appliance API version unknown'
    end

    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml human],
      default: 'human'
    desc 'env', 'Show environment variables for oneview-sdk-ruby'
    # Show environment variables for oneview-sdk-ruby
    def env
      data = {}
      OneviewSDK::ENV_VARS.each { |k| data[k] = ENV[k] }
      if @options['format'] == 'human'
        data.each do |key, value|
          value = "'#{value}'" if value && !%w[true false].include?(value)
          printf "%-#{data.keys.max_by(&:length).length}s = %s\n", key, value || 'nil'
        end
      else
        output(parse_hash(data, true))
      end
    end

    desc 'login', 'Attempt authentication and return token'
    # Attempt authentication and return token
    def login
      client_setup
      puts "Login Successful! Token = #{@client.token}"
    end

    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml human],
      default: 'human'
    method_option :attribute,
      type: :string,
      desc: 'Comma-separated list of attributes to show. Supports nesting/chaining with periods',
      aliases: '-a'
    list_examples =  "\n  oneview-sdk-ruby list ServerProfiles"
    list_examples << "\n  oneview-sdk-ruby list ServerHardware -a serialNumber,mpHostInfo.mpHostName"
    desc 'list TYPE', "List resources. Examples:#{list_examples}"
    # List names of resources (and optionally, specific attributes)
    def list(type)
      resource_class = parse_type(type)
      client_setup
      all = resource_class.get_all(@client)
      if options['attribute']
        data = select_attributes_from_multiple(options['attribute'], all)
        output data, -2 # Shift left by 2 so things look right
      else # List names only by default
        names = []
        all.each { |r| names.push(r['name']) }
        output names
      end
    end

    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml human],
      default: 'human'
    method_option :attribute,
      type: :string,
      desc: 'Comma-separated list of attributes to show. Supports nesting/chaining with periods',
      aliases: '-a'
    desc 'show TYPE NAME', 'Show resource details'
    show_examples =  "\n  oneview-sdk-ruby show ServerProfile 'Profile 1'"
    show_examples << "\n  oneview-sdk-ruby show ServerHardware 'Rack1, bay 1' -a serialNumber,mpHostInfo.mpHostName"
    desc 'show TYPE NAME', "Show resource details. Examples:#{show_examples}"
    # Show resource details
    def show(type, name)
      resource_class = parse_type(type)
      client_setup
      matches = resource_class.find_by(@client, name: name)
      fail_nice 'Not Found' if matches.empty?
      data = matches.first.data
      if options['attribute']
        data = select_attributes(options['attribute'], data)
      end
      output data
    end

    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml human],
      default: 'human'
    method_option :attribute,
      type: :string,
      desc: 'Comma-separated list of attributes to show. Supports nesting/chaining with periods',
      aliases: '-a'
    method_option :filter,
      type: :hash,
      desc: 'Hash of key/value pairs to filter on',
      required: true
    search_examples =  "\n  oneview-sdk-ruby search ServerProfiles --filter=status:Critical"
    search_examples << "\n  oneview-sdk-ruby search ServerHardware --filter=state:ProfileApplied -a mpHostInfo.mpHostName"
    desc 'search TYPE', "Search for resource by key/value pair(s). Examples:#{search_examples}"

    # Search for resource by key/value pair(s)
    def search(type)
      resource_class = parse_type(type)
      client_setup
      filter = parse_hash(options['filter'])
      matches = resource_class.find_by(@client, filter)
      if matches.empty? # Search with integers & booleans converted
        filter = parse_hash(options['filter'], true)
        matches = resource_class.find_by(@client, filter) unless filter == options['filter']
      end
      if options['attribute']
        data = select_attributes_from_multiple(options['attribute'], matches)
        output data, -2 # Shift left by 2 so things look right
      else # List names only by default
        names = []
        matches.each { |m| names.push(m['name']) }
        output names
      end
    end

    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml raw],
      default: 'json'
    method_option :data,
      desc: 'Data to pass in the request body (in JSON format)',
      aliases: '-d'
    rest_examples =  "\n  oneview-sdk-ruby rest GET rest/fc-networks"
    rest_examples << "\n  oneview-sdk-ruby rest PUT rest/fc-networks/<id> -d '{\"linkStabilityTime\": 20, ...}'"
    rest_examples << "\n  oneview-sdk-ruby rest PUT rest/enclosures/<id>/configuration"
    desc 'rest METHOD URI', "Make REST call to the OneView API. Examples:#{rest_examples}"
    # Make REST call to the OneView API
    def rest(method, uri)
      log_level = @options['log_level'] == :warn ? :error : @options['log_level'].to_sym # Default to :error
      client_setup('log_level' => log_level)
      uri_copy = uri.dup
      uri_copy.prepend('/') unless uri_copy.start_with?('/')
      if @options['data']
        begin
          data = { body: JSON.parse(@options['data']) }
        rescue JSON::ParserError => e
          fail_nice("Failed to parse data as JSON\n#{e.message}")
        end
      end
      data ||= {}
      response = @client.rest_api(method, uri_copy, data)
      if response.code.to_i.between?(200, 299)
        case @options['format']
        when 'yaml'
          puts JSON.parse(response.body).to_yaml
        when 'json'
          puts JSON.pretty_generate(JSON.parse(response.body))
        else # raw
          puts response.body
        end
      else
        body = JSON.pretty_generate(JSON.parse(response.body)) rescue response.body
        fail_nice("Request failed: #{response.inspect}\nHeaders: #{response.to_hash}\nBody: #{body}")
      end
    rescue OneviewSDK::InvalidRequest => e
      fail_nice(e.message)
    end

    method_option :hash,
      type: :hash,
      desc: 'Hash of key/value pairs to update',
      aliases: '-h'
    method_option :json,
      desc: 'JSON data to pass in the request body',
      aliases: '-j'
    update_examples =  "\n  oneview-sdk-ruby update FCNetwork FC1 -h linkStabilityTime:20"
    update_examples << "\n  oneview-sdk-ruby update Volume VOL1 -j '{\"shareable\": true}'"
    desc 'update TYPE NAME --[hash|json] <data>', "Update resource by name. Examples:#{update_examples}"
    # Update resource by name
    def update(type, name)
      resource_class = parse_type(type)
      client_setup
      fail_nice 'Must set the hash or json option' unless @options['hash'] || @options['json']
      fail_nice 'Must set the hash OR json option. Not both' if @options['hash'] && @options['json']
      begin
        data = @options['hash'] || JSON.parse(@options['json'])
      rescue JSON::ParserError => e
        fail_nice("Failed to parse json\n#{e.message}")
      end
      matches = resource_class.find_by(@client, name: name)
      fail_nice 'Not Found' if matches.empty?
      resource = matches.first
      begin
        resource.update(data)
        puts 'Updated Successfully!'
      rescue StandardError => e
        fail_nice "Failed to update #{resource.class.name.split('::').last} '#{name}': #{e}"
      end
    end

    method_option :force,
      desc: 'Delete without confirmation',
      type: :boolean,
      aliases: '-f'
    desc 'delete TYPE NAME', 'Delete resource by name'
    # Delete resource by name
    def delete(type, name)
      resource_class = parse_type(type)
      client_setup
      matches = resource_class.find_by(@client, name: name)
      fail_nice('Not Found', 2) if matches.empty?
      resource = matches.first
      return unless options['force'] || agree("Delete '#{name}'? [Y/N] ")
      begin
        resource.delete
        puts 'Deleted Successfully!'
      rescue StandardError => e
        fail_nice "Failed to delete #{resource.class.name.split('::').last} '#{name}': #{e}"
      end
    end

    method_option :force,
      desc: 'Delete without confirmation',
      type: :boolean,
      aliases: '-f'
    desc 'delete_from_file FILE_PATH', 'Delete resource defined in file'
    # Delete resource defined in file
    def delete_from_file(file_path)
      client_setup
      resource = OneviewSDK::Resource.from_file(@client, file_path)
      fail_nice("#{resource.class.name.split('::').last} '#{resource[:name] || resource[:uri]}' Not Found", 2) unless resource.retrieve!
      return unless options['force'] || agree("Delete '#{resource[:name]}'? [Y/N] ")
      begin
        resource.delete
        puts 'Deleted Successfully!'
      rescue StandardError => e
        fail_nice "Failed to delete #{resource.class.name.split('::').last} '#{resource[:name]}': #{e}"
      end
    rescue IncompleteResource => e
      fail_nice "Failed to delete #{resource.class.name.split('::').last} '#{resource[:name]}': #{e}"
    rescue SystemCallError => e # File open errors
      fail_nice e
    end

    method_option :if_missing,
      desc: 'Only create if missing (Don\'t update)',
      type: :boolean,
      aliases: '-i'
    desc 'create_from_file FILE_PATH', 'Create/Update resource defined in file'
    # Create/Update resource defined in file
    def create_from_file(file_path)
      client_setup
      resource = OneviewSDK::Resource.from_file(@client, file_path)
      fail_nice 'Failed to determine resource type!' if resource.class == OneviewSDK::Resource
      existing_resource = resource.class.new(@client, resource.data)
      resource.data.delete('uri')
      if existing_resource.retrieve!
        if options['if_missing']
          puts "Skipped: #{resource.class.name.split('::').last} '#{resource[:name]}' already exists.\n#{existing_resource[:uri]}"
          return
        end
        if existing_resource.like?(resource.data)
          puts "Skipped: #{resource.class.name.split('::').last} '#{resource[:name]}' is up to date.\n#{existing_resource[:uri]}"
          return
        end
        begin
          existing_resource.update(resource.data)
          puts "Updated Successfully!\n#{existing_resource[:uri]}"
        rescue StandardError => e
          fail_nice "Failed to update #{resource.class.name.split('::').last} '#{resource[:name]}': #{e}"
        end
      else
        begin
          resource.create
          puts "Created Successfully!\n#{resource[:uri]}"
        rescue StandardError => e
          fail_nice "Failed to create #{resource.class.name.split('::').last} '#{resource[:name]}': #{e}"
        end
      end
    rescue IncompleteResource => e
      fail_nice "Failed to create #{resource.class.name.split('::').last} '#{resource[:name]}': #{e}"
    rescue SystemCallError => e # File open errors
      fail_nice e
    end

    method_option :path,
      desc: 'File path to save resource in',
      type: :string,
      aliases: '-p',
      required: true
    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml],
      default: 'json'
    desc 'to_file TYPE NAME', 'Save resource details to file'
    # Save resource details to file
    def to_file(type, name)
      file = File.expand_path(options['path'])
      resource_class = parse_type(type)
      client_setup
      resource = resource_class.find_by(@client, name: name).first
      fail_nice "#{resource_class.name.split('::').last} '#{name}' not found" unless resource
      resource.to_file(file, options['format'])
      puts "Output to #{file}"
    rescue SystemCallError => e
      fail_nice "Failed to create file! (You may need to create the necessary directories). Message: #{e}"
    end

    desc 'cert check|import|list URL', 'Check, import, or list OneView certs'
    # Check, import, or list OneView certs
    def cert(type, url = ENV['ONEVIEWSDK_URL'])
      case type.downcase
      when 'check'
        fail_nice 'Must specify a url' unless url
        puts "Checking certificate for '#{url}' ..."
        if OneviewSDK::SSLHelper.check_cert(url)
          puts 'Certificate is valid!'
        else
          fail_nice 'Certificate Validation Failed!'
        end
      when 'import'
        fail_nice 'Must specify a url' unless url
        puts "Importing certificate for '#{url}' into '#{OneviewSDK::SSLHelper::CERT_STORE}'..."
        OneviewSDK::SSLHelper.install_cert(url)
      when 'list'
        if File.file?(OneviewSDK::SSLHelper::CERT_STORE)
          puts File.read(OneviewSDK::SSLHelper::CERT_STORE)
        else
          puts 'No certs imported!'
        end
      else fail_nice "Invalid action '#{type}'. Valid actions are [check, import, list]"
      end
    rescue StandardError => e
      fail_nice e.message
    end

    method_option :route,
      desc: 'Routing key to filter messages',
      type: :string,
      aliases: '-r',
      default: OneviewSDK::SCMB::DEFAULT_ROUTING_KEY
    method_option :format,
      desc: 'Output format',
      aliases: '-f',
      enum: %w[json yaml raw],
      default: 'json'
    scmb_examples = "\n  oneview-sdk-ruby scmb -r 'scmb.ethernet-networks.#'"
    scmb_examples << "\n  oneview-sdk-ruby scmb -r 'scmb.ethernet-networks.Created'"
    scmb_examples << "\n  oneview-sdk-ruby scmb -r 'scmb.ethernet-networks.Updated.<resource_uri>'"
    desc 'scmb', "Subscribe to the OneView State Change Message Bus. Examples:#{scmb_examples}"
    # Subscribe to the OneView SCMB
    def scmb
      client_setup
      connection = OneviewSDK::SCMB.new_connection(@client)
      q = OneviewSDK::SCMB.new_queue(connection, @options['route'])
      puts 'Subscribing to OneView messages. To exit, press Ctrl + c'
      q.subscribe(block: true) do |_delivery_info, _properties, payload|
        data = JSON.parse(payload) rescue payload
        puts "\n#{'=' * 50}\n\nReceived message with payload:"
        @options['format'] == 'raw' ? puts(payload) : output(data)
      end
    end

    private

    def fail_nice(msg = nil, exit_code = 1)
      puts "ERROR: #{msg}" if msg
      exit exit_code
    end

    def client_setup(client_params = {}, quiet = false, throw_errors = false)
      client_params['ssl_enabled'] = true if @options['ssl_verify'] == true
      client_params['ssl_enabled'] = false if @options['ssl_verify'] == false
      client_params['url'] ||= @options['url'] if @options['url']
      client_params['log_level'] ||= @options['log_level'].to_sym if @options['log_level']
      client_params['api_version'] ||= @options['api_version'].to_i if @options['api_version']
      client_params['api_version'] ||= ENV['ONEVIEWSDK_API_VERSION'].to_i if ENV['ONEVIEWSDK_API_VERSION']
      @client = OneviewSDK::Client.new(client_params)
    rescue StandardError => e
      raise e if throw_errors
      fail_nice if quiet
      fail_nice "Failed to login to OneView appliance at '#{client_params['url']}'. Message: #{e}"
    end

    # Get resource class from given string
    def parse_type(type)
      api_ver = (@options['api_version'] || ENV['ONEVIEWSDK_API_VERSION'] || OneviewSDK.api_version).to_i
      unless OneviewSDK::SUPPORTED_API_VERSIONS.include?(api_ver)
        # Find and use the best available match for the desired API version (round down to nearest)
        valid_api_ver = OneviewSDK::SUPPORTED_API_VERSIONS.select { |x| x <= api_ver }.max || OneviewSDK::SUPPORTED_API_VERSIONS.min
        puts "WARNING: Module API version #{api_ver} is not supported. Using #{valid_api_ver}"
        api_ver = valid_api_ver
      end
      variant = @options['variant'] || ENV['ONEVIEWSDK_VARIANT']
      variant ||= OneviewSDK::API300.variant if api_ver == 300
      if variant && !SUPPORTED_VARIANTS.include?(variant)
        fail_nice "Variant '#{variant}' is not supported. Try one of #{SUPPORTED_VARIANTS}"
      end
      r = OneviewSDK.resource_named(type, api_ver, variant)
      # Try default API version as last resort
      r ||= OneviewSDK.resource_named(type, OneviewSDK.api_version, variant) unless api_ver == OneviewSDK.api_version
      return r if r && r.respond_to?(:find_by)
      valid_classes = []
      api_module = OneviewSDK.const_get("API#{api_ver}")
      api_module = api_module.const_get(variant.to_s) unless api_ver.to_i == 200
      api_module.constants.each do |c|
        klass = api_module.const_get(c)
        next unless klass.is_a?(Class) && klass.respond_to?(:find_by)
        valid_classes.push(klass.name.split('::').last)
      end
      vc = valid_classes.sort_by!(&:downcase).join("\n  ")
      var = variant ? " (variant #{variant})" : ''
      fail_nice("Invalid resource type: '#{type}'.  Valid options for API version #{api_ver}#{var} are:\n  #{vc}")
    end

    # Parse options hash from input. Handles chaining and keywords such as true/false & nil
    # Returns new hash with proper nesting and formatting
    def parse_hash(hash, convert_types = false)
      new_hash = {}
      hash.each do |k, v|
        if convert_types
          v = v.to_i if v && v.match(/^\d+$/)
          v = true if v == 'true'
          v = false if v == 'false'
          v = nil if v == 'nil'
        end
        if k =~ /\./
          sub_hash = new_hash
          split = k.split('.')
          split.each do |sub_key|
            if sub_key == split.last
              sub_hash[sub_key] = v
            else
              sub_hash[sub_key] ||= {}
              sub_hash = sub_hash[sub_key]
            end
          end
          new_hash[split.first] ||= {}
        else
          new_hash[k] = v
        end
      end
      new_hash
    end

    # Select a subset of attributes from a given resource
    # @param attributes [String, Array<Array<String>>] Comma-separated string or array of array of strings
    #   The reason it's a nested array is to allow retrieval of nested keys.
    #   For example, the following 2 attribute params will return the same result:
    #     - [['key1'], ['key2', 'subKey3']]
    #     - 'key1,key2.subKey3'
    # @param data [Hash, OneviewSDK::Resource]
    # @return [Hash] A Hash is returned. For example:
    #   { 'key1' => 'val1', 'key2' => { 'subKey3' => 'val2' } }
    def select_attributes(attributes, data = {})
      attributes = attributes.split(',').map(&:strip).reject(&:empty?).map { |a| a.split('.') } if attributes.is_a?(String)
      r_data = data.is_a?(Hash) ? data : data.data
      temp = {}
      attributes.each do |attr|
        temp_level = temp
        attr = [attr] if attr.is_a?(String)
        attr.each_with_index do |a, index|
          # Safely retrieving and setting nested keys is not as easy, so loop to build a nested Hash structure for the result
          if index == attr.size - 1
            # Use r_data.dig(*attr) if we ever drop support for Ruby < 2.3
            temp_level[a] = [*attr].reduce(r_data) { |m, k| m && m[k] } rescue nil
          else
            temp_level[a] ||= {}
            temp_level = temp_level[a]
          end
        end
      end
      temp
    end

    # Select a subset of attributes from a given set of resources
    # @param attributes [String, Array<Array<String>>] Comma-separated string or array of array of strings
    #   The reason it's a nested array is to allow retrieval of nested keys.
    #   For example, the following 2 attribute params will return the same result:
    #     - [['key1'], ['key2', 'subKey3']]
    #     - 'key1,key2.subKey3'
    # @param data [Array<Hash>, Array<OneviewSDK::Resource>]
    # @return [Array<Hash>] An Array of Hashes is returned. For example:
    #   [
    #     { 'resource_name1' => { 'key1' => 'val1', 'key2' => { 'subKey3' => 'val2' } } },
    #     { 'resource_name2' => { 'key1' => 'val3', 'key2' => { 'subKey3' => 'val4' } } },
    #   ]
    def select_attributes_from_multiple(attributes, data = [])
      attributes = attributes.split(',').map(&:strip).reject(&:empty?).map { |a| a.split('.') } if attributes.is_a?(String)
      result = []
      data.each do |r|
        result.push(r['name'] => select_attributes(attributes, r))
      end
      result
    end

    # Print output in a given format.
    def output(data = {}, indent = 0)
      case @options['format']
      when 'json'
        puts JSON.pretty_generate(data)
      when 'yaml'
        puts data.to_yaml
      else
        # rubocop:disable Metrics/BlockNesting
        if data.class == Hash || data.class <= OneviewSDK::Resource
          data.each do |k, v|
            if v.class == Hash || v.class == Array
              puts "#{' ' * indent}#{k.nil? ? 'nil' : k}:"
              output(v, indent + 2)
            else
              puts "#{' ' * indent}#{k.nil? ? 'nil' : k}: #{v.nil? ? 'nil' : v}"
            end
          end
        elsif data.class == Array
          data.each do |d|
            if d.class == Hash || d.class == Array
              output(d, indent + 2)
            else
              puts "#{' ' * indent}#{d.nil? ? 'nil' : d}"
            end
          end
          puts "\nTotal: #{data.size}" if indent < 1
        else
          puts "#{' ' * indent}#{data.nil? ? 'nil' : data}"
        end
        # rubocop:enable Metrics/BlockNesting
      end
    end
  end

  # Console class
  class Console
    def initialize(client)
      @client = client
    end
  end
end