diff --git a/CHANGELOG.md b/CHANGELOG.md index 170f9e826..3de5d9adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### v2.2.0 + - Added the 'rest' and 'update' commands to the CLI + - Removed the --force option from the create_from_file CLI command + ### v2.1.0 - Fixed issue with the :resource_named method for OneViewSDK::Resource in Ruby 2.3 diff --git a/README.md b/README.md index 12d5fd577..aa931bd40 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Each OneView resource is exposed for usage with CRUD-like functionality. For example, once you instantiate a resource object, you can call intuitive methods such as `resource.create`, `resource.udpate` and `resource.delete`. In addition, resources respond to helpful methods such as `.each`, `.eql?(other_resource)`, `.like(other_resource)`, `.retrieve!`, and many others. -Please see the [rubydoc.info](http://www.rubydoc.info/gems/oneview-sdk) documentation for the complete list and usage details, but here are a few examples to get you started: +Please see the [rubydoc.info](http://www.rubydoc.info/gems/oneview-sdk) documentation for complete usage details and the [examples](examples/) directory for more examples and test-scripts, but here are a few examples to get you started: ##### Create a resource @@ -264,6 +264,20 @@ $ oneview-sdk-ruby create_from_file /my-server-profile.json $ oneview-sdk-ruby delete_from_file /my-server-profile.json ``` +##### Update resources by name: + +```bash +$ oneview-sdk-ruby update FCNetwork FC1 -h linkStabilityTime:20 # Using hash format +$ oneview-sdk-ruby update Volume VOL_01 -j '{"shareable": true}' # Using json format +``` + +##### Make REST calls: + +```bash +$ oneview-sdk-ruby rest get rest/fc-networks +$ oneview-sdk-ruby rest PUT rest/enclosures//configuration +``` + ##### Start an interactive console session with a OneView connection: ```bash diff --git a/lib/oneview-sdk/cli.rb b/lib/oneview-sdk/cli.rb index ce9d60e4e..0ae8a317f 100644 --- a/lib/oneview-sdk/cli.rb +++ b/lib/oneview-sdk/cli.rb @@ -1,7 +1,7 @@ -# (C) Copyright 2016 Hewlett Packard Enterprise Development LP +# (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 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 @@ -201,6 +201,80 @@ def search(type) 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/ -d '{\"linkStabilityTime\": 20, ...}'" + rest_examples << "\n oneview-sdk-ruby rest PUT rest/enclosures//configuration" + desc 'rest METHOD URI', "Make REST call to the OneView API. Examples:#{rest_examples}" + def rest(method, uri) + client_setup('log_level' => :error) + 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] ', "Update resource by name. Examples:#{update_examples}" + 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[:uri] = '/rest/storage-volumes/57A22A70-73EC-43C1-91B9-9FABD1E' + resource.update(data) + output '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, @@ -245,17 +319,12 @@ def delete_from_file(file_path) end end - method_option :force, - desc: 'Overwrite without confirmation', - type: :boolean, - aliases: '-f' method_option :if_missing, desc: 'Only create if missing (Don\'t update)', type: :boolean, aliases: '-i' - desc 'create_from_file FILE_PATH', 'Create/Overwrite resource defined in file' + desc 'create_from_file FILE_PATH', 'Create/Update resource defined in file' def create_from_file(file_path) - fail_nice "Can't use the 'force' and 'if_missing' flags at the same time." if options['force'] && options['if_missing'] client_setup resource = OneviewSDK::Resource.from_file(@client, file_path) resource[:uri] = nil @@ -266,7 +335,6 @@ def create_from_file(file_path) puts "Skipped: '#{resource[:name]}': #{resource.class.name.split('::').last} already exists." return end - fail_nice "#{resource.class.name.split('::').last} '#{resource[:name]}' already exists." unless options['force'] begin resource.data.delete('uri') existing_resource.update(resource.data) @@ -338,7 +406,8 @@ def parse_type(type) next unless klass.is_a?(Class) && klass < OneviewSDK::Resource valid_classes.push(klass.name.split('::').last) end - OneviewSDK.resource_named(type) || fail_nice("Invalid resource type: '#{type}'.\n Valid options are #{valid_classes}") + vc = valid_classes.sort_by!(&:downcase).join("\n ") + OneviewSDK.resource_named(type) || fail_nice("Invalid resource type: '#{type}'. Valid options are:\n #{vc}") end # Parse options hash from input. Handles chaining and keywords such as true/false & nil diff --git a/lib/oneview-sdk/rest.rb b/lib/oneview-sdk/rest.rb index 9f578c3bc..ead0a35e2 100644 --- a/lib/oneview-sdk/rest.rb +++ b/lib/oneview-sdk/rest.rb @@ -180,7 +180,7 @@ def build_request(type, uri, options, api_ver) when :delete request = Net::HTTP::Delete.new(uri.request_uri) else - fail InvalidRequest, "Invalid rest call: #{type}" + fail InvalidRequest, "Invalid rest method: #{type}. Valid methods are: get, post, put, patch, delete" end options['X-API-Version'] ||= api_ver diff --git a/lib/oneview-sdk/version.rb b/lib/oneview-sdk/version.rb index 3793c6df5..2bcdeba1c 100644 --- a/lib/oneview-sdk/version.rb +++ b/lib/oneview-sdk/version.rb @@ -11,5 +11,5 @@ # Gem version defined here module OneviewSDK - VERSION = '2.1.0'.freeze + VERSION = '2.2.0'.freeze end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0df64509d..11c237585 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -9,8 +9,8 @@ add_group 'Client', client_files add_group 'Resources', resource_path add_group 'CLI', 'cli.rb' - minimum_coverage 90 # TODO: bump up as we increase coverage. Goal: 95% - minimum_coverage_by_file 50 # TODO: bump up as we increase coverage. Goal: 70% + minimum_coverage 92 # TODO: bump up as we increase coverage. Goal: 95% + minimum_coverage_by_file 60 # TODO: bump up as we increase coverage. Goal: 70% end SimpleCov.profiles.define 'integration' do diff --git a/spec/unit/cli/create_from_file_spec.rb b/spec/unit/cli/create_from_file_spec.rb index a121b7ff4..a328b2236 100644 --- a/spec/unit/cli/create_from_file_spec.rb +++ b/spec/unit/cli/create_from_file_spec.rb @@ -13,12 +13,6 @@ expect { OneviewSDK::Cli.start(['create_from_file']) } .to output(/was called with no arguments*\sUsage:/).to_stderr_from_any_process end - - it 'does not allow both the if_missing and force options' do - expect(STDOUT).to receive(:puts).with(/flags at the same time/) - expect { OneviewSDK::Cli.start(['create_from_file', yaml_file, '-f', '-i']) } - .to raise_error SystemExit - end end context 'with valid options' do @@ -36,24 +30,13 @@ .to output(/Created Successfully!/).to_stdout_from_any_process end - it 'respects the force option for overrides' do - expect { OneviewSDK::Cli.start(['create_from_file', yaml_file, '-f']) } - .to output(/Updated Successfully!/).to_stdout_from_any_process - end - it 'respects the if_missing option' do expect { OneviewSDK::Cli.start(['create_from_file', yaml_file, '-i']) } .to output(/Skipped/).to_stdout_from_any_process end - it 'fails if the resource already exists' do - expect(STDOUT).to receive(:puts).with(/already exists/) - expect { OneviewSDK::Cli.start(['create_from_file', yaml_file]) } - .to raise_error SystemExit - end - it 'fails if the file does not exist' do - expect { OneviewSDK::Cli.start(['create_from_file', yaml_file + '.yml', '-f']) } + expect { OneviewSDK::Cli.start(['create_from_file', yaml_file + '.yml']) } .to raise_error(/No such file or directory/) end @@ -61,7 +44,7 @@ resource = OneviewSDK::Resource.new(@client) allow(OneviewSDK::Resource).to receive(:from_file).and_return(resource) expect(STDOUT).to receive(:puts).with(/must specify a resource name/) - expect { OneviewSDK::Cli.start(['create_from_file', yaml_file, '-f']) } + expect { OneviewSDK::Cli.start(['create_from_file', yaml_file]) } .to raise_error SystemExit end @@ -76,7 +59,7 @@ it 'shows the resource update error message on failure' do allow_any_instance_of(OneviewSDK::Resource).to receive(:update).and_raise('Explanation') expect(STDOUT).to receive(:puts).with(/Failed to update.*Explanation/) - expect { OneviewSDK::Cli.start(['create_from_file', yaml_file, '-f']) } + expect { OneviewSDK::Cli.start(['create_from_file', yaml_file]) } .to raise_error SystemExit end end diff --git a/spec/unit/cli/rest_spec.rb b/spec/unit/cli/rest_spec.rb new file mode 100644 index 000000000..c93b3c667 --- /dev/null +++ b/spec/unit/cli/rest_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +RSpec.describe OneviewSDK::Cli do + include_context 'cli context' + + let(:data) { { 'key1' => 'val1', 'key2' => 'val2' } } + let(:response) { { 'key1' => 'val1', 'key2' => 'val2', 'key3' => { 'key4' => 'val4' } } } + + describe '#rest get' do + it 'requires a URI' do + expect($stderr).to receive(:puts).with(/ERROR.*arguments/) + described_class.start(%w(rest get)) + end + + it 'sends any data that is passed in' do + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('get', '/rest/fake', body: data).and_return FakeResponse.new(response) + expect { described_class.start(['rest', 'get', 'rest/fake', '-d', data.to_json]) } + .to output(JSON.pretty_generate(response) + "\n").to_stdout_from_any_process + end + + context 'output formats' do + before :each do + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('get', '/rest/fake', {}).and_return FakeResponse.new(response) + end + + it 'makes a GET call to the URI and outputs the response in json format' do + expect { described_class.start(%w(rest get rest/fake)) } + .to output(JSON.pretty_generate(response) + "\n").to_stdout_from_any_process + end + + it 'can output the response in raw format' do + expect { described_class.start(%w(rest get rest/fake -f raw)) } + .to output(response.to_json + "\n").to_stdout_from_any_process + end + + it 'can output the response in yaml format' do + expect { described_class.start(%w(rest get rest/fake -f yaml)) } + .to output(response.to_yaml).to_stdout_from_any_process + end + end + + context 'bad requests' do + it 'fails if the data cannot be parsed as json' do + expect($stdout).to receive(:puts).with(/Failed to parse data as JSON/) + expect { described_class.start(%w(rest get rest/ -d fake_json)) } + .to raise_error SystemExit + end + + it 'fails if the request method is invalid' do + expect($stdout).to receive(:puts).with(/Invalid rest method/) + expect { described_class.start(%w(rest blah rest/)) } + .to raise_error SystemExit + end + + it 'fails if the response code is 3XX' do + headers = { 'location' => ['rest/Systems/1/'] } + body = {} + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('get', '/rest', {}).and_return FakeResponse.new(body, 308, headers) + expect($stdout).to receive(:puts).with(/308.*location/m) + expect { described_class.start(%w(rest get rest)) } + .to raise_error SystemExit + end + + it 'fails if the response code is 4XX' do + headers = { 'content-type' => ['text/plain'] } + body = { 'Message' => 'Not found!' } + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('get', '/rest', {}).and_return FakeResponse.new(body, 404, headers) + expect($stdout).to receive(:puts).with(/404.*content-type.*Not found/m) + expect { described_class.start(%w(rest get rest)) } + .to raise_error SystemExit + end + + it 'fails if the response code is 4XX' do + headers = { 'content-type' => ['text/plain'] } + body = { 'Message' => 'Server error!' } + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('get', '/rest', {}).and_return FakeResponse.new(body, 500, headers) + expect($stdout).to receive(:puts).with(/500.*content-type.*Server error/m) + expect { described_class.start(%w(rest get rest)) } + .to raise_error SystemExit + end + end + end + + describe '#rest post' do + it 'makes a rest call with any data that is passed in' do + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('post', '/rest/fake', body: data).and_return FakeResponse.new(response) + expect { described_class.start(['rest', 'post', 'rest/fake', '-d', data.to_json]) } + .to output(JSON.pretty_generate(response) + "\n").to_stdout_from_any_process + end + end + + describe '#rest put' do + it 'makes a rest call with any data that is passed in' do + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('put', '/rest/fake', body: data).and_return FakeResponse.new(response) + expect { described_class.start(['rest', 'put', 'rest/fake', '-d', data.to_json]) } + .to output(JSON.pretty_generate(response) + "\n").to_stdout_from_any_process + end + end + + describe '#rest patch' do + it 'makes a rest call with any data that is passed in' do + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('patch', '/rest/fake', body: data).and_return FakeResponse.new(response) + expect { described_class.start(['rest', 'patch', 'rest/fake', '-d', data.to_json]) } + .to output(JSON.pretty_generate(response) + "\n").to_stdout_from_any_process + end + end + + describe '#rest delete' do + it 'makes a rest call with any data that is passed in' do + expect_any_instance_of(OneviewSDK::Client).to receive(:rest_api) + .with('delete', '/rest/fake', body: data).and_return FakeResponse.new(response) + expect { described_class.start(['rest', 'delete', 'rest/fake', '-d', data.to_json]) } + .to output(JSON.pretty_generate(response) + "\n").to_stdout_from_any_process + end + end +end diff --git a/spec/unit/cli/update_spec.rb b/spec/unit/cli/update_spec.rb new file mode 100644 index 000000000..22028a313 --- /dev/null +++ b/spec/unit/cli/update_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +RSpec.describe OneviewSDK::Cli do + include_context 'cli context' + include_context 'shared context' + + describe '#update' do + + let(:data) do + { 'name' => 'SP_1', 'description' => 'NewBlah' } + end + + let(:resource_data) do + { 'name' => 'SP1', 'uri' => '/rest/fake', 'description' => 'Blah' } + end + + let(:sp1) do + OneviewSDK::ServerProfile.new(@client, resource_data) + end + + let(:sp_list) do + [sp1] + end + + context 'with invalid options' do + it 'requires a type' do + expect { described_class.start(%w(update)) } + .to output(/called with no arguments/).to_stderr_from_any_process + end + + it 'requires a valid type' do + expect(STDOUT).to receive(:puts).with(/Invalid resource type/) + expect { described_class.start(%w(update InvalidType Name)) }.to raise_error SystemExit + end + + it 'requires a name' do + expect { described_class.start(%w(update ServerProfile)) } + .to output(/called with arguments/).to_stderr_from_any_process + end + + it 'requires the hash or json option' do + expect($stdout).to receive(:puts).with(/Must set the hash or json option/) + expect { described_class.start(%w(update ServerProfile SP1)) } + .to raise_error SystemExit + end + + it 'requires the json to be valid' do + expect($stdout).to receive(:puts).with(/Failed to parse json/) + expect { described_class.start(%w(update ServerProfile SP1 -j invalid_json)) } + .to raise_error SystemExit + end + end + + it 'fails if no match is found' do + expect(OneviewSDK::ServerProfile).to receive(:find_by).with(instance_of(OneviewSDK::Client), name: 'SP_2') + .and_return [] + expect($stdout).to receive(:puts).with(/Not Found/) + expect { described_class.start(%w(update ServerProfile SP_2 -h name:SP2)) } + .to raise_error SystemExit + end + + it 'parses hash data' do + expect(OneviewSDK::ServerProfile).to receive(:find_by).with(instance_of(OneviewSDK::Client), name: sp1['name']) + .and_return sp_list + expect(sp1).to receive(:update).with(data).and_return true + expect { described_class.start(%w(update ServerProfile SP1 -h name:SP_1 description:NewBlah)) } + .to output(/Successfully/).to_stdout_from_any_process + end + + it 'parses json data' do + expect(OneviewSDK::ServerProfile).to receive(:find_by).with(instance_of(OneviewSDK::Client), name: sp1['name']) + .and_return sp_list + expect(sp1).to receive(:update).with(data).and_return true + expect { described_class.start(['update', 'ServerProfile', 'SP1', '-j', data.to_json]) } + .to output(/Successfully/).to_stdout_from_any_process + end + + it 'prints out error messages' do + expect(OneviewSDK::ServerProfile).to receive(:find_by).with(instance_of(OneviewSDK::Client), name: sp1['name']) + .and_return sp_list + expect(sp1).to receive(:update).with(data).and_raise(OneviewSDK::BadRequest, 'Reason') + expect($stdout).to receive(:puts).with(/Reason/) + expect { described_class.start(['update', 'ServerProfile', 'SP1', '-j', data.to_json]) } + .to raise_error SystemExit + end + end +end diff --git a/spec/unit/rest_spec.rb b/spec/unit/rest_spec.rb index 0f6d71f73..5074e7afc 100644 --- a/spec/unit/rest_spec.rb +++ b/spec/unit/rest_spec.rb @@ -155,7 +155,7 @@ end it 'fails when an invalid request type is given' do - expect { @client.send(:build_request, :fake, @uri, {}, @client.api_version) }.to raise_error(OneviewSDK::InvalidRequest, /Invalid rest call/) + expect { @client.send(:build_request, :fake, @uri, {}, @client.api_version) }.to raise_error(OneviewSDK::InvalidRequest, /Invalid rest method/) end context 'default header values' do