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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +23 -0
- data/.github/workflows/ci.yml +57 -0
- data/.github/workflows/release.yml +60 -0
- 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 +118 -23
- 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 +26 -62
- 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 +131 -19
- data/.travis.yml +0 -8
- data/Gemfile.lock +0 -27
|
@@ -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
|
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
|