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/README.md
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
# cloudstack_client
|
|
2
2
|
|
|
3
|
-
[](https://rubygems.org/gems/cloudstack_client)
|
|
4
|
+
[](https://github.com/niwo/cloudstack_client/actions/workflows/ci.yml)
|
|
4
5
|
|
|
5
6
|
A CloudStack API client written in Ruby.
|
|
6
7
|
|
|
8
|
+
## Version 2.0.0
|
|
9
|
+
|
|
10
|
+
Version 2.0.0 requires Ruby 3.0 or newer. It also fixes request dispatch when
|
|
11
|
+
no custom host is configured and improves support for CloudStack commands with
|
|
12
|
+
acronyms, such as `create_ssh_key_pair`.
|
|
13
|
+
|
|
7
14
|
## Installation
|
|
8
15
|
|
|
9
16
|
Install the cloudstack_client gem:
|
|
10
17
|
|
|
11
18
|
```bash
|
|
12
|
-
|
|
19
|
+
gem install cloudstack_client
|
|
13
20
|
```
|
|
14
21
|
|
|
15
22
|
## Features
|
|
16
23
|
|
|
17
24
|
- Access to the whole CloudStack-API from Ruby
|
|
18
|
-
- Interactive console for playing with the CloudStack API:
|
|
25
|
+
- Interactive console for playing with the CloudStack API:
|
|
26
|
+
`cloudstack_client console`
|
|
19
27
|
- Dynamically builds API methods based on the listApis function of CloudStack
|
|
20
|
-
- Command names are converted to match Ruby naming conventions (i.e.
|
|
21
|
-
|
|
28
|
+
- Command names are converted to match Ruby naming conventions (i.e.
|
|
29
|
+
ListVirtualMachines becomes list_virtual_machines)
|
|
30
|
+
- Accepts Ruby Hash arguments passed to commands as options (i.e. `list_all: true`
|
|
31
|
+
becomes `listall=true`)
|
|
22
32
|
- Assure all required arguments are passed
|
|
23
33
|
- Removes unsupported arguments and arguments with nil values from commands
|
|
24
34
|
|
|
@@ -69,11 +79,15 @@ cs = CloudstackClient::Client.new(
|
|
|
69
79
|
|
|
70
80
|
### Pagination and Response Options
|
|
71
81
|
|
|
72
|
-
When working with paginated responses, you can include the total count in the
|
|
82
|
+
When working with paginated responses, you can include the total count in the
|
|
83
|
+
API response:
|
|
73
84
|
|
|
74
85
|
```ruby
|
|
75
86
|
# Get paginated results with count information
|
|
76
|
-
vms = cs.list_virtual_machines(
|
|
87
|
+
vms = cs.list_virtual_machines(
|
|
88
|
+
{ page: 1, pagesize: 10 },
|
|
89
|
+
{ include_count: true }
|
|
90
|
+
)
|
|
77
91
|
total_count = vms[:count]
|
|
78
92
|
items = vms[:virtualmachine]
|
|
79
93
|
|
|
@@ -84,9 +98,10 @@ vms = cs.list_virtual_machines(page: 1, pagesize: 10)
|
|
|
84
98
|
|
|
85
99
|
### Using the configuration module
|
|
86
100
|
|
|
87
|
-
The configuration module of CloudstackClient makes it easy to load CloudStack
|
|
101
|
+
The configuration module of CloudstackClient makes it easy to load CloudStack
|
|
102
|
+
API settings from configuration files.
|
|
88
103
|
|
|
89
|
-
#### Example
|
|
104
|
+
#### Configuration Example
|
|
90
105
|
|
|
91
106
|
```ruby
|
|
92
107
|
require "cloudstack_client"
|
|
@@ -94,7 +109,11 @@ require "cloudstack_client/configuration"
|
|
|
94
109
|
|
|
95
110
|
# looks for ~/.cloudstack.yml per default
|
|
96
111
|
config = CloudstackClient::Configuration.load
|
|
97
|
-
cs = CloudstackClient::Client.new(
|
|
112
|
+
cs = CloudstackClient::Client.new(
|
|
113
|
+
config[:url],
|
|
114
|
+
config[:api_key],
|
|
115
|
+
config[:secret_key]
|
|
116
|
+
)
|
|
98
117
|
```
|
|
99
118
|
|
|
100
119
|
#### Configuration files
|
|
@@ -120,62 +139,117 @@ test:
|
|
|
120
139
|
|
|
121
140
|
### Configuration options
|
|
122
141
|
|
|
123
|
-
You can pass `options` as 4th argument in `CloudstackClient::Client.new`.
|
|
142
|
+
You can pass `options` as 4th argument in `CloudstackClient::Client.new`.
|
|
143
|
+
All its keys are optional.
|
|
124
144
|
|
|
125
145
|
```ruby
|
|
126
146
|
options = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
# Pass symbolize_names: true in JSON#parse for CloudStack responses.
|
|
148
|
+
# Default: false.
|
|
149
|
+
symbolize_keys: true,
|
|
150
|
+
# Custom host header for Net::HTTP. Useful with CloudStack-simulator.
|
|
151
|
+
# Default: parsed from config[:url] via Net::HTTP.
|
|
152
|
+
host: 'localhost',
|
|
153
|
+
# Timeout in seconds for a connection to CloudStack. Default: 60.
|
|
154
|
+
read_timeout: 10,
|
|
155
|
+
# HTTP request attempts before raising ConnectionError. Default: 1.
|
|
156
|
+
# Uses incremental back-off between attempts.
|
|
157
|
+
request_retries: 3,
|
|
158
|
+
# Verify HTTPS certificates. Default: true.
|
|
159
|
+
verify_ssl: true,
|
|
160
|
+
# Optional path to a custom CA certificate bundle.
|
|
161
|
+
ca_file: "/path/to/ca-bundle.pem",
|
|
162
|
+
# Raise ParameterError for unsupported command parameters. Default: false.
|
|
163
|
+
strict_params: false
|
|
131
164
|
}
|
|
132
|
-
cs = CloudstackClient::Client.new(
|
|
165
|
+
cs = CloudstackClient::Client.new(
|
|
166
|
+
config[:url],
|
|
167
|
+
config[:api_key],
|
|
168
|
+
config[:secret_key],
|
|
169
|
+
options
|
|
170
|
+
)
|
|
133
171
|
```
|
|
134
172
|
|
|
135
|
-
|
|
173
|
+
HTTPS certificate verification is enabled by default. Set `verify_ssl: false`
|
|
174
|
+
only when connecting to a trusted endpoint with a certificate that cannot be
|
|
175
|
+
validated, such as a local development server.
|
|
176
|
+
|
|
177
|
+
By default, unsupported command parameters are ignored. Set
|
|
178
|
+
`strict_params: true` to raise `ParameterError` instead.
|
|
179
|
+
|
|
180
|
+
For a single call you can override defaults on the **second** hash (client
|
|
181
|
+
options), without changing the client instance:
|
|
136
182
|
|
|
137
183
|
```ruby
|
|
138
|
-
cs.deploy_virtual_machine(
|
|
139
|
-
|
|
184
|
+
cs.deploy_virtual_machine(
|
|
185
|
+
{ zoneid: "...", serviceofferingid: "...", templateid: "..." },
|
|
186
|
+
async_timeout: 600,
|
|
187
|
+
async_poll_interval: 5
|
|
188
|
+
)
|
|
140
189
|
```
|
|
141
190
|
|
|
142
191
|
### Interactive Console
|
|
143
192
|
|
|
144
193
|
cloudstack_client comes with an interactive console.
|
|
145
194
|
|
|
146
|
-
#### Example
|
|
195
|
+
#### Console Example
|
|
147
196
|
|
|
148
197
|
```bash
|
|
149
|
-
|
|
198
|
+
cloudstack_client console -e prod
|
|
150
199
|
prod >> list_virtual_machines
|
|
151
200
|
```
|
|
152
201
|
|
|
153
202
|
## Development
|
|
154
203
|
|
|
204
|
+
### Running the tests
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
bundle install
|
|
208
|
+
bundle exec rake # tests and RuboCop
|
|
209
|
+
bundle exec rake test # tests only
|
|
210
|
+
bundle exec rake rubocop # RuboCop only
|
|
211
|
+
bundle exec rake benchmark
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Tests use [Minitest](https://github.com/minitest/minitest) in spec style and
|
|
215
|
+
[WebMock](https://github.com/bblimke/webmock). Outbound network access is
|
|
216
|
+
disabled in the test suite, so any request that is not stubbed fails the test
|
|
217
|
+
rather than reaching a real endpoint. Helpers for stubbing CloudStack responses
|
|
218
|
+
live in `test/support/api_stubs.rb`.
|
|
219
|
+
|
|
220
|
+
`Gemfile.lock` is deliberately not checked in, so each supported Ruby version
|
|
221
|
+
resolves its own compatible dependency set.
|
|
222
|
+
|
|
155
223
|
### Generate or update API definitions
|
|
156
224
|
|
|
157
225
|
New API definitions can be generated using the `list_apis` command.
|
|
158
226
|
|
|
159
|
-
#### Example
|
|
227
|
+
#### API Definition Example
|
|
160
228
|
|
|
161
229
|
```bash
|
|
162
230
|
# running against a CloudStack 4.15 API endpoint:
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
cloudstack_client list_apis > data/4.15.json
|
|
232
|
+
gzip data/4.15.json
|
|
165
233
|
```
|
|
166
234
|
|
|
167
235
|
### GitHub Actions
|
|
168
236
|
|
|
169
237
|
This repository includes GitHub Actions workflows for:
|
|
170
238
|
|
|
171
|
-
- Running tests and gem build
|
|
239
|
+
- Running tests and gem build against every supported Ruby version, plus a
|
|
240
|
+
RuboCop lint job, on every push and pull request (`CI`)
|
|
172
241
|
- Publishing the gem to RubyGems when a GitHub Release is published (`Release`)
|
|
173
242
|
|
|
174
|
-
|
|
243
|
+
Dependency and GitHub Actions updates are proposed weekly by Dependabot.
|
|
175
244
|
|
|
176
|
-
|
|
245
|
+
To enable publishing, configure a RubyGems trusted publisher for this
|
|
246
|
+
repository, workflow, and `rubygems` environment. The release workflow grants
|
|
247
|
+
the publish job GitHub's OIDC `id-token` permission, so no RubyGems API key
|
|
248
|
+
secret is required.
|
|
177
249
|
|
|
178
|
-
The release workflow checks that `CloudstackClient::VERSION` is greater than
|
|
250
|
+
The release workflow checks that `CloudstackClient::VERSION` is greater than
|
|
251
|
+
the latest version on RubyGems before building, then uses the `rubygems`
|
|
252
|
+
environment to publish.
|
|
179
253
|
|
|
180
254
|
## References
|
|
181
255
|
|
|
@@ -191,4 +265,6 @@ The release workflow checks that `CloudstackClient::VERSION` is greater than the
|
|
|
191
265
|
|
|
192
266
|
## License
|
|
193
267
|
|
|
194
|
-
Released under the MIT License. See the
|
|
268
|
+
Released under the MIT License. See the
|
|
269
|
+
[LICENSE](https://raw.github.com/niwo/cloudstack_client/master/LICENSE.txt)
|
|
270
|
+
file for further details.
|
data/Rakefile
CHANGED
|
@@ -9,5 +9,20 @@ Rake::TestTask.new do |t|
|
|
|
9
9
|
t.warning = false
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
begin
|
|
13
|
+
require 'rubocop/rake_task'
|
|
14
|
+
RuboCop::RakeTask.new
|
|
15
|
+
rescue LoadError
|
|
16
|
+
desc "rubocop is not available"
|
|
17
|
+
task :rubocop do
|
|
18
|
+
abort "RuboCop is not available. Run `bundle install` first."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "Run the API benchmark"
|
|
23
|
+
task :benchmark do
|
|
24
|
+
ruby "-Ilib test/benchmark.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "Run tests and RuboCop"
|
|
28
|
+
task default: %i[test rubocop]
|
data/cloudstack_client.gemspec
CHANGED
|
@@ -8,20 +8,39 @@ Gem::Specification.new do |gem|
|
|
|
8
8
|
gem.version = CloudstackClient::VERSION
|
|
9
9
|
gem.authors = ["Nik Wolfgramm"]
|
|
10
10
|
gem.email = ["nik.wolfgramm@gmail.com"]
|
|
11
|
-
gem.description = %q{CloudStack API
|
|
11
|
+
gem.description = %q{A Ruby client for the Apache CloudStack API that builds its
|
|
12
|
+
methods dynamically from the CloudStack API definition,
|
|
13
|
+
including an interactive console.}.gsub(/\s+/, ' ')
|
|
12
14
|
gem.summary = %q{CloudStack API client written in Ruby}
|
|
13
15
|
gem.homepage = "https://github.com/niwo/cloudstack_client"
|
|
14
16
|
gem.license = 'MIT'
|
|
15
17
|
|
|
16
|
-
gem.required_ruby_version = '>=
|
|
18
|
+
gem.required_ruby_version = '>= 3.0'
|
|
17
19
|
gem.files = `git ls-files`.split($/)
|
|
18
20
|
gem.executables = %w(cloudstack_client)
|
|
19
|
-
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
20
21
|
gem.require_paths = ["lib"]
|
|
21
22
|
gem.rdoc_options = %w[--line-numbers --inline-source]
|
|
22
23
|
|
|
24
|
+
gem.metadata = {
|
|
25
|
+
"homepage_uri" => gem.homepage,
|
|
26
|
+
"bug_tracker_uri" => "#{gem.homepage}/issues",
|
|
27
|
+
"changelog_uri" => "#{gem.homepage}/releases",
|
|
28
|
+
"rubygems_mfa_required" => "true"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# base64 stopped being a default gem in Ruby 3.4, so `require "base64"`
|
|
32
|
+
# in connection.rb fails under Bundler unless it is declared here.
|
|
33
|
+
gem.add_dependency('base64', '>= 0.1')
|
|
34
|
+
|
|
23
35
|
gem.add_development_dependency('rake', '~> 13.0')
|
|
24
36
|
gem.add_development_dependency('thor', '~> 1.1')
|
|
25
37
|
gem.add_development_dependency('ripl', '~> 0.7')
|
|
26
|
-
|
|
38
|
+
# Allow minitest 6 on Ruby >= 3.2 while staying installable on Ruby 3.0/3.1.
|
|
39
|
+
gem.add_development_dependency('minitest', '>= 5.14', '< 7')
|
|
40
|
+
gem.add_development_dependency('minitest-reporters', '~> 1.8')
|
|
41
|
+
gem.add_development_dependency('webmock', '~> 3.26')
|
|
42
|
+
gem.add_development_dependency('benchmark', '>= 0.3')
|
|
43
|
+
gem.add_development_dependency('rubocop', '~> 1.90')
|
|
44
|
+
gem.add_development_dependency('rubocop-minitest', '~> 0.40')
|
|
45
|
+
gem.add_development_dependency('rubocop-rake', '~> 0.7')
|
|
27
46
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "zlib"
|
|
2
2
|
require "json"
|
|
3
|
+
require "cloudstack_client/error"
|
|
3
4
|
require "cloudstack_client/utils"
|
|
4
5
|
|
|
5
6
|
module CloudstackClient
|
|
@@ -25,14 +26,29 @@ module CloudstackClient
|
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def command_supported?(command)
|
|
28
|
-
|
|
29
|
+
!find_command(command).nil?
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def command_supports_param?(command, key)
|
|
32
|
-
command =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
command = find_command(command)
|
|
34
|
+
return false if command.nil?
|
|
35
|
+
|
|
36
|
+
command["params"].any? { |param| param["name"] == key.to_s }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resolves a command given either its CloudStack name ("createSSHKeyPair")
|
|
40
|
+
# or its underscored Ruby name ("create_ssh_key_pair").
|
|
41
|
+
#
|
|
42
|
+
# underscore_to_camel_case cannot be relied on here: converting to
|
|
43
|
+
# underscores loses acronym casing, so "create_ssh_key_pair" converts back
|
|
44
|
+
# to "createSshKeyPair", which is not a CloudStack command. Underscored
|
|
45
|
+
# names are resolved through an index built in the lossy direction instead.
|
|
46
|
+
#
|
|
47
|
+
# The index is built on first use: most lookups arrive as CloudStack names
|
|
48
|
+
# and hit @commands directly, so instantiating an Api should not pay for it.
|
|
49
|
+
def find_command(command)
|
|
50
|
+
command = command.to_s
|
|
51
|
+
@commands[command] || underscored_commands[command]
|
|
36
52
|
end
|
|
37
53
|
|
|
38
54
|
def required_params(command)
|
|
@@ -68,11 +84,7 @@ module CloudstackClient
|
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
def set_api_path(options)
|
|
71
|
-
@api_path =
|
|
72
|
-
File.expand_path(options[:api_path])
|
|
73
|
-
else
|
|
74
|
-
API_PATH
|
|
75
|
-
end
|
|
87
|
+
@api_path = options[:api_path] ? File.expand_path(options[:api_path]) : API_PATH
|
|
76
88
|
end
|
|
77
89
|
|
|
78
90
|
def set_api_version(options)
|
|
@@ -89,13 +101,26 @@ module CloudstackClient
|
|
|
89
101
|
@api_version
|
|
90
102
|
end
|
|
91
103
|
|
|
104
|
+
def underscored_commands
|
|
105
|
+
@underscored_commands ||= @commands.each_with_object({}) do |(name, command), index|
|
|
106
|
+
index[camel_case_to_underscore(name)] = command
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
92
110
|
def load_commands
|
|
93
111
|
@commands = {}
|
|
94
|
-
|
|
112
|
+
@underscored_commands = nil
|
|
113
|
+
parsed = Zlib::GzipReader.open(@api_file) do |gz|
|
|
95
114
|
JSON.parse(gz.read)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
end
|
|
116
|
+
unless parsed.is_a?(Array) && parsed.all?(Hash)
|
|
117
|
+
raise ApiDefinitionError,
|
|
118
|
+
"Unable to read API definition '#{@api_file}': unexpected format (expected an array of command hashes)"
|
|
119
|
+
end
|
|
120
|
+
parsed.each { |cmd| @commands[cmd["name"]] = cmd }
|
|
121
|
+
rescue Zlib::Error, JSON::ParserError, SystemCallError, EOFError => e
|
|
122
|
+
raise ApiDefinitionError,
|
|
123
|
+
"Unable to read API definition '#{@api_file}': #{e.message}"
|
|
99
124
|
end
|
|
100
125
|
|
|
101
126
|
end
|
|
@@ -25,8 +25,11 @@ module CloudstackClient
|
|
|
25
25
|
|
|
26
26
|
args.each do |k, v|
|
|
27
27
|
k = k.to_s.gsub("_", "")
|
|
28
|
-
if v && @api.command_supports_param?(command["name"], k)
|
|
28
|
+
if !v.nil? && @api.command_supports_param?(command["name"], k)
|
|
29
29
|
params[k] = v
|
|
30
|
+
elsif !v.nil? && options.fetch(:strict_params, @options[:strict_params])
|
|
31
|
+
raise ParameterError,
|
|
32
|
+
"#{command['name']} does not support parameter #{k}"
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
|
|
@@ -10,7 +10,7 @@ module CloudstackClient
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
begin
|
|
13
|
-
config = YAML
|
|
13
|
+
config = YAML.safe_load(IO.read(file), permitted_classes: [Symbol])
|
|
14
14
|
rescue => e
|
|
15
15
|
message = "Can't load configuration from file '#{file}'."
|
|
16
16
|
if configuration[:debug]
|
|
@@ -20,13 +20,25 @@ module CloudstackClient
|
|
|
20
20
|
raise ConfigurationError, message
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
unless config.is_a?(Hash)
|
|
24
|
+
raise ConfigurationError, "Configuration file '#{file}' must contain a hash."
|
|
25
|
+
end
|
|
26
|
+
|
|
23
27
|
if env = configuration[:env] || config[:default]
|
|
24
28
|
unless config = config[env]
|
|
25
29
|
raise ConfigurationError, "Can't find environment #{env}."
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
unless config.
|
|
33
|
+
unless config.is_a?(Hash)
|
|
34
|
+
raise ConfigurationError, "Environment #{env} must contain a hash."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
required_keys = %i[url api_key secret_key]
|
|
38
|
+
missing_keys = required_keys.reject do |key|
|
|
39
|
+
config.key?(key) && !config[key].to_s.strip.empty?
|
|
40
|
+
end
|
|
41
|
+
unless missing_keys.empty?
|
|
30
42
|
message = "The environment #{env || '\'-\''} does not contain all required keys."
|
|
31
43
|
raise ConfigurationError, message
|
|
32
44
|
end
|
|
@@ -4,12 +4,16 @@ require "uri"
|
|
|
4
4
|
require "cgi"
|
|
5
5
|
require "net/http"
|
|
6
6
|
require "json"
|
|
7
|
+
require "cloudstack_client/request_handling"
|
|
7
8
|
|
|
8
9
|
module CloudstackClient
|
|
9
10
|
class Connection
|
|
10
11
|
include Utils
|
|
12
|
+
include RequestHandling
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
attr_reader :api_key, :secret_key
|
|
15
|
+
attr_accessor :api_url, :verbose, :debug, :symbolize_keys, :host, :read_timeout
|
|
16
|
+
attr_accessor :verify_ssl, :ca_file
|
|
13
17
|
attr_accessor :async_poll_interval, :async_timeout, :request_retries
|
|
14
18
|
|
|
15
19
|
DEF_POLL_INTERVAL = 2.0
|
|
@@ -25,6 +29,8 @@ module CloudstackClient
|
|
|
25
29
|
@debug = options[:debug] ? true : false
|
|
26
30
|
@symbolize_keys = options[:symbolize_keys] ? true : false
|
|
27
31
|
@host = options[:host]
|
|
32
|
+
@verify_ssl = options.fetch(:verify_ssl, true)
|
|
33
|
+
@ca_file = options[:ca_file]
|
|
28
34
|
@read_timeout = options[:read_timeout] || DEF_REQ_TIMEOUT
|
|
29
35
|
@async_poll_interval = options[:async_poll_interval] || DEF_POLL_INTERVAL
|
|
30
36
|
@async_timeout = options[:async_timeout] || DEF_ASYNC_TIMEOUT
|
|
@@ -37,60 +43,6 @@ module CloudstackClient
|
|
|
37
43
|
# Sends a synchronous request to the CloudStack API and returns the response as a Hash.
|
|
38
44
|
#
|
|
39
45
|
|
|
40
|
-
def send_request(params, opts = {})
|
|
41
|
-
params['response'] = 'json'
|
|
42
|
-
params['apiKey'] = @api_key
|
|
43
|
-
print_debug_output JSON.pretty_generate(params) if @debug
|
|
44
|
-
|
|
45
|
-
data = params_to_data(params)
|
|
46
|
-
uri = URI.parse "#{@api_url}?#{data}&signature=#{create_signature(data)}"
|
|
47
|
-
|
|
48
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
49
|
-
if uri.scheme == 'https'
|
|
50
|
-
http.use_ssl = true
|
|
51
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
52
|
-
end
|
|
53
|
-
http.read_timeout = @read_timeout
|
|
54
|
-
|
|
55
|
-
retries = 0
|
|
56
|
-
begin
|
|
57
|
-
req = Net::HTTP::Get.new(uri.request_uri)
|
|
58
|
-
req['Host'] = host if host.present?
|
|
59
|
-
response = http.request(req)
|
|
60
|
-
rescue => e
|
|
61
|
-
retries += 1
|
|
62
|
-
if retries < @request_retries
|
|
63
|
-
sleep(retries) # incremental back-off
|
|
64
|
-
print "." if @verbose
|
|
65
|
-
retry
|
|
66
|
-
end
|
|
67
|
-
raise ConnectionError, "API URL \'#{@api_url}\' is not reachable (after #{retries} attempt#{'s' if retries > 1}): #{e.message}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
begin
|
|
71
|
-
body = JSON.parse(response.body, symbolize_names: @symbolize_keys).values.first
|
|
72
|
-
rescue JSON::ParserError
|
|
73
|
-
raise ParseError,
|
|
74
|
-
"Response from server is not readable. Check if the API endpoint (#{@api_url}) is valid and accessible."
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
if response.is_a?(Net::HTTPOK)
|
|
78
|
-
return body unless body.respond_to?(:keys)
|
|
79
|
-
if body.size == 2 && body.key?(k('count'))
|
|
80
|
-
return opts[:include_count] ? body : body.reject { |key, _| key == k('count') }.values.first
|
|
81
|
-
elsif body.size == 1 && body.values.first.respond_to?(:keys)
|
|
82
|
-
item = body.values.first
|
|
83
|
-
return (item.is_a?(Array) || item.is_a?(Hash)) ? item : []
|
|
84
|
-
else
|
|
85
|
-
body.reject! { |key, _| key == k('count') } if body.key?(k('count')) && !opts[:include_count]
|
|
86
|
-
body.size == 0 ? [] : body
|
|
87
|
-
end
|
|
88
|
-
else
|
|
89
|
-
message = body[k('errortext')] rescue body
|
|
90
|
-
raise ApiError, "Status #{response.code}: #{message}."
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
46
|
##
|
|
95
47
|
# Sends an asynchronous request and waits for the response.
|
|
96
48
|
#
|
|
@@ -116,7 +68,11 @@ module CloudstackClient
|
|
|
116
68
|
when 1
|
|
117
69
|
return data[k('jobresult')]
|
|
118
70
|
when 2
|
|
119
|
-
|
|
71
|
+
result = data[k('jobresult')]
|
|
72
|
+
error_text = result.is_a?(Hash) ? result[k('errortext')] : nil
|
|
73
|
+
error_text ||= "Unknown error"
|
|
74
|
+
raise JobError,
|
|
75
|
+
"Request failed (#{data[k('jobresultcode')]}): #{error_text}."
|
|
120
76
|
end
|
|
121
77
|
|
|
122
78
|
STDOUT.flush if @verbose
|
|
@@ -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
|