vagrant_cloud 2.0.1 → 3.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,163 @@
1
+ module VagrantCloud
2
+ class Box
3
+ class Version < Data::Mutable
4
+ attr_reader :box
5
+ attr_required :version
6
+ attr_optional :status, :description_html, :description_markdown,
7
+ :created_at, :updated_at, :number, :providers, :description
8
+
9
+ attr_mutable :description
10
+
11
+ def initialize(box:, **opts)
12
+ if !box.is_a?(Box)
13
+ raise TypeError, "Expecting type `#{Box.name}` but received `#{box.class.name}`"
14
+ end
15
+ @box = box
16
+ opts[:providers] = Array(opts[:providers]).map do |provider|
17
+ if provider.is_a?(Provider)
18
+ provider
19
+ else
20
+ Provider.load(version: self, **provider)
21
+ end
22
+ end
23
+ super(opts)
24
+ clean!
25
+ end
26
+
27
+ # Delete this version
28
+ #
29
+ # @return [nil]
30
+ # @note This will delete the version, and all providers
31
+ def delete
32
+ if exist?
33
+ box.organization.account.client.box_version_delete(
34
+ username: box.username,
35
+ name: box.name,
36
+ version: version
37
+ )
38
+ # Remove self from box
39
+ v = box.versions.dup
40
+ v.delete(self)
41
+ box.clean(data: {versions: v})
42
+ end
43
+ nil
44
+ end
45
+
46
+ # Release this version
47
+ #
48
+ # @return [self]
49
+ def release
50
+ if released?
51
+ raise Error::BoxError::VersionStatusChangeError,
52
+ "Version #{version} is already released for box #{box.tag}"
53
+ end
54
+ if !exist?
55
+ raise Error::BoxError::VersionStatusChangeError,
56
+ "Version #{version} for box #{box.tag} must be saved before release"
57
+ end
58
+ result = box.organization.account.client.box_version_release(
59
+ username: box.username,
60
+ name: box.name,
61
+ version: version
62
+ )
63
+ clean(data: result, only: :status)
64
+ self
65
+ end
66
+
67
+ # Revoke this version
68
+ #
69
+ # @return [self]
70
+ def revoke
71
+ if !released?
72
+ raise Error::BoxError::VersionStatusChangeError,
73
+ "Version #{version} is not yet released for box #{box.tag}"
74
+ end
75
+ result = box.organization.account.client.box_version_revoke(
76
+ username: box.username,
77
+ name: box.name,
78
+ version: version
79
+ )
80
+ clean(data: result, only: :status)
81
+ self
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def released?
86
+ status == "active"
87
+ end
88
+
89
+ # Add a new provider for this version
90
+ #
91
+ # @param [String] pname Name of provider
92
+ # @return [Provider]
93
+ def add_provider(pname)
94
+ if providers.any? { |p| p.name == pname }
95
+ raise Error::BoxError::VersionProviderExistsError,
96
+ "Provider #{pname} already exists for box #{box.tag} version #{version}"
97
+ end
98
+ pv = Provider.new(version: self, name: pname)
99
+ clean(data: {providers: providers + [pv]})
100
+ pv
101
+ end
102
+
103
+ # Check if this instance is dirty
104
+ #
105
+ # @param [Boolean] deep Check nested instances
106
+ # @return [Boolean] instance is dirty
107
+ def dirty?(key=nil, deep: false)
108
+ if key
109
+ super(key)
110
+ else
111
+ d = super() || !exist?
112
+ if deep && !d
113
+ d = providers.any? { |p| p.dirty?(deep: true) }
114
+ end
115
+ d
116
+ end
117
+ end
118
+
119
+ # @return [Boolean] version exists remotely
120
+ def exist?
121
+ !!created_at
122
+ end
123
+
124
+ # Save the version if any changes have been made
125
+ #
126
+ # @return [self]
127
+ def save
128
+ save_version if dirty?
129
+ save_providers if dirty?(deep: true)
130
+ self
131
+ end
132
+
133
+ protected
134
+
135
+ # Save the version
136
+ #
137
+ # @return [self]
138
+ def save_version
139
+ params = {
140
+ username: box.username,
141
+ name: box.name,
142
+ version: version,
143
+ description: description
144
+ }
145
+ if exist?
146
+ result = box.organization.account.client.box_version_update(**params)
147
+ else
148
+ result = box.organization.account.client.box_version_create(**params)
149
+ end
150
+ clean(data: result, ignores: :providers)
151
+ self
152
+ end
153
+
154
+ # Save the providers if any require saving
155
+ #
156
+ # @return [self]
157
+ def save_providers
158
+ Array(providers).map(&:save)
159
+ self
160
+ end
161
+ end
162
+ end
163
+ end
@@ -1,73 +1,478 @@
1
- require 'json'
2
-
3
1
  module VagrantCloud
