vagrant_cloud 2.0.1 → 3.0.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.
@@ -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