cloudstack_client 1.6.0 → 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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +23 -0
- data/.github/workflows/ci.yml +29 -4
- data/.github/workflows/release.yml +9 -11
- data/.gitignore +4 -0
- data/.rubocop.yml +33 -0
- data/.rubocop_todo.yml +572 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +5 -0
- data/README.md +105 -29
- data/Rakefile +17 -2
- data/cloudstack_client.gemspec +23 -4
- data/lib/cloudstack_client/api.rb +39 -14
- data/lib/cloudstack_client/cli.rb +1 -1
- data/lib/cloudstack_client/client.rb +4 -1
- data/lib/cloudstack_client/configuration.rb +14 -2
- data/lib/cloudstack_client/connection.rb +12 -56
- data/lib/cloudstack_client/error.rb +1 -0
- data/lib/cloudstack_client/request_handling.rb +91 -0
- data/lib/cloudstack_client/version.rb +1 -1
- data/test/api_test.rb +23 -1
- data/test/client_test.rb +147 -13
- data/test/configuration_test.rb +84 -0
- data/test/connection_test.rb +364 -0
- data/test/data/cloudstack-empty.yml +3 -0
- data/test/data/cloudstack-incomplete.yml +5 -0
- data/test/data/cloudstack-malformed.yml +5 -0
- data/test/data/cloudstack-unsafe.yml +1 -0
- data/test/support/api_stubs.rb +85 -0
- data/test/test_helper.rb +38 -1
- data/test/utils_test.rb +62 -0
- metadata +130 -23
- data/.travis.yml +0 -8
- data/Gemfile.lock +0 -27
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'
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 "
|
|
14
|
-
_(
|
|
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 "
|
|
18
|
-
_(
|
|
16
|
+
it "is not in debug mode by default" do
|
|
17
|
+
_(client.debug).must_equal false
|
|
19
18
|
end
|
|
20
19
|
|
|
21
|
-
it "
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
data/test/configuration_test.rb
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
|
|
3
|
+
describe CloudstackClient::Connection do
|
|
4
|
+
let(:connection) do
|
|
5
|
+
CloudstackClient::Connection.new(
|
|
6
|
+
TestHelpers::TEST_URL,
|
|
7
|
+
TestHelpers::TEST_KEY,
|
|
8
|
+
TestHelpers::TEST_SECRET,
|
|
9
|
+
quiet: true
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
describe "input validation" do
|
|
14
|
+
it "raises when the API URL is missing" do
|
|
15
|
+
_(proc { CloudstackClient::Connection.new(nil, "k", "s") })
|
|
16
|
+
.must_raise CloudstackClient::InputError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "raises when the API key is missing" do
|
|
20
|
+
_(proc { CloudstackClient::Connection.new("http://url", nil, "s") })
|
|
21
|
+
.must_raise CloudstackClient::InputError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "raises when the secret key is missing" do
|
|
25
|
+
_(proc { CloudstackClient::Connection.new("http://url", "k", nil) })
|
|
26
|
+
.must_raise CloudstackClient::InputError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "raises when the async poll interval is below 1 second" do
|
|
30
|
+
error = _(proc {
|
|
31
|
+
CloudstackClient::Connection.new("http://url", "k", "s", async_poll_interval: 0.5)
|
|
32
|
+
}).must_raise CloudstackClient::InputError
|
|
33
|
+
_(error.message).must_match(/POLL INTERVAL/)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "raises when the async timeout is below 60 seconds" do
|
|
37
|
+
error = _(proc {
|
|
38
|
+
CloudstackClient::Connection.new("http://url", "k", "s", async_timeout: 30)
|
|
39
|
+
}).must_raise CloudstackClient::InputError
|
|
40
|
+
_(error.message).must_match(/ASYNC TIMEOUT/)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "raises when request retries is below 1" do
|
|
44
|
+
error = _(proc {
|
|
45
|
+
CloudstackClient::Connection.new("http://url", "k", "s", request_retries: 0)
|
|
46
|
+
}).must_raise CloudstackClient::InputError
|
|
47
|
+
_(error.message).must_match(/REQUEST RETRIES/)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "applies the documented defaults" do
|
|
51
|
+
_(connection.read_timeout).must_equal CloudstackClient::Connection::DEF_REQ_TIMEOUT
|
|
52
|
+
_(connection.async_timeout).must_equal CloudstackClient::Connection::DEF_ASYNC_TIMEOUT
|
|
53
|
+
_(connection.async_poll_interval).must_equal CloudstackClient::Connection::DEF_POLL_INTERVAL
|
|
54
|
+
_(connection.request_retries).must_equal CloudstackClient::Connection::DEF_REQUEST_RETRIES
|
|
55
|
+
_(connection.verify_ssl).must_equal true
|
|
56
|
+
_(connection).wont_respond_to :secret_key=
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "allows SSL verification to be disabled explicitly" do
|
|
60
|
+
configured = CloudstackClient::Connection.new(
|
|
61
|
+
TestHelpers::TEST_URL,
|
|
62
|
+
TestHelpers::TEST_KEY,
|
|
63
|
+
TestHelpers::TEST_SECRET,
|
|
64
|
+
verify_ssl: false,
|
|
65
|
+
ca_file: "/tmp/cloudstack-ca.pem"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_(configured.verify_ssl).must_equal false
|
|
69
|
+
_(configured.ca_file).must_equal "/tmp/cloudstack-ca.pem"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "request signing" do
|
|
74
|
+
# Golden value: HMAC-SHA1 of the downcased payload, Base64 encoded and then
|
|
75
|
+
# URL escaped. Pinned so a change to the signing algorithm fails loudly.
|
|
76
|
+
it "signs the downcased payload with HMAC-SHA1" do
|
|
77
|
+
expected = CGI.escape(
|
|
78
|
+
Base64.encode64(
|
|
79
|
+
OpenSSL::HMAC.digest("sha1", TestHelpers::TEST_SECRET, "apikey=test-key&command=listzones")
|
|
80
|
+
).chomp
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_(connection.send(:create_signature, "apiKey=test-key&command=listZones"))
|
|
84
|
+
.must_equal expected
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "is case insensitive, because the payload is downcased before signing" do
|
|
88
|
+
_(connection.send(:create_signature, "command=listZones"))
|
|
89
|
+
.must_equal connection.send(:create_signature, "COMMAND=LISTZONES")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "sends the signature and apiKey with the request" do
|
|
93
|
+
stub_list("listZones", "zone", [{ "id" => "1" }])
|
|
94
|
+
connection.send_request("command" => "listZones")
|
|
95
|
+
|
|
96
|
+
params = last_request_params
|
|
97
|
+
_(params["apiKey"].first).must_equal TestHelpers::TEST_KEY
|
|
98
|
+
_(params["response"].first).must_equal "json"
|
|
99
|
+
_(params["signature"].first).wont_be_nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe "parameter serialization" do
|
|
104
|
+
it "sorts parameters, since the signature depends on their order" do
|
|
105
|
+
_(connection.send(:params_to_data, "z" => "1", "a" => "2", "m" => "3"))
|
|
106
|
+
.must_equal "a=2&m=3&z=1"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "serializes an Array of Hashes as an indexed map" do
|
|
110
|
+
data = connection.send(:params_to_data,
|
|
111
|
+
"tags" => [{ "key" => "role", "value" => "web" }])
|
|
112
|
+
|
|
113
|
+
_(data).must_equal "tags[0].key=role&tags[0].value=web"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "serializes a Hash as an indexed key/value map" do
|
|
117
|
+
data = connection.send(:params_to_data, "details" => { "cpu" => "2" })
|
|
118
|
+
|
|
119
|
+
_(data).must_equal "details[0].key=cpu&details[0].value=2"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "escapes spaces as %20 rather than +" do
|
|
123
|
+
_(connection.send(:escape, "my machine")).must_equal "my%20machine"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "leaves asterisks unescaped" do
|
|
127
|
+
_(connection.send(:escape, "*")).must_equal "*"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "escapes reserved characters" do
|
|
131
|
+
_(connection.send(:escape, "a&b=c")).must_equal "a%26b%3Dc"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
describe "response unwrapping" do
|
|
136
|
+
it "returns the collection from a count plus collection response" do
|
|
137
|
+
stub_list("listZones", "zone", [{ "id" => "1" }, { "id" => "2" }])
|
|
138
|
+
|
|
139
|
+
_(connection.send_request("command" => "listZones"))
|
|
140
|
+
.must_equal [{ "id" => "1" }, { "id" => "2" }]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "keeps the count when include_count is requested" do
|
|
144
|
+
stub_list("listZones", "zone", [{ "id" => "1" }])
|
|
145
|
+
|
|
146
|
+
result = connection.send_request({ "command" => "listZones" }, include_count: true)
|
|
147
|
+
_(result["count"]).must_equal 1
|
|
148
|
+
_(result["zone"]).must_equal [{ "id" => "1" }]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "returns an empty Array for a zero count response" do
|
|
152
|
+
stub_command("listZones", { "listzonesresponse" => { "count" => 0 } })
|
|
153
|
+
|
|
154
|
+
_(connection.send_request("command" => "listZones")).must_equal []
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "unwraps a single nested Hash" do
|
|
158
|
+
stub_command("createUser", { "createuserresponse" => { "user" => { "id" => "42" } } })
|
|
159
|
+
|
|
160
|
+
_(connection.send_request("command" => "createUser")).must_equal("id" => "42")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "returns a scalar-valued body unchanged" do
|
|
164
|
+
stub_command("someCommand", { "somecommandresponse" => { "jobid" => "job-9" } })
|
|
165
|
+
|
|
166
|
+
_(connection.send_request("command" => "someCommand")).must_equal("jobid" => "job-9")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "returns an empty body as an empty Array" do
|
|
170
|
+
stub_command("listZones", { "listzonesresponse" => {} })
|
|
171
|
+
|
|
172
|
+
_(connection.send_request("command" => "listZones")).must_equal []
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe "symbolized keys" do
|
|
177
|
+
let(:connection) do
|
|
178
|
+
CloudstackClient::Connection.new(
|
|
179
|
+
TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
|
|
180
|
+
quiet: true, symbolize_keys: true
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "returns Symbol keys throughout the response" do
|
|
185
|
+
stub_list("listZones", "zone", [{ "id" => "1", "name" => "zone1" }])
|
|
186
|
+
|
|
187
|
+
_(connection.send_request("command" => "listZones"))
|
|
188
|
+
.must_equal [{ id: "1", name: "zone1" }]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "still strips the count key when it is symbolized" do
|
|
192
|
+
stub_list("listZones", "zone", [{ "id" => "1" }])
|
|
193
|
+
result = connection.send_request("command" => "listZones")
|
|
194
|
+
|
|
195
|
+
_(result).must_equal [{ id: "1" }]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe "error handling" do
|
|
200
|
+
it "raises ApiError with the errortext for a non-200 response" do
|
|
201
|
+
stub_command("listZones",
|
|
202
|
+
{ "listzonesresponse" => { "errortext" => "Unable to execute" } },
|
|
203
|
+
status: 431)
|
|
204
|
+
|
|
205
|
+
error = _(proc { connection.send_request("command" => "listZones") })
|
|
206
|
+
.must_raise CloudstackClient::ApiError
|
|
207
|
+
_(error.message).must_match(/431/)
|
|
208
|
+
_(error.message).must_match(/Unable to execute/)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "raises ParseError when the body is not JSON" do
|
|
212
|
+
stub_request(:get, ApiStubs::ANY_REQUEST).to_return(status: 200, body: "<html>oops</html>")
|
|
213
|
+
|
|
214
|
+
error = _(proc { connection.send_request("command" => "listZones") })
|
|
215
|
+
.must_raise CloudstackClient::ParseError
|
|
216
|
+
_(error.message).must_match(/not readable/)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "raises ConnectionError when the endpoint is unreachable" do
|
|
220
|
+
stub_request(:get, ApiStubs::ANY_REQUEST).to_raise(Errno::ECONNREFUSED)
|
|
221
|
+
|
|
222
|
+
error = _(proc { connection.send_request("command" => "listZones") })
|
|
223
|
+
.must_raise CloudstackClient::ConnectionError
|
|
224
|
+
_(error.message).must_match(/is not reachable/)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
describe "retries" do
|
|
229
|
+
it "makes exactly one attempt with the default retry setting" do
|
|
230
|
+
stub_request(:get, ApiStubs::ANY_REQUEST).to_raise(Errno::ECONNREFUSED)
|
|
231
|
+
|
|
232
|
+
error = _(proc { connection.send_request("command" => "listZones") })
|
|
233
|
+
.must_raise CloudstackClient::ConnectionError
|
|
234
|
+
_(error.message).must_match(/after 1 attempt/)
|
|
235
|
+
assert_requested(:get, ApiStubs::ANY_REQUEST, times: 1)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "retries up to request_retries attempts before giving up" do
|
|
239
|
+
connection = CloudstackClient::Connection.new(
|
|
240
|
+
TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
|
|
241
|
+
quiet: true, request_retries: 3
|
|
242
|
+
)
|
|
243
|
+
stub_request(:get, ApiStubs::ANY_REQUEST).to_raise(Errno::ECONNREFUSED)
|
|
244
|
+
|
|
245
|
+
error = connection.stub(:sleep, nil) do
|
|
246
|
+
_(proc { connection.send_request("command" => "listZones") })
|
|
247
|
+
.must_raise CloudstackClient::ConnectionError
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
_(error.message).must_match(/after 3 attempts/)
|
|
251
|
+
assert_requested(:get, ApiStubs::ANY_REQUEST, times: 3)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "returns the result when a retry succeeds" do
|
|
255
|
+
connection = CloudstackClient::Connection.new(
|
|
256
|
+
TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
|
|
257
|
+
quiet: true, request_retries: 3
|
|
258
|
+
)
|
|
259
|
+
stub_request(:get, ApiStubs::ANY_REQUEST)
|
|
260
|
+
.to_raise(Errno::ECONNREFUSED)
|
|
261
|
+
.then.to_return(
|
|
262
|
+
status: 200,
|
|
263
|
+
body: JSON.generate("listzonesresponse" => { "count" => 1, "zone" => [{ "id" => "1" }] })
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
result = connection.stub(:sleep, nil) do
|
|
267
|
+
connection.send_request("command" => "listZones")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
_(result).must_equal [{ "id" => "1" }]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
describe "asynchronous requests" do
|
|
275
|
+
it "returns the jobresult once the job succeeds" do
|
|
276
|
+
stub_async("deployVirtualMachine",
|
|
277
|
+
polls: [job_success("virtualmachine" => { "id" => "vm-1" })])
|
|
278
|
+
|
|
279
|
+
result = connection.stub(:sleep, nil) do
|
|
280
|
+
connection.send_async_request("command" => "deployVirtualMachine")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
_(result).must_equal("virtualmachine" => { "id" => "vm-1" })
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it "polls until the job leaves the pending state" do
|
|
287
|
+
stub_async("deployVirtualMachine",
|
|
288
|
+
polls: [job_pending, job_pending, job_success("id" => "vm-1")])
|
|
289
|
+
|
|
290
|
+
result = connection.stub(:sleep, nil) do
|
|
291
|
+
connection.send_async_request("command" => "deployVirtualMachine")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
_(result).must_equal("id" => "vm-1")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it "raises JobError when the job fails" do
|
|
298
|
+
stub_async("deployVirtualMachine",
|
|
299
|
+
polls: [job_failure("Insufficient capacity")])
|
|
300
|
+
|
|
301
|
+
error = connection.stub(:sleep, nil) do
|
|
302
|
+
_(proc { connection.send_async_request("command" => "deployVirtualMachine") })
|
|
303
|
+
.must_raise CloudstackClient::JobError
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
_(error.message).must_match(/Insufficient capacity/)
|
|
307
|
+
_(error.message).must_match(/530/)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "raises JobError when a failed job has no error text" do
|
|
311
|
+
stub_async(
|
|
312
|
+
"deployVirtualMachine",
|
|
313
|
+
polls: [{ "jobstatus" => 2, "jobresultcode" => 530 }]
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
error = connection.stub(:sleep, nil) do
|
|
317
|
+
_(proc { connection.send_async_request("command" => "deployVirtualMachine") })
|
|
318
|
+
.must_raise CloudstackClient::JobError
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
_(error.message).must_match(/Unknown error/)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
it "raises TimeoutError when the job never completes" do
|
|
325
|
+
stub_async("deployVirtualMachine", polls: [job_pending])
|
|
326
|
+
|
|
327
|
+
error = connection.stub(:sleep, nil) do
|
|
328
|
+
_(proc { connection.send_async_request("command" => "deployVirtualMachine") })
|
|
329
|
+
.must_raise CloudstackClient::TimeoutError
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
_(error.message).must_match(/timed out/)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "rejects per-request async options that are out of range" do
|
|
336
|
+
_(proc {
|
|
337
|
+
connection.send_async_request({ "command" => "x" }, async_timeout: 5)
|
|
338
|
+
}).must_raise CloudstackClient::InputError
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
describe "the host option" do
|
|
343
|
+
it "sets the Host header when a host is given" do
|
|
344
|
+
connection = CloudstackClient::Connection.new(
|
|
345
|
+
TestHelpers::TEST_URL, TestHelpers::TEST_KEY, TestHelpers::TEST_SECRET,
|
|
346
|
+
quiet: true, host: "cloudstack.internal"
|
|
347
|
+
)
|
|
348
|
+
stub_list("listZones", "zone", [{ "id" => "1" }])
|
|
349
|
+
|
|
350
|
+
connection.send_request("command" => "listZones")
|
|
351
|
+
|
|
352
|
+
assert_requested(:get, ApiStubs::ANY_REQUEST,
|
|
353
|
+
headers: { "Host" => "cloudstack.internal" }, times: 1)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
it "omits the Host header when no host is given" do
|
|
357
|
+
stub_list("listZones", "zone", [{ "id" => "1" }])
|
|
358
|
+
connection.send_request("command" => "listZones")
|
|
359
|
+
|
|
360
|
+
signature = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last
|
|
361
|
+
_(signature.headers.to_h.keys.map(&:downcase)).wont_include "host"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--- !ruby/object:Object {}
|