cloudstack_client 1.5.12 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ module CloudstackClient
2
+ module RequestHandling
3
+ def send_request(params, opts = {})
4
+ params['response'] = 'json'
5
+ params['apiKey'] = @api_key
6
+ print_debug_output JSON.pretty_generate(params) if @debug
7
+
8
+ data = params_to_data(params)
9
+ uri = URI.parse "#{@api_url}?#{data}&signature=#{create_signature(data)}"
10
+ http = build_http(uri)
11
+ response = request_with_retries(http, uri)
12
+
13
+ handle_response(response, opts)
14
+ end
15
+
16
+ private
17
+
18
+ def build_http(uri)
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ return configure_https(http) if uri.scheme == 'https'
21
+
22
+ http.read_timeout = @read_timeout
23
+ http
24
+ end
25
+
26
+ def configure_https(http)
27
+ http.use_ssl = true
28
+ http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
29
+ http.ca_file = @ca_file if @ca_file
30
+ http.read_timeout = @read_timeout
31
+ http
32
+ end
33
+
34
+ def request_with_retries(http, uri)
35
+ retries = 0
36
+ begin
37
+ request = Net::HTTP::Get.new(uri.request_uri)
38
+ request['Host'] = host unless host.to_s.strip.empty?
39
+ http.request(request)
40
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
41
+ Errno::ENETUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout,
42
+ Net::ReadTimeout, SocketError, EOFError => e
43
+ retries += 1
44
+ if retries < @request_retries
45
+ sleep(retries)
46
+ print "." if @verbose
47
+ retry
48
+ end
49
+ raise ConnectionError,
50
+ "API URL '#{@api_url}' is not reachable " \
51
+ "(after #{retries} attempt#{'s' if retries > 1}): #{e.message}"
52
+ end
53
+ end
54
+
55
+ def handle_response(response, opts)
56
+ body = parse_response_body(response)
57
+ return normalize_success(body, opts) if response.is_a?(Net::HTTPOK)
58
+
59
+ message = body.is_a?(Hash) ? body[k('errortext')] : body
60
+ raise ApiError, "Status #{response.code}: #{message}."
61
+ end
62
+
63
+ def parse_response_body(response)
64
+ JSON.parse(response.body, symbolize_names: @symbolize_keys).values.first
65
+ rescue JSON::ParserError
66
+ raise ParseError,
67
+ "Response from server is not readable. Check if the API endpoint " \
68
+ "(#{@api_url}) is valid and accessible."
69
+ end
70
+
71
+ def normalize_success(body, opts)
72
+ return body unless body.respond_to?(:keys)
73
+ return normalize_counted_response(body, opts) if body.size == 2 && body.key?(k('count'))
74
+ return normalize_nested_response(body) if body.size == 1 && body.values.first.respond_to?(:keys)
75
+
76
+ body.reject! { |key, _| key == k('count') } if body.key?(k('count')) && !opts[:include_count]
77
+ body.empty? ? [] : body
78
+ end
79
+
80
+ def normalize_counted_response(body, opts)
81
+ return body if opts[:include_count]
82
+
83
+ body.reject { |key, _| key == k('count') }.values.first
84
+ end
85
+
86
+ def normalize_nested_response(body)
87
+ item = body.values.first
88
+ item.is_a?(Array) || item.is_a?(Hash) ? item : []
89
+ end
90
+ end
91
+ end
@@ -1,3 +1,3 @@
1
1
  module CloudstackClient
2
- VERSION = "1.5.12"
2
+ VERSION = "2.0.0"
3
3
  end
data/test/api_test.rb CHANGED
@@ -91,7 +91,7 @@ describe CloudstackClient::Api do
91
91
  end
92
92
 
93
93
  describe "when asked about all required params" do