4
2
  class Client
3
+ include Logger
5
4
  # Base Vagrant Cloud API URL
6
- URL_BASE = 'https://vagrantcloud.com/api/v1'.freeze
7
- attr_accessor :access_token
8
-
9
- # @param [String] access_token - token used to authenticate API requests
10
- # @param [String] url_base - URL used to make API requests
11
- def initialize(access_token = nil, url_base = nil)
12
- if url_base
13
- @url_base = url_base
14
- else
15
- @url_base = URL_BASE
16
- end
5
+ DEFAULT_URL = 'https://vagrantcloud.com/api/v1'.freeze
6
+ # Valid methods that can be retried
7
+ IDEMPOTENT_METHODS = [:get, :head].freeze
8
+ # Number or allowed retries
9
+ IDEMPOTENT_RETRIES = 3
10
+ # Number of seconds to wait between retries
11
+ IDEMPOTENT_RETRY_INTERVAL = 2
12
+ # Methods which require query parameters
13
+ QUERY_PARAMS_METHODS = [:get, :head, :delete].freeze
14
+ # Default instrumentor
15
+ DEFAULT_INSTRUMENTOR = Instrumentor::Collection.new
17
16
 
18
- @access_token = access_token
17
+ # @return [Instrumentor::Collection]
18
+ def self.instrumentor
19
+ DEFAULT_INSTRUMENTOR
19
20
  end
20
21
 
21
- # @param [String] method
22
- # @param [String] path
23
- # @param [Hash] params
24
- # @param [String] token
25
- # @return [Hash]
26
- def request(method, path, params = {}, token = nil)
27
- headers = {}
22
+ # @return [String] Access token for Vagrant Cloud
23
+ attr_reader :access_token
24
+ # @return [String] Base request path
25
+ attr_reader :path_base
26
+ # @return [String] URL for initializing connection
27
+ attr_reader :url_base
28
+ # @return [Integer] Number of retries on idempotent requests
29
+ attr_reader :retry_count
30
+ # @return [Integer] Number of seconds to wait between requests
31
+ attr_reader :retry_interval
32
+ # @return [Instrumentor::Collection] Instrumentor in use
33
+ attr_reader :instrumentor
28
34
 
29
- if token
30
- headers['Authorization'] = "Bearer #{token}"
31
- elsif @access_token
32
- headers['Authorization'] = "Bearer #{@access_token}"
35
+ # Create a new Client instance
36
+ #
37
+ # @param [String] access_token Authentication token for API requests
38
+ # @param [String] url_base URL used to make API requests
39
+ # @param [Integer] retry_count Number of retries on idempotent requests
40
+ # @param [Integer] retry_interval Number of seconds to wait between requests
41
+ # @param [Instrumentor::Core] instrumentor Instrumentor to use
42
+ # @return [Client]
43
+ def initialize(access_token: nil, url_base: nil, retry_count: nil, retry_interval: nil, instrumentor: nil)
44
+ url_base = DEFAULT_URL if url_base.nil?
45
+ remote_url = URI.parse(url_base)
46
+ @url_base = "#{remote_url.scheme}://#{remote_url.host}"
47
+ @path_base = remote_url.path
48
+ @access_token = access_token.dup.freeze if access_token
49
+ if !@access_token && ENV["VAGRANT_CLOUD_TOKEN"]
50
+ @access_token = ENV["VAGRANT_CLOUD_TOKEN"].dup.freeze
51
+ end
52
+ @retry_count = retry_count.nil? ? IDEMPOTENT_RETRIES : retry_count.to_i
53
+ @retry_interval = retry_interval.nil? ? IDEMPOTENT_RETRY_INTERVAL : retry_interval.to_i
54
+ @instrumentor = instrumentor.nil? ? Instrumentor::Collection.new : instrumentor
55
+ headers = {}.tap do |h|
56
+ h["Accept"] = "application/json"
57
+ h["Authorization"] = "Bearer #{@access_token}" if @access_token
58
+ h["Content-Type"] = "application/json"
33
59
  end
