rackspace 0.1.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5f21137370f518602031bc7bc0c3ec8983188ff1
4
+ data.tar.gz: 744d67b457cb92494713f485d7693bfdab7dbfa3
5
+ SHA512:
6
+ metadata.gz: 9faeabd8841aeed72d33d70b11777ae185f1c77d26ed92978d07491669597a244c1578d50cdf77e1426a205bb526b01555f138ee680207d91a082c84437560e3
7
+ data.tar.gz: 71eb99c1571bcaf16338f347f560b7eb251a392ffe6fa52b5e99eaac9830db54bf8cfbf26ee832c7eb108bd71eb8ffd53365bbca7adc5d307cd0095a1bd266c0
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ *.gem
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ rackspace
2
+ =========
3
+
4
+ (A reconstruction of the rackspace gem that was deleted from github).
5
+
6
+ A simple ruby interface to various Rackspace Cloud APIs.
7
+ Currently, the only implemented API is the Cloud Load Balancer API, but
8
+ others can be implemented very easily.
9
+
10
+ Load Balancers
11
+ ==============
12
+
13
+ Using the API is fairly simple, and the methods are largely self-documenting:
14
+
15
+ ```ruby
16
+ #!/usr/bin/env ruby
17
+ require 'rackspace'
18
+ require 'pp'
19
+
20
+ lbs = Rackspace::LoadBalancers.new()
21
+ lbs.authenticate({
22
+ :username => 'user',
23
+ :key => 'api-key-here'
24
+ })
25
+
26
+ # Pretty-print a list of load balancer names
27
+ puts "Load balancers:\n"
28
+ pp list = lbs.list()
29
+
30
+ # ... and the list of nodes
31
+ puts "Nodes on #{list[0]}:\n"
32
+ pp lbs.list_nodes(list[0])
33
+ ```
34
+
35
+ TODO
36
+ ====
37
+
38
+ * Implement other APIs/features
39
+ * Better documentation
data/lib/rackspace.rb ADDED
@@ -0,0 +1,2 @@
1
+ require 'rackspace/rackspace'
2
+ require 'rackspace/loadbalancers'
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #
4
+ # Interface to Rackspace's Cloud Load Balancer API
5
+ #
6
+ # TODO: SSL Termination, Virtual IPs, ACLs, Usage Reports, Health Monitors,
7
+ # Connection Persistence/Logging/Throttling, Content Caching
8
+ #
9
+ class Rackspace::LoadBalancers < Rackspace
10
+ def initialize
11
+ super
12
+ @ltype = 'loadBalancers' # for list()
13
+ @service = 'cloudLoadBalancers' # from @@auth_config
14
+ @base_uri = '/loadbalancers' # for Rackspace::get/put/etc.
15
+ @metadata = {}
16
+ end
17
+
18
+ # Get load balancer details
19
+ def get_details(name)
20
+ if @x_list[name][@ltype.chop]
21
+ # Get metadata for this lb
22
+ md = get(name,'metadata')
23
+ @metadata[name] = { 'lb' => md[md.keys[0]] }
24
+ end
25
+
26
+ @x_list[name][@ltype.chop]
27
+ end
28
+
29
+ # Create a load balancer
30
+ def create(attributes)
31
+ post(attributes)
32
+ end
33
+
34
+ # Update load balancer attributes
35
+ def update(name,attributes)
36
+ allowed_attribs = [ 'name', 'algorithm', 'protocol', 'port' ]
37
+ attributes.keys.each { |k| attributes.delete(k) unless allowed_attribs.index(k) }
38
+ put({ @ltype.chop => attributes },name)
39
+ end
40
+
41
+ # Get load balancer stats
42
+ def stats(name)
43
+ get(name,'stats')
44
+ end
45
+
46
+ # Get Allowed Domains
47
+ def get_allowed_domains
48
+ out = []
49
+ ad = get(nil,'alloweddomains')
50
+ ad[ad.keys[0]].each { |x| out.push(x['allowedDomain']['name']) }
51
+ return out
52
+ end
53
+
54
+ # List nodes
55
+ def list_nodes(name)
56
+ @metadata[name] = { 'lb' => {} } unless @metadata[name]
57
+ @metadata[name]['nodes'] = {}
58
+ nodes = get_subitems(name,'nodes','address')
59
+ nodes.each { |node|
60
+ # Get metadata for this node
61
+ md = get(name,'nodes/' + @s2id[name]['nodes'][node['address']] + '/metadata')
62
+ @metadata[name]['nodes'][node['address']] = md[md.keys[0]]
63
+ }
64
+ end
65
+
66
+ # Add nodes
67
+ def add_nodes(name,nodes)
68
+ nodes.each { |node|
69
+ raise 'You must specify an address and a port when adding a node' unless node[:address] && node[:port]
70
+ node[:condition] = 'ENABLED' unless node[:condition]
71
+ node[:type] = 'PRIMARY' unless node[:type]
72
+ }
73
+
74
+ create_subitems(name,'nodes',nodes)
75
+ end
76
+
77
+ # Modify nodes
78
+ def update_nodes(name,nodes)
79
+ update_subitems(name,'nodes',[ 'condition', 'weight', 'type' ],nodes)
80
+ end
81
+
82
+ # Delete nodes
83
+ def delete_nodes(name,nodes)
84
+ delete_subitems(name,'nodes',nodes)
85
+ end
86
+
87
+ # Get error page
88
+ def get_error_page(name)
89
+ get(name,'errorpage')['content']
90
+ end
91
+
92
+ # Set error page
93
+ def set_error_page(name,content)
94
+ put({ :content => content },name,'errorpage')
95
+ end
96
+
97
+ # Delete error page
98
+ def delete_error_page(name)
99
+ delete(name,'errorpage')
100
+ end
101
+
102
+ # List Virtual IPs
103
+ def list_vips(name)
104
+ get_subitems(name,'virtualIps','address')
105
+ end
106
+
107
+ # Add Virtual IPs
108
+ def add_vips(name,vips)
109
+ vips.each { |vip|
110
+ raise 'You must specify the ipVersion and type when adding a virtual IP' unless vip[:ipVersion] && vip[:type]
111
+ }
112
+
113
+ create_subitems(name,'virtualIps',vips)
114
+ end
115
+
116
+ # Delete Virtual IPs
117
+ def delete_vips(name,vips)
118
+ delete_subitems(name,'virtualIps',vips)
119
+ end
120
+
121
+ # Get metadata
122
+ def get_metadata(name,node=nil)
123
+ raise "No such node: #{node} on #{name}" unless !node || @s2id[name]['nodes'][node]
124
+ mda = node ? @metadata[name]['nodes'][node] : @metadata[name]['lb']
125
+ out = {}
126
+ mda.each { |md| out[md['key']] = md['value'] }
127
+ return out
128
+ end
129
+
130
+ # Add metadata
131
+ def add_metadata(name,values,node=nil)
132
+ raise "No such node: #{node} on #{name}" unless !node || @s2id[name]['nodes'][node]
133
+ resp = post({ 'metadata' => values },name,(node ? 'nodes/' + @s2id[name]['nodes'][node] + '/' : '') + 'metadata')
134
+ resp['metadata'].each { |md|
135
+ if node
136
+ @metadata[name]['nodes'][node].push(md)
137
+ else
138
+ @metadata[name]['lb'].push(md)
139
+ end
140
+ }
141
+ end
142
+
143
+ # Update metadata
144
+ def update_metadata(name,values,node=nil)
145
+ raise "No such node: #{node} on #{name}" unless !node || @s2id[name]['nodes'][node]
146
+ mda = (node ? @metadata[name]['nodes'][node] : @metadata[name]['lb'])
147
+
148
+ values.keys.each { |k|
149
+ md = nil
150
+ mda.each { |mdx| md = mdx if mdx['key'] == k }
151
+ if md
152
+ put({ 'meta' => { 'value' => values[k] }},name,
153
+ (node ? 'nodes/' + @s2id[name]['nodes'][node] + '/' : '') + 'metadata/' + md['id'].to_s)
154
+ md['value'] = values[k]
155
+ end
156
+ }
157
+ end
158
+
159
+ # Delete metadata
160
+ def delete_metadata(name,key,node=nil)
161
+ raise "No such node: #{node} on #{name}" unless !node || @s2id[name]['nodes'][node]
162
+ md = nil
163
+ mda = (node ? @metadata[name]['nodes'][node] : @metadata[name]['lb'])
164
+ mda.each { |mdx| md = mdx if mdx['key'] == key }
165
+ if md
166
+ delete(name,(node ? 'nodes/' + @s2id[name]['nodes'][node] + '/' : '') + 'metadata/' + md['id'].to_s)
167
+ mda.delete(md)
168
+ end
169
+ end
170
+ end
171
+
172
+ # vi:set ts=2:
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ #
8
+ # Base class for Rackspace Cloud API Services
9
+ #
10
+ # This class handles authentication, and all basic
11
+ # service features (GET,PUT,DELETE) transparently.
12
+ #
13
+ class Rackspace
14
+ def initialize
15
+ @@credentials = {}
16
+ @@authenticated = 0
17
+ @@auth_config = {}
18
+ @ltype = nil
19
+ @service = nil
20
+ @base_uri = nil
21
+ @region = 'ORD'
22
+ @name2id = {}
23
+ @x_list = {}
24
+ @s2id = {} # Subitem to ID
25
+ @nfxx = {} # name field lookup
26
+ end
27
+
28
+ # Get/Set region
29
+ def region(region=nil)
30
+ @region = (region ? region : @region)
31
+ return @region
32
+ end
33
+
34
+ # Do an actual request, returns a hash representing the JSON result (or do custom processing)
35
+ def do_req(verb,url,body=nil,fun=nil)
36
+ raise 'This method shouldn\'t be called directly' unless (@ltype && @service && @base_uri)
37
+
38
+ # We don't need to do this when authenticating (obviously)
39
+ unless url.index('v1.1/auth')
40
+ raise 'You must call authenticate() before anything else' unless @@authenticated
41
+
42
+ # Get the service map entries for this service
43
+ raise 'No service map for service "' + @service + '"' unless cfg = @@auth_config['auth']['serviceCatalog'][@service]
44
+
45
+ # Get the service URL based on region (TODO: handle snet)
46
+ av_regions = []
47
+ regioned = false
48
+ service_url = nil
49
+ cfg.each { |x|
50
+ if (x['region'])
51
+ av_regions.push(x['region'])
52
+ regioned = true
53
+ service_url = (x['region'] == @region) ? x['publicURL'] : service_url
54
+ elsif (x['publicURL'])
55
+ service_url = x['publicURL']
56
+ end
57
+ }
58
+
59
+ # Make sure region is valid (if applicable)
60
+ raise 'Invalid region "' + @region + '" for service "' + @service +
61
+ '" available: [' + av_regions.join(',') + "]" if !service_url && regioned
62
+ raise 'Unable to determine service_url for service "' + @service + '"' if !service_url
63
+ else
64
+ service_url = ''
65
+ end
66
+
67
+ # Extract info from the url
68
+ uri = URI(service_url + url)
69
+ http = Net::HTTP.new(uri.host,uri.port)
70
+ http.use_ssl = true if uri.scheme == 'https'
71
+
72
+ # Figure the type of request to make
73
+ case verb
74
+ when 'GET'
75
+ req = Net::HTTP::Get.new(uri.path)
76
+ when 'POST'
77
+ raise "POST requires a body" unless body
78
+ req = Net::HTTP::Post.new(uri.path)
79
+ when 'PUT'
80
+ raise "PUT requires a body" unless body
81
+ req = Net::HTTP::Put.new(uri.path)
82
+ when 'DELETE'
83
+ req = Net::HTTP::Delete.new(uri.path)
84
+ else
85
+ raise 'Invalid HTTP verb: "' + verb + '"'
86
+ end
87
+
88
+ # Add Accept
89
+ req.add_field('Accept','application/json')
90
+
91
+ # Add the body
92
+ req.content_type = 'application/json'
93
+ req.body = body
94
+
95
+ # Do it (and handle HTTP error responses)
96
+ fun = fun ? fun : proc { |resp| JSON.parse(resp.body) }
97
+
98
+ begin
99
+ # Delete and add the token (in case of 'retry')
100
+ req.delete('X-Auth-Token')
101
+ req.add_field('X-Auth-Token',@@auth_config['auth']['token']['id']) if @@authenticated && @@auth_config && !@@auth_config.empty?
102
+
103
+ # Send the request, get the response
104
+ resp = http.request(req)
105
+
106
+ case resp.code
107
+ when '200', '201', '202'
108
+ fun.call(resp)
109
+ when '400'
110
+ raise 'Bad Request: ' + resp.body
111
+ when '401'
112
+ bod = JSON.parse(resp.body)
113
+ raise Rackspace::ExpiredToken, bod if @@authenticated && @@auth_config && !@@auth_config.empty?
114
+ raise 'Unauthorized: ' + (bod.empty? ? resp.body : (bod['unauthorized'] ? bod['unauthorized']['message'] : bod['message']))
115
+ when '404'
116
+ raise 'Resource not found'
117
+ when '413'
118
+ raise 'Don\'t be a butt-knocker. You\'re sending too many requests.'
119
+ when '422'
120
+ bod = JSON.parse(resp.body)
121
+ raise Rackspace::Retry, bod['message'] if /considered immutable/.match(bod['message'])
122
+ raise bod['message']
123
+ when '500'
124
+ raise 'Internal Error: ' + resp.body
125
+ when '503'
126
+ raise 'Service Unavailable: ' + resp.body
127
+ else
128
+ raise 'Unknown Response: [' + resp.code + '] ' + resp.body
129
+ end
130
+ rescue Rackspace::ExpiredToken
131
+ authenticate(@@credentials)
132
+ retry
133
+ rescue Rackspace::Retry
134
+ sleep(5)
135
+ retry
136
+ end
137
+ end
138
+
139
+ #
140
+ # Authenticate to Rackspace (v1.1)
141
+ #
142
+ # credentials is a hash of:
143
+ # {
144
+ # :username => 'username',
145
+ # :key => 'api_key'
146
+ # }
147
+ #
148
+ # uk should be 1 for UK-based accounts
149
+ #
150
+ def authenticate(credentials, uk=0)
151
+ # Check to make sure we have all our credentials
152
+ raise 'Missing :username' unless credentials[:username]
153
+ raise 'Missing :key' unless credentials[:key]
154
+ creds = {'credentials' => credentials}.to_json
155
+ @@credentials = credentials
156
+
157
+ # Post, and handle the response
158
+ do_req('POST','https://' + ((uk == 1) ? 'lon.' : '') + 'identity.api.rackspacecloud.com/v1.1/auth',creds,proc { |resp|
159
+ @@auth_config = JSON.parse(resp.body)
160
+ @@authenticated = 1
161
+ })
162
+ end
163
+
164
+ # List all 'things' of the given type
165
+ def list
166
+ raise 'This method shouldn\'t be called directly' unless (@ltype && @service && @base_uri)
167
+ raise 'You must call authenticate() before anything else' unless @@authenticated
168
+ @x_list = {}
169
+
170
+ do_req('GET',@base_uri,nil,proc { |resp|
171
+ JSON.parse(resp.body)[@ltype].each { |z|
172
+ @name2id[z['name']] = z['id'].to_s
173
+ @x_list[z['name']] = get(z['name'])
174
+ }
175
+ })
176
+
177
+ return @x_list.keys
178
+ end
179
+
180
+ # Get one/all resource(s)
181
+ def get(name=nil,sub_uri=nil)
182
+ # Otherwise, assume the user knows what they want
183
+ raise 'Rackspace::get: ' + @ltype + '/' + name + ' doesn\'t exist' unless !name || (name && @name2id[name])
184
+ uri = @base_uri
185
+ uri = uri + '/' + @name2id[name] if name
186
+ uri = uri + '/' + sub_uri if sub_uri
187
+ do_req('GET',uri)
188
+ end
189
+
190
+ #
191
+ # Create one resource
192
+ #
193
+ # If name is specified, it is assumed that a sub-object is intended
194
+ # If sub_uri is specified it is assumed to be a URI relative to name
195
+ #
196
+ # This update the name -> id mapping for the main resource
197
+ # sub-resources should be handled by the actual API implementation.
198
+ #
199
+ def post(attributes,name=nil,sub_uri=nil)
200
+ raise 'Rackspace::post: name must be specified' unless (name || name = attributes['name'])
201
+ raise 'Rackspace::post: attributes must be specified' unless attributes
202
+ uri = @base_uri
203
+ uri = uri + '/' + @name2id[name] if name
204
+ uri = uri + '/' + sub_uri if sub_uri
205
+ do_req('POST',uri,attributes.to_json,proc { |resp|
206
+ data = JSON.parse(resp.body)
207
+ unless sub_uri
208
+ @x_list[name] = data
209
+ @name2id[name] = @x_list[name][@ltype]['id']
210
+ end
211
+
212
+ return data
213
+ })
214
+ end
215
+
216
+ # Modify one resource
217
+ def put(attributes,name,sub_uri=nil)
218
+ raise 'Rackspace::put: name must be specified' unless name
219
+ raise 'Rackspace::put: attributes must be specified' unless attributes && attributes[attributes.keys[0]]
220
+ raise 'Rackspace::put: ' + @ltype + '/' + name + ' doesn\'t exist' unless @name2id[name]
221
+ uri = @base_uri + '/' + @name2id[name]
222
+ uri = uri + '/' + sub_uri if sub_uri
223
+ do_req('PUT',uri,attributes.to_json,proc { |resp|
224
+ # Handle name changes
225
+ attributes = attributes[attributes.keys[0]]
226
+ if (!sub_uri && attributes['name'] && attributes['name'] != name)
227
+ @x_list[attributes['name']] = @x_list[name]
228
+ @name2id[attributes['name']] = @name2id[name]
229
+ @name2id.delete(name)
230
+ @x_list.delete(name)
231
+ name = attributes['name']
232
+ end
233
+
234
+ # Update attributes
235
+ attributes.keys.each { |k| @x_list[name][@ltype.chop][k] = attributes[k] } unless sub_uri
236
+ })
237
+ end
238
+
239
+ # Delete one resource
240
+ def delete(name,sub_uri=nil)
241
+ raise 'Rackspace::delete: ' + @ltype + '/' + name + ' doesn\'t exist' unless (name && @name2id[name])
242
+ uri = @base_uri + '/' + @name2id[name]
243
+ uri = uri + '/' + sub_uri if sub_uri
244
+ do_req('DELETE',uri,nil,proc { |resp|
245
+ unless sub_uri
246
+ @name2id.delete(name)
247
+ @x_list.delete(name)
248
+ end
249
+ })
250
+ end
251
+
252
+ # Get subitems
253
+ def get_subitems(name,type,namefield)
254
+ @s2id[name] = { type => {} }
255
+ @nfxx[type] = namefield unless @nfxx[type]
256
+ items = @x_list[name][@ltype.chop][type]
257
+ items.each { |item| @s2id[name][type][item[namefield]] = item['id'].to_s }
258
+ return items
259
+ end
260
+
261
+ # Create subitems
262
+ def create_subitems(name,type,attributes)
263
+ resp = post({ type => attributes },name,type.downcase)[type]
264
+ resp.each { |item|
265
+ @s2id[name][type][item[@nfxx[type]]] = item['id'].to_s
266
+ @x_list[name][@ltype.chop][type].push(item)
267
+ }
268
+ return resp
269
+ end
270
+
271
+ # Update subitems
272
+ def update_subitems(name,type,allowed_attribs,items)
273
+ items.keys.each { |item|
274
+ raise "No such #{type.chop}: #{item} on #{name}" unless @s2id[name][type][item]
275
+ items[item].keys.each { |k| items[item].delete(k) unless allowed_attribs.index(k) }
276
+ put({ type.chop => items[item] },name,type.downcase + '/' + @s2id[name][type][item])
277
+
278
+ # Update the local copy
279
+ @x_list[name][@ltype.chop][type].each { |xitem|
280
+ if xitem[@nfxx[type]] == item
281
+ items[item].keys.each { |k| xitem[k] = items[item][k] if xitem[k] && items[item][k] }
282
+ end
283
+ }
284
+ }
285
+ end
286
+
287
+ # Delete subitems
288
+ def delete_subitems(name,type,items)
289
+ items.each { |item|
290
+ if @s2id[name][type][item]
291
+ delete(name,type.downcase + '/' + @s2id[name][type][item])
292
+ @s2id[name][type].delete(item)
293
+ @x_list[name][@ltype.chop][type].each { |xitem|
294
+ @x_list[name][@ltype.chop][type].delete(xitem) if xitem[@nfxx[type]] == item
295
+ }
296
+ end
297
+ }
298
+ end
299
+ end
300
+
301
+ class Rackspace::ExpiredToken < Exception
302
+ end
303
+
304
+ class Rackspace::Retry < Exception
305
+ end
306
+
307
+ # vi:set ts=2:
data/rackspace.gemspec ADDED
@@ -0,0 +1,18 @@
1
+ # coding: utf-8
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "rackspace"
5
+ spec.version = "0.1.2"
6
+ spec.authors = ["Tim Hentenaar", "Richard Grainger"]
7
+ spec.email = ["grainger@gmail.com"]
8
+
9
+ spec.summary = "Simple interface to Rackspace's Cloud APIs"
10
+ spec.description = "\tRuby interface to various Rackspace Cloud APIs.\n\n\tCurrently only the Cloud Load Balancers API is supported. See the README for more\n\tdetails.\n"
11
+ spec.homepage = "https://gitlab.com/harbottle/rackspace"
12
+
13
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
14
+ spec.require_paths << "lib"
15
+
16
+ spec.add_dependency("json", ">=0")
17
+
18
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rackspace
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Tim Hentenaar
8
+ - Richard Grainger
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2018-04-20 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: json
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ description: "\tRuby interface to various Rackspace Cloud APIs.\n\n\tCurrently only
29
+ the Cloud Load Balancers API is supported. See the README for more\n\tdetails.\n"
30
+ email:
31
+ - grainger@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".gitignore"
37
+ - README.md
38
+ - lib/rackspace.rb
39
+ - lib/rackspace/loadbalancers.rb
40
+ - lib/rackspace/rackspace.rb
41
+ - rackspace.gemspec
42
+ homepage: https://gitlab.com/harbottle/rackspace
43
+ licenses: []
44
+ metadata: {}
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubyforge_project:
62
+ rubygems_version: 2.6.14
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Simple interface to Rackspace's Cloud APIs
66
+ test_files: []