94
- it "must respond positively for 'createUser' and params 'account, email, firtsname, lastname, password, username'" do
94
+ it "must respond positively for 'createUser' with all required params" do
95
95
  params = {
96
96
  "account" => "Master",
97
97
  "email" => "me@me.com",
@@ -109,4 +109,26 @@ describe CloudstackClient::Api do
109
109
  end
110
110
  end
111
111
 
112
+ describe "when asked about commands containing acronyms" do
113
+ it "must respond positively for the camel case name 'createSSHKeyPair'" do
114
+ _(@api.command_supported?('createSSHKeyPair')).must_equal true
115
+ end
116
+
117
+ it "must respond positively for the underscored name 'create_ssh_key_pair'" do
118
+ _(@api.command_supported?('create_ssh_key_pair')).must_equal true
119
+ end
120
+
121
+ it "must respond positively for the underscored name 'list_vpc_offerings'" do
122
+ _(@api.command_supported?('list_vpc_offerings')).must_equal true
123
+ end
124
+
125
+ it "must resolve params via the underscored name of an acronym command" do
126
+ _(@api.command_supports_param?('create_ssh_key_pair', 'name')).must_equal true
127
+ end
128
+
129
+ it "must return false rather than raise for an unknown command" do
130
+ _(@api.command_supports_param?('listClowns', 'name')).must_equal false
131
+ end
132
+ end
133
+
112
134
  end
data/test/client_test.rb CHANGED
@@ -1,29 +1,163 @@
1
1
  require "test_helper"
2
2
 
3
3
  describe CloudstackClient::Client do
4
- before do
5
- @client = CloudstackClient::Client.new(
6
- "https://cloudstack.api/client/api",
7
- "test-key",
8
- "test-secret"
4
+ let(:client) do
5
+ CloudstackClient::Client.new(
6
+ TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET, quiet: true
9
7
  )
10
8
  end
11
9
 
12
10
  describe "when the client is instantiated" do
13
- it "must respond_to 'list_virtual_machines'" do
14
- _(@client.respond_to?(:list_virtual_machines)).must_equal true
11
+ it "exposes the loaded API definition" do
12
+ _(client.api).must_be_kind_of CloudstackClient::Api
13
+ _(client.api.api_version).must_equal CloudstackClient::Api::DEFAULT_API_VERSION
15
14
  end
16
15
 
17
- it "must respond_to 'deploy_virtual_machine'" do
18
- _(@client.respond_to?(:deploy_virtual_machine)).must_equal true
16
+ it "is not in debug mode by default" do
17
+ _(client.debug).must_equal false
19
18
  end
20
19
 
21
- it "must respond_to 'create_user'" do
22
- _(@client.respond_to?(:create_user)).must_equal true
20
+ it "defines no API methods when no_api_methods is set" do
21
+ bare = bare_client
22
+ _(bare.respond_to?(:list_virtual_machines)).must_equal false
23
+ _(bare.api).must_be_nil
23
24
  end
25
+ end
26
+
27
+ describe "generated command methods" do
28
+ it "sends the CloudStack command name for the underscored method" do
29
+ stub_list("listVirtualMachines", "virtualmachine", [{ "id" => "vm-1" }])
30
+
31
+ client.list_virtual_machines
32
+
33
+ _(last_request_params["command"].first).must_equal "listVirtualMachines"
34
+ end
35
+
36
+ it "returns the unwrapped collection" do
37
+ stub_list("listVirtualMachines", "virtualmachine",
38
+ [{ "id" => "vm-1" }, { "id" => "vm-2" }])
39
+
40
+ _(client.list_virtual_machines).must_equal [{ "id" => "vm-1" }, { "id" => "vm-2" }]
41
+ end
42
+
43
+ it "strips underscores from argument names" do
44
+ stub_list("listVirtualMachines", "virtualmachine", [])
45
+
46
+ client.list_virtual_machines(list_all: true)
47
+
48
+ _(last_request_params["listall"].first).must_equal "true"
49
+ end
50
+
51
+ it "passes through arguments that need no translation" do
52
+ stub_list("listVirtualMachines", "virtualmachine", [])
53
+
54
+ client.list_virtual_machines(state: "running")
55
+
56
+ _(last_request_params["state"].first).must_equal "running"
57
+ end
58
+
59
+ it "drops arguments the command does not support" do
60
+ stub_list("listVirtualMachines", "virtualmachine", [])
61
+
62
+ client.list_virtual_machines(state: "running", hotdog: "mustard")
63
+
64
+ params = last_request_params
65
+ _(params).must_include "state"
66
+ _(params).wont_include "hotdog"
67
+ end
68
+
69
+ it "drops arguments with a nil value" do
70
+ stub_list("listVirtualMachines", "virtualmachine", [])
71
+
72
+ client.list_virtual_machines(state: nil, name: "web01")
73
+
74
+ params = last_request_params
75
+ _(params).must_include "name"
76
+ _(params).wont_include "state"
77
+ end
78
+
79
+ it "accepts String keys as well as Symbol keys" do
80
+ stub_list("listVirtualMachines", "virtualmachine", [])
81
+
82
+ client.list_virtual_machines("state" => "running")
83
+
84
+ _(last_request_params["state"].first).must_equal "running"
85
+ end
86
+ end
87
+
88
+ describe "required parameter validation" do
89
+ it "raises ParameterError when a required parameter is missing" do
90
+ error = _(proc { client.deploy_virtual_machine(zoneid: "1") })
91
+ .must_raise CloudstackClient::ParameterError
92
+
93
+ _(error.message).must_match(/deployVirtualMachine requires/)
94
+ _(error.message).must_match(/serviceofferingid/)
95
+ _(error.message).must_match(/templateid/)
96
+ end
97
+
98
+ it "raises ParameterError for unsupported parameters in strict mode" do
99
+ error = _(proc {
100
+ client.list_virtual_machines({ unsupported: true }, strict_params: true)
101
+ }).must_raise CloudstackClient::ParameterError
102
+
103
+ _(error.message).must_match(/does not support parameter unsupported/)
104
+ end
105
+
106
+ it "does not issue a request when validation fails" do
107
+ _(proc { client.create_user(username: "meme") })
108
+ .must_raise CloudstackClient::ParameterError
109
+
110
+ assert_not_requested(:get, ApiStubs::ANY_REQUEST)
111
+ end
112
+
113
+ it "proceeds when every required parameter is present" do
114
+ stub_command("createUser", { "createuserresponse" => { "user" => { "id" => "u-1" } } })
115
+
116
+ result = client.create_user(
117
+ account: "Master", email: "me@me.com", firstname: "Me",
118
+ lastname: "Me", password: "secret", username: "meme"
119
+ )
120
+
121
+ _(result).must_equal("id" => "u-1")
122
+ end
123
+ end
124
+
125
+ describe "synchronous and asynchronous dispatch" do
126
+ it "sends a synchronous command directly" do
127
+ stub_list("listVirtualMachines", "virtualmachine", [])
128
+
129
+ client.list_virtual_machines
130
+
131
+ assert_not_requested(:get, ApiStubs::ANY_REQUEST,
132
+ query: hash_including("command" => "queryAsyncJobResult"))
133
+ end
134
+
135
+ it "polls for an asynchronous command" do
136
+ stub_async("deployVirtualMachine",
137
+ polls: [job_success("id" => "vm-1")])
138
+
139
+ result = client.stub(:sleep, nil) do
140
+ client.deploy_virtual_machine(
141
+ zoneid: "1", serviceofferingid: "2", templateid: "3"
142
+ )
143
+ end
144
+
145
+ _(result).must_equal("id" => "vm-1")
146
+ assert_requested(:get, ApiStubs::ANY_REQUEST,
147
+ query: hash_including("command" => "queryAsyncJobResult"))
148
+ end
149
+
150
+ it "forces a synchronous request when the sync option is given" do
151
+ stub_command("deployVirtualMachine",
152
+ { "deployvirtualmachineresponse" => { "jobid" => "job-1" } })
153
+
154
+ result = client.deploy_virtual_machine(
155
+ { zoneid: "1", serviceofferingid: "2", templateid: "3" }, { sync: true }
156
+ )
24
157
 
25
- it "must not be in debug mode" do
26
- _(@client.debug).must_equal false
158
+ _(result).must_equal("jobid" => "job-1")
159
+ assert_not_requested(:get, ApiStubs::ANY_REQUEST,
160
+ query: hash_including("command" => "queryAsyncJobResult"))
27
161
  end
28
162
  end
29
163
  end
@@ -31,4 +31,88 @@ describe CloudstackClient::Configuration do
31
31
  end
32
32
  end
33
33
 
34
+ describe "when the configuration cannot be loaded" do
35
+ it "must raise when the file does not exist" do
36
+ error = _(proc {
37
+ CloudstackClient::Configuration.load(config_file: "/nonexistent/cloudstack.yml")
38
+ }).must_raise CloudstackClient::ConfigurationError
39
+
40
+ _(error.message).must_match(/not found/)
41
+ end
42
+
43
+ it "must raise when the file is not valid YAML" do
44
+ error = _(proc {
45
+ CloudstackClient::Configuration.load(
46
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-malformed.yml"
47
+ )
48
+ }).must_raise CloudstackClient::ConfigurationError
49
+
50
+ _(error.message).must_match(/Can't load configuration/)
51
+ end
52
+
53
+ it "must reject unsafe YAML objects" do
54
+ error = _(proc {
55
+ CloudstackClient::Configuration.load(
56
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-unsafe.yml"
57
+ )
58
+ }).must_raise CloudstackClient::ConfigurationError
59
+
60
+ _(error.message).must_match(/Can't load configuration/)
61
+ end
62
+
63
+ it "must include the backtrace in debug mode" do
64
+ error = _(proc {
65
+ CloudstackClient::Configuration.load(
66
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-malformed.yml",
67
+ debug: true
68
+ )
69
+ }).must_raise CloudstackClient::ConfigurationError
70
+
71
+ _(error.message).must_match(/Backtrace/)
72
+ end
73
+
74
+ it "must raise when the requested environment is absent" do
75
+ error = _(proc {
76
+ CloudstackClient::Configuration.load(
77
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-1.yml",
78
+ env: "nope"
79
+ )
80
+ }).must_raise CloudstackClient::ConfigurationError
81
+
82
+ _(error.message).must_match(/Can't find environment nope/)
83
+ end
84
+
85
+ it "must raise when required keys are missing" do
86
+ error = _(proc {
87
+ CloudstackClient::Configuration.load(
88
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-incomplete.yml"
89
+ )
90
+ }).must_raise CloudstackClient::ConfigurationError
91
+
92
+ _(error.message).must_match(/does not contain all required keys/)
93
+ end
94
+
95
+ it "must raise when a required value is empty" do
96
+ error = _(proc {
97
+ CloudstackClient::Configuration.load(
98
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-empty.yml"
99
+ )
100
+ }).must_raise CloudstackClient::ConfigurationError
101
+
102
+ _(error.message).must_match(/does not contain all required keys/)
103
+ end
104
+ end
105
+
106
+ describe "when the configuration is valid" do
107
+ it "must return the url and secret key alongside the environment" do
108
+ config = CloudstackClient::Configuration.load(
109
+ config_file: "#{File.expand_path File.dirname(__FILE__)}/data/cloudstack-1.yml"
110
+ )
111
+
112
+ _(config[:url]).must_equal "https://cloud.swisstxt.ch/client/api/"
113
+ _(config[:environment]).must_equal "test1"
114
+ _(config[:secret_key]).wont_be_nil
115
+ end
116
+ end
117
+
34
118
  end