60
+ @connection_lock = Mutex.new
61
+ @connection = Excon.new(url_base,
62
+ headers: headers,
63
+ instrumentor: @instrumentor
64
+ )
65
+ end
34
66
 
35
- headers['Accept'] = 'application/json'
67
+ # Use the remote connection
68
+ #
69
+ # @param [Boolean] wait Wait for the connection to be available
70
+ # @yieldparam [Excon::Connection]
71
+ # @return [Object]
72
+ def with_connection(wait: true)
73
+ raise ArgumentError,
74
+ "Block expected but no block given" if !block_given?
75
+ if !wait
76
+ raise Error::ClientError::ConnectionLockedError,
77
+ "Connection is currently locked" if !@connection_lock.try_lock
78
+ begin
79
+ yield @connection
80
+ ensure
81
+ @connection_lock.unlock
82
+ end
83
+ else
84
+ @connection_lock.synchronize { yield @connection }
85
+ end
86
+ end
36
87
 
88
+ # Send a request
89
+ # @param [String, Symbol] method Request method
90
+ # @param [String, URI] path Path of request
91
+ # @param [Hash] params Parameters to send with request
92
+ # @return [Hash]
93
+ def request(path:, method: :get, params: {})
94
+ if !path.start_with?(path_base)
95
+ # Build the full path for the request and clean it
96
+ path = [path_base, path].compact.join("/").gsub(/\/{2,}/, "/")
97
+ end
98
+ method = method.to_s.downcase.to_sym
99
+
100
+ # Build base request parameters
37
101
  request_params = {
38
102
  method: method,
39
- url: @url_base + path,
40
- headers: headers,
41
- ssl_version: 'TLSv1'
103
+ path: path,
104
+ expects: [200, 201, 204]
42
105
  }
43
106
 
44
- if ['get', 'head', 'delete'].include?(method.downcase)
45
- headers[:params] = params
46
- else
47
- request_params[:payload] = params
107
+ # If this is an idempotent request allow it to retry on failure
108
+ if IDEMPOTENT_METHODS.include?(method)
109
+ request_params[:idempotent] = true
110
+ request_params[:retry_limit] = retry_count
111
+ request_params[:retry_interval] = retry_interval
48
112
  end
49
113
 
50
- begin
51
- result = RestClient::Request.execute(request_params)
114
+ # If parameters are provided, set them in the expected location
115
+ if !params.empty?
116
+ # Copy the parameters so we can freely modify them
117
+ params = clean_parameters(params)
52
118
 
53
- parse_json(result)
54
- rescue RestClient::ExceptionWithResponse => e
55
- raise ClientError.new(e.message, e.http_body, e.http_code)
119
+ if QUERY_PARAMS_METHODS.include?(method)
120
+ request_params[:query] = params
121
+ else
122
+ request_params[:body] = JSON.dump(params)
123
+ end
56
124
  end
