vagrant_cloud 2.0.3 → 3.0.0

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