125
+
126
+ # Set a request ID so we can track request/responses
127
+ request_params[:headers] = {"X-Request-Id" => SecureRandom.uuid}
128
+
129
+ begin
130
+ result = with_connection { |c| c.request(request_params) }
131
+ rescue Excon::Error::HTTPStatus => err
132
+ raise Error::ClientError::RequestError.new(
133
+ "Vagrant Cloud request failed", err.response.body, err.response.status)
134
+ rescue Excon::Error => err
135
+ raise Error::ClientError, err.message
136
+ end
137
+
138
+ parse_json(result.body)
139
+ end
140
+
141
+ # Clone this client to create a new instance
142
+ #
143
+ # @param [String] access_token Authentication token for API requests
144
+ # @return [Client]
145
+ def clone(access_token: nil)
146
+ self.class.new(access_token: access_token, url_base: url_base,
147
+ retry_count: retry_count, retry_interval: retry_interval
148
+ )
149
+ end
150
+
151
+ # Submit a search on Vagrant Cloud
152
+ #
153
+ # @param [String] query Search query
154
+ # @param [String] provider Limit results to only this provider
155
+ # @param [String] sort Field to sort results ("downloads", "created", or "updated")
156
+ # @param [String] order Order to return sorted result ("desc" or "asc")
157
+ # @param [Integer] limit Number of results to return
158
+ # @param [Integer] page Page number of results to return
159
+ # @return [Hash]
160
+ def search(query: Data::Nil, provider: Data::Nil, sort: Data::Nil, order: Data::Nil, limit: Data::Nil, page: Data::Nil)
161
+ params = {
162
+ q: query,
163
+ provider: provider,
164
+ sort: sort,
165
+ order: order,
166
+ limit: limit,
167
+ page: page
168
+ }
169
+ request(method: :get, path: "search", params: params)
170
+ end
171
+
172
+ # Create a new access token
173
+ #
174
+ # @param [String] username Vagrant Cloud username
175
+ # @param [String] password Vagrant Cloud password
176
+ # @param [String] description Description of token
177
+ # @param [String] code 2FA code
178
+ # @return [Hash]
179
+ def authentication_token_create(username:, password:, description: Data::Nil, code: Data::Nil)
180
+ params = {
181
+ user: {
182
+ login: username,
183
+ password: password
184
+ },
185
+ token: {
186
+ description: description
187
+ },
188
+ two_factor: {
189
+ code: code
190
+ }
191
+ }
192
+ request(method: :post, path: "authenticate", params: params)
193
+ end
194
+
195
+ # Delete the token currently in use
196
+ #
197
+ # @return [Hash] empty
198
+ def authentication_token_delete
199
+ request(method: :delete, path: "authenticate")
200
+ end
201
+
202
+ # Request a 2FA code is sent
203
+ #
204
+ # @param [String] username Vagrant Cloud username
205
+ # @param [String] password Vagrant Cloud password
206
+ # @param [String] delivery_method Delivery method of 2FA
207
+ # @param [String] password Account password
208
+ # @return [Hash]
209
+ def authentication_request_2fa_code(username:, password:, delivery_method:)
210
+ params = {
211
+ two_factor: {
212
+ delivery_method: delivery_method
213
+ },
214
+ user: {
215
+ login: username,
216
+ password: password
217
+ }
218
+ }
219
+
220
+ request(method: :post, path: "two-factor/request-code", params: params)
221
+ end
222
+
223
+ # Validate the current token
224
+ #
225
+ # @return [Hash] emtpy
226
+ def authentication_token_validate
227
+ request(method: :get, path: "authenticate")
228
+ end
229
+
230
+ # Get an organization
231
+ #
232
+ # @param [String] name Name of organization
233
+ # @return [Hash] organization information
234
+ def organization_get(name:)
235
+ request(method: :get, path: "user/#{name}")
236
+ end
237
+
238
+ # Get an existing box
239
+ #
240
+ # @param [String] username Username/organization name to create box under
241
+ # @param [String] name Box name
242
+ # @return [Hash] box information
243
+ def box_get(username:, name:)
244
+ request(method: :get, path: "/box/#{username}/#{name}")
245
+ end
246
+
247
+ # Create a new box
248
+ #
249
+ # @param [String] username Username/organization name to create box under
250
+ # @param [String] name Box name
251
+ # @param [String] short_description Short description of box
252
+ # @param [String] description Long description of box (markdown supported)
253
+ # @param [Boolean] is_private Set if box is private
254
+ # @return [Hash] box information
255
+ def box_create(username:, name:, short_description: Data::Nil, description: Data::Nil, is_private: Data::Nil)
256
+ request(method: :post, path: '/boxes', params: {
257
+ username: username,
258
+ name: name,
259
+ short_description: short_description,
260
+ description: description,
261
+ is_private: is_private
262
+ })
263
+ end
264
+
265
+ # Update an existing box
266
+ #
267
+ # @param [String] username Username/organization name to create box under
268
+ # @param [String] name Box name
269
+ # @param [String] short_description Short description of box
270
+ # @param [String] description Long description of box (markdown supported)
271
+ # @param [Boolean] is_private Set if box is private
272
+ # @return [Hash] box information
273
+ def box_update(username:, name:, short_description: Data::Nil, description: Data::Nil, is_private: Data::Nil)
274
+ params = {
275
+ short_description: short_description,
276
+ description: description,
277
+ is_private: is_private
278
+ }
279
+ request(method: :put, path: "/box/#{username}/#{name}", params: params)
280
+ end
281
+
282
+ # Delete an existing box
283
+ #
284
+ # @param [String] username Username/organization name to create box under
285
+ # @param [String] name Box name
286
+ # @return [Hash] box information
287
+ def box_delete(username:, name:)
288
+ request(method: :delete, path: "/box/#{username}/#{name}")
289
+ end
290
+
291
+ # Get an existing box version
292
+ #
293
+ # @param [String] username Username/organization name to create box under
294
+ # @param [String] name Box name
295
+ # @param [String] version Box version
296
+ # @return [Hash] box version information
297
+ def box_version_get(username:, name:, version:)
298
+ request(method: :get, path: "/box/#{username}/#{name}/version/#{version}")
299
+ end
300
+
301
+ # Create a new box version
302
+ #
303
+ # @param [String] username Username/organization name to create box under
304
+ # @param [String] name Box name
305
+ # @param [String] version Box version
306
+ # @param [String] description Box description
307
+ # @return [Hash] box version information
308
+ def box_version_create(username:, name:, version:, description: Data::Nil)
309
+ request(method: :post, path: "/box/#{username}/#{name}/versions", params: {
310
+ version: {
311
+ version: version,
312
+ description: description
313
+ }
314
+ })
315
+ end
316
+
317
+ # Update an existing box version
318
+ #
319
+ # @param [String] username Username/organization name to create box under
320
+ # @param [String] name Box name
321
+ # @param [String] version Box version
322
+ # @param [String] description Box description
323
+ # @return [Hash] box version information
324
+ def box_version_update(username:, name:, version:, description: Data::Nil)
325
+ params = {
326
+ version: {
327
+ version: version,
328
+ description: description
329
+ }
330
+ }
331
+ request(method: :put, path: "/box/#{username}/#{name}/version/#{version}", params: params)
332
+ end
333
+
334
+ # Delete an existing box version
335
+ #
336
+ # @param [String] username Username/organization name to create box under
337
+ # @param [String] name Box name
338
+ # @param [String] version Box version
339
+ # @return [Hash] box version information
340
+ def box_version_delete(username:, name:, version:)
341
+ request(method: :delete, path: "/box/#{username}/#{name}/version/#{version}")
342
+ end
343
+
344
+ # Release an existing box version
345
+ #
346
+ # @param [String] username Username/organization name to create box under
347
+ # @param [String] name Box name
348
+ # @param [String] version Box version
349
+ # @return [Hash] box version information
350
+ def box_version_release(username:, name:, version:)
351
+ request(method: :put, path: "/box/#{username}/#{name}/version/#{version}/release")
352
+ end
353
+
354
+ # Revoke an existing box version
355
+ #
356
+ # @param [String] username Username/organization name to create box under
357
+ # @param [String] name Box name
358
+ # @param [String] version Box version
359
+ # @return [Hash] box version information
360
+ def box_version_revoke(username:, name:, version:)
361
+ request(method: :put, path: "/box/#{username}/#{name}/version/#{version}/revoke")
362
+ end
363
+
364
+ # Get an existing box version provider
365
+ #
366
+ # @param [String] username Username/organization name to create box under
367
+ # @param [String] name Box name
368
+ # @param [String] version Box version
369
+ # @param [String] provider Provider name
370
+ # @return [Hash] box version provider information
371
+ def box_version_provider_get(username:, name:, version:, provider:)
372
+ request(method: :get, path: "/box/#{username}/#{name}/version/#{version}/provider/#{provider}")
373
+ end
374
+
375
+ # Create a new box version provider
376
+ #
377
+ # @param [String] username Username/organization name to create box under
378
+ # @param [String] name Box name
379
+ # @param [String] version Box version
380
+ # @param [String] provider Provider name
381
+ # @param [String] url Remote URL for box download
382
+ # @return [Hash] box version provider information
383
+ def box_version_provider_create(username:, name:, version:, provider:, url: Data::Nil, checksum: Data::Nil, checksum_type: Data::Nil)
384
+ request(method: :post, path: "/box/#{username}/#{name}/version/#{version}/providers", params: {
385
+ provider: {
386
+ name: provider,
387
+ url: url,
388
+ checksum: checksum,
389
+ checksum_type: checksum_type
390
+ }
391
+ })
392
+ end
393
+
394
+ # Update an existing box version provider
395
+ #
396
+ # @param [String] username Username/organization name to create box under
397
+ # @param [String] name Box name
398
+ # @param [String] version Box version
399
+ # @param [String] provider Provider name
400
+ # @param [String] url Remote URL for box download
401
+ # @return [Hash] box version provider information
402
+ def box_version_provider_update(username:, name:, version:, provider:, url: Data::Nil, checksum: Data::Nil, checksum_type: Data::Nil)
403
+ params = {
404
+ provider: {
405
+ name: provider,
406
+ url: url,
407
+ checksum: checksum,
408
+ checksum_type: checksum_type
409
+ }
410
+ }
411
+ request(method: :put, path: "/box/#{username}/#{name}/version/#{version}/provider/#{provider}", params: params)
412
+ end
413
+
414
+ # Delete an existing box version provider
415
+ #
416
+ # @param [String] username Username/organization name to create box under
417
+ # @param [String] name Box name
418
+ # @param [String] version Box version
419
+ # @param [String] provider Provider name
420
+ # @return [Hash] box version provider information
421
+ def box_version_provider_delete(username:, name:, version:, provider:)
422
+ request(method: :delete, path: "/box/#{username}/#{name}/version/#{version}/provider/#{provider}")
423
+ end
424
+
425
+ # Upload a box asset for an existing box version provider
426
+ #
427
+ # @param [String] username Username/organization name to create box under
428
+ # @param [String] name Box name
429
+ # @param [String] version Box version
430
+ # @param [String] provider Provider name
431
+ # @return [Hash] box version provider upload information (contains upload_path entry)
432
+ def box_version_provider_upload(username:, name:, version:, provider:)
433
+ request(method: :get, path: "/box/#{username}/#{name}/version/#{version}/provider/#{provider}/upload")
434
+ end
435
+
436
+ # Upload a box asset directly to the backend storage for an existing box version provider
437
+ #
438
+ # @param [String] username Username/organization name to create box under
439
+ # @param [String] name Box name
440
+ # @param [String] version Box version
441
+ # @param [String] provider Provider name
442
+ # @return [Hash] box version provider upload information (contains upload_path and callback entries)
443
+ def box_version_provider_upload_direct(username:, name:, version:, provider:)
444
+ request(method: :get, path: "/box/#{username}/#{name}/version/#{version}/provider/#{provider}/upload/direct")
57
445
  end
58
446
 
59
447
  protected
60
448
 
61
- # Parse string of JSON
449
+ # Parse a string of JSON
62
450
  #
63
- # @param [String] string JSON encoded string
451
+ # @param [String] string String of JSON data
64
452
  # @return [Object]
65
- # @note This is included to provide expected behavior on
66
- # Ruby 2.3. Once it has reached EOL this can be removed.
453
+ # @note All keys are symbolized when parsed
67
454
  def parse_json(string)
68
- JSON.parse(string)
69
- rescue JSON::ParserError
70
- raise if string != 'null'
455
+ return {} if string.empty?
456
+ JSON.parse(string, symbolize_names: true)
457
+ end
458
+
459
+ # Remove any values that have a default value set
460
+ #
461
+ # @param [Object] item Item to clean
462
+ # @return [Object] cleaned item
463
+ def clean_parameters(item)
464
+ case item
465
+ when Array
466
+ item = item.find_all { |i| i != Data::Nil }
467
+ item.map! { |i| clean_parameters(i) }
468
+ when Hash
469
+ item = item.dup
470
+ item.delete_if{ |_,v| v == Data::Nil }
471
+ item.keys.each do |k|
472
+ item[k] = clean_parameters(item[k])
473
+ end
474
+ end
475
+ item
71
476
  end
72
477
  end
73
478
  end