aspera-cli 4.12.0 → 4.13.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +17 -0
  4. data/CONTRIBUTING.md +97 -22
  5. data/README.md +548 -394
  6. data/bin/ascli +3 -3
  7. data/docs/test_env.conf +12 -5
  8. data/lib/aspera/aoc.rb +42 -42
  9. data/lib/aspera/ascmd.rb +4 -3
  10. data/lib/aspera/cli/extended_value.rb +24 -37
  11. data/lib/aspera/cli/formatter.rb +6 -0
  12. data/lib/aspera/cli/info.rb +2 -4
  13. data/lib/aspera/cli/main.rb +6 -0
  14. data/lib/aspera/cli/manager.rb +15 -6
  15. data/lib/aspera/cli/plugin.rb +1 -5
  16. data/lib/aspera/cli/plugins/aoc.rb +23 -6
  17. data/lib/aspera/cli/plugins/config.rb +13 -6
  18. data/lib/aspera/cli/plugins/faspex.rb +4 -3
  19. data/lib/aspera/cli/plugins/faspex5.rb +175 -42
  20. data/lib/aspera/cli/plugins/node.rb +107 -50
  21. data/lib/aspera/cli/plugins/preview.rb +3 -3
  22. data/lib/aspera/cli/plugins/server.rb +11 -1
  23. data/lib/aspera/cli/plugins/sync.rb +3 -3
  24. data/lib/aspera/cli/transfer_agent.rb +24 -10
  25. data/lib/aspera/cli/version.rb +2 -1
  26. data/lib/aspera/command_line_builder.rb +2 -1
  27. data/lib/aspera/cos_node.rb +1 -1
  28. data/lib/aspera/fasp/agent_connect.rb +1 -1
  29. data/lib/aspera/fasp/agent_direct.rb +12 -12
  30. data/lib/aspera/fasp/agent_node.rb +14 -4
  31. data/lib/aspera/fasp/installation.rb +1 -0
  32. data/lib/aspera/fasp/parameters.rb +11 -3
  33. data/lib/aspera/fasp/parameters.yaml +3 -1
  34. data/lib/aspera/fasp/transfer_spec.rb +3 -1
  35. data/lib/aspera/faspex_gw.rb +1 -0
  36. data/lib/aspera/faspex_postproc.rb +2 -2
  37. data/lib/aspera/node.rb +11 -4
  38. data/lib/aspera/oauth.rb +3 -2
  39. data/lib/aspera/preview/file_types.rb +8 -6
  40. data/lib/aspera/preview/generator.rb +23 -11
  41. data/lib/aspera/preview/options.rb +3 -2
  42. data/lib/aspera/preview/terminal.rb +34 -0
  43. data/lib/aspera/preview/utils.rb +8 -8
  44. data/lib/aspera/rest.rb +5 -4
  45. data/lib/aspera/rest_call_error.rb +3 -1
  46. data/lib/aspera/secret_hider.rb +4 -4
  47. data/lib/aspera/sync.rb +39 -33
  48. data/lib/aspera/web_server_simple.rb +22 -18
  49. data.tar.gz.sig +0 -0
  50. metadata +39 -46
  51. metadata.gz.sig +0 -0
  52. data/examples/aoc.rb +0 -30
  53. data/examples/faspex4.rb +0 -94
  54. data/examples/node.rb +0 -96
  55. data/examples/server.rb +0 -93
@@ -18,12 +18,20 @@ module Aspera
18
18
  RECIPIENT_TYPES = %w[user workgroup external_user distribution_list shared_inbox].freeze
19
19
  PACKAGE_TERMINATED = %w[completed failed].freeze
20
20
  API_DETECT = 'api/v5/configuration/ping'
21
+ # list of supported mailbox types
22
+ API_MAILBOXES = %w[inbox inbox_history inbox_all inbox_all_history outbox outbox_history pending pending_history all].freeze
23
+ PACKAGE_TYPE_RECEIVED = 'received'
24
+ PACKAGE_ALL_INIT = 'INIT'
25
+ private_constant(*%i[RECIPIENT_TYPES PACKAGE_TERMINATED API_DETECT API_MAILBOXES PACKAGE_TYPE_RECEIVED])
21
26
  class << self
22
27
  def detect(base_url)
23
28
  api = Rest.new(base_url: base_url, redirect_max: 1)
24
29
  result = api.read(API_DETECT)
25
30
  if result[:http].code.start_with?('2') && result[:http].body.strip.empty?
26
- return {version: '5', url: result[:http].uri.to_s[0..-(API_DETECT.length + 2)]}
31
+ suffix_length = -2 - API_DETECT.length
32
+ return {
33
+ version: result[:http]['x-ibm-aspera'] || '5',
34
+ url: result[:http].uri.to_s[0..suffix_length]}
27
35
  end
28
36
  return nil
29
37
  end
@@ -36,19 +44,39 @@ module Aspera
36
44
  options.add_opt_simple(:client_id, 'OAuth client identifier')
37
45
  options.add_opt_simple(:client_secret, 'OAuth client secret')
38
46
  options.add_opt_simple(:redirect_uri, 'OAuth redirect URI for web authentication')
39
- options.add_opt_list(:auth, [:boot].concat(Oauth::STD_AUTH_TYPES), 'OAuth type of authentication')
47
+ options.add_opt_list(:auth, %i[boot link].concat(Oauth::STD_AUTH_TYPES), 'OAuth type of authentication')
48
+ options.add_opt_simple(:box, "Package inbox, either shared inbox name or one of #{API_MAILBOXES}")
40
49
  options.add_opt_simple(:private_key, 'OAuth JWT RSA private key PEM value (prefix file path with @file:)')
41
50
  options.add_opt_simple(:passphrase, 'RSA private key passphrase')
42
51
  options.add_opt_simple(:shared_folder, 'Shared folder source for package files')
52
+ options.add_opt_simple(:link, 'public link for specific operation')
43
53
  options.set_option(:auth, :jwt)
54
+ options.set_option(:box, 'inbox')
44
55
  options.parse_options!
45
56
  end
46
57
 
47
58
  def set_api
48
- @faspex5_api_base_url = options.get_option(:url, is_type: :mandatory).gsub(%r{/+$}, '')
59
+ public_link = options.get_option(:link)
60
+ unless public_link.nil?
61
+ @faspex5_api_base_url = public_link.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
62
+ options.set_option(:auth, :link)
63
+ end
64
+ @faspex5_api_base_url ||= options.get_option(:url, is_type: :mandatory).gsub(%r{/+$}, '')
49
65
  @faspex5_api_auth_url = "#{@faspex5_api_base_url}/auth"
50
66
  faspex5_api_v5_url = "#{@faspex5_api_base_url}/api/v5"
51
67
  case options.get_option(:auth, is_type: :mandatory)
68
+ when :link
69
+ uri = URI.parse(public_link)
70
+ args = URI.decode_www_form(uri.query).each_with_object({}){|v, h|h[v.first] = v.last; }
71
+ Log.dump(:args, args)
72
+ context = args['context']
73
+ raise 'missing context' if context.nil?
74
+ @pub_link_context = JSON.parse(Base64.decode64(context))
75
+ Log.dump(:@pub_link_context, @pub_link_context)
76
+ @api_v5 = Rest.new({
77
+ base_url: faspex5_api_v5_url,
78
+ headers: {'Passcode' => @pub_link_context['passcode']}
79
+ })
52
80
  when :boot
53
81
  # the password here is the token copied directly from browser in developer mode
54
82
  @api_v5 = Rest.new({
@@ -78,17 +106,18 @@ module Aspera
78
106
  jwt: {
79
107
  payload: {
80
108
  iss: app_client_id, # issuer
81
- aud: app_client_id, # audience TODO: ???
82
- sub: "user:#{options.get_option(:username, is_type: :mandatory)}" # subject also "client:#{app_client_id}" + auth user/pass
109
+ aud: app_client_id, # audience (this field is not clear...)
110
+ sub: "user:#{options.get_option(:username, is_type: :mandatory)}" # subject is a user
83
111
  },
84
- # auth: {type: :basic, options.get_option(:username,is_type: :mandatory), options.get_option(:password,is_type: :mandatory),
85
112
  private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, is_type: :mandatory), options.get_option(:passphrase)),
86
113
  headers: {typ: 'JWT'}
87
114
  }
88
115
  }})
116
+ else raise 'Unexpected case for option: auth'
89
117
  end
90
118
  end
91
119
 
120
+ # if recipient is just an email, then convert to expected API hash : name and type
92
121
  def normalize_recipients(parameters)
93
122
  return unless parameters.key?('recipients')
94
123
  raise 'Field recipients must be an Array' unless parameters['recipients'].is_a?(Array)
@@ -96,7 +125,7 @@ module Aspera
96
125
  # if just a string, assume it is the name
97
126
  if recipient_data.is_a?(String)
98
127
  result = @api_v5.read('contacts', {q: recipient_data, context: 'packages', type: [Rest::ARRAY_PARAMS, *RECIPIENT_TYPES]})[:data]
99
- raise "No matching contact for #{recipient_data}" if result.empty?
128
+ raise "No matching contact for #{recipient_data}" if 0.eql?(result['total_count'])
100
129
  raise "Multiple matching contact for #{recipient_data} : #{result['contacts'].map{|i|i['name']}.join(', ')}" unless 1.eql?(result['total_count'])
101
130
  matched = result['contacts'].first
102
131
  recipient_data = {
@@ -109,7 +138,8 @@ module Aspera
109
138
  end
110
139
  end
111
140
 
112
- def wait_for_complete_upload(id)
141
+ # wait for package status to be in provided list
142
+ def wait_package_status(id, status_list=PACKAGE_TERMINATED)
113
143
  parameters = options.get_option(:value)
114
144
  spinner = nil
115
145
  progress = nil
@@ -133,22 +163,70 @@ module Aspera
133
163
  else
134
164
  progress.progress = status['bytes_written'].to_i
135
165
  end
136
- break if PACKAGE_TERMINATED.include?(status['upload_status'])
166
+ break if status_list.include?(status['upload_status'])
137
167
  sleep(0.5)
138
168
  end
139
169
  status['id'] = id
140
170
  return status
141
171
  end
142
172
 
143
- def lookup_entity(entity_type, property, value)
144
- # TODO: what if too many, use paging ?
145
- all = @api_v5.read(entity_type)[:data][entity_type]
146
- found = all.find{|i|i[property].eql?(value)}
147
- raise "No #{entity_type} with #{property} = #{value}" if found.nil?
148
- return found
173
+ # get a list of all entities of a given type
174
+ # @param entity_type [String] the type of entity to list
175
+ # @param query [Hash] additional query parameters
176
+ # @param prefix [String] optional prefix to add to the path (nil or empty string: no prefix)
177
+ def list_entities(entity_type, query: {}, prefix: nil)
178
+ path = entity_type
179
+ path = "#{prefix}/#{path}" unless prefix.nil? || prefix.empty?
180
+ result = []
181
+ offset = 0
182
+ max_items = query.delete(MAX_ITEMS)
183
+ remain_pages = query.delete(MAX_PAGES)
184
+ # merge default parameters, by default 100 per page
185
+ query = {'limit'=> 100}.merge(query)
186
+ loop do
187
+ query['offset'] = offset
188
+ page = @api_v5.read(path, query)[:data]
189
+ result.concat(page[entity_type])
190
+ # reach the limit set by user ?
191
+ if !max_items.nil? && (result.length >= max_items)
192
+ result = result.slice(0, max_items)
193
+ break
194
+ end
195
+ break if result.length >= page['total_count']
196
+ remain_pages -= 1 unless remain_pages.nil?
197
+ break if remain_pages == 0
198
+ offset += page[entity_type].length
199
+ end
200
+ return result
201
+ end
202
+
203
+ # lookup an entity id from its name
204
+ def lookup_name_to_id(entity_type, name)
205
+ found = list_entities(entity_type, query: {'q'=> name}).select{|i|i['name'].eql?(name)}
206
+ case found.length
207
+ when 0 then raise "No #{entity_type} with name = #{name}"
208
+ when 1 then return found.first['id']
209
+ else raise "Multiple #{entity_type} with name = #{name}"
210
+ end
149
211
  end
150
212
 
151
- ACTIONS = %i[health version user bearer_token package shared_folders admin gateway postprocessing].freeze
213
+ # translate box name to API prefix (with ending slash)
214
+ def box_to_prefix(box)
215
+ return \
216
+ case box
217
+ when VAL_ALL then ''
218
+ when *API_MAILBOXES then box
219
+ else "shared_inboxes/#{lookup_name_to_id('shared_inboxes', box)}"
220
+ end
221
+ end
222
+
223
+ # list all packages with optional filter
224
+ def list_packages
225
+ parameters = options.get_option(:value) || {}
226
+ return list_entities('packages', query: parameters, prefix: box_to_prefix(options.get_option(:box)))
227
+ end
228
+
229
+ ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing].freeze
152
230
 
153
231
  def execute_action
154
232
  command = options.get_next_command(ACTIONS)
@@ -180,22 +258,47 @@ module Aspera
180
258
  end
181
259
  when :bearer_token
182
260
  return {type: :text, data: @api_v5.oauth_token}
183
- when :package
184
- command = options.get_next_command(%i[list show send receive status])
261
+ when :packages
262
+ command = options.get_next_command(%i[list show browse status delete send receive])
185
263
  case command
186
264
  when :list
187
- parameters = options.get_option(:value)
188
265
  return {
189
266
  type: :object_list,
190
- data: @api_v5.read('packages', parameters)[:data]['packages'],
267
+ data: list_packages,
191
268
  fields: %w[id title release_date total_bytes total_files created_time state]
192
269
  }
193
270
  when :show
194
- id = instance_identifier
271
+ id = @pub_link_context['package_id'] if @pub_link_context&.key?('package_id')
272
+ id ||= instance_identifier
195
273
  return {type: :single_object, data: @api_v5.read("packages/#{id}")[:data]}
274
+ when :browse
275
+ id = @pub_link_context['package_id'] if @pub_link_context&.key?('package_id')
276
+ id ||= instance_identifier
277
+ path = options.get_next_argument('path', expected: :single, mandatory: false) || '/'
278
+ # TODO: support multi-page listing ?
279
+ params = {
280
+ # recipient_user_id: 25,
281
+ # offset: 0,
282
+ # limit: 25
283
+ }
284
+ result = @api_v5.call({
285
+ operation: 'POST',
286
+ subpath: "packages/#{id}/files/received",
287
+ headers: {'Accept' => 'application/json'},
288
+ url_params: params,
289
+ json_params: {'path' => path, 'filters' => {'basenames'=>[]}}})[:data]
290
+ formatter.display_item_count(result['item_count'], result['total_count'])
291
+ return {type: :object_list, data: result['items']}
196
292
  when :status
197
- status = wait_for_complete_upload(instance_identifier)
293
+ status = wait_package_status(instance_identifier)
198
294
  return {type: :single_object, data: status}
295
+ when :delete
296
+ ids = instance_identifier
297
+ ids = [ids] unless ids.is_a?(Array)
298
+ raise 'Package identifier must be a single id or an Array' unless ids.is_a?(Array) && ids.all?(String)
299
+ # API returns 204, empty on success
300
+ @api_v5.call({operation: 'DELETE', subpath: 'packages', headers: {'Accept' => 'application/json'}, json_params: {ids: ids}})
301
+ return Main.result_status('Package(s) deleted')
199
302
  when :send
200
303
  parameters = options.get_option(:value, is_type: :mandatory)
201
304
  raise CliBadArgument, 'Value must be Hash, refer to API' unless parameters.is_a?(Hash)
@@ -211,11 +314,12 @@ module Aspera
211
314
  url_params: {transfer_type: TRANSFER_CONNECT},
212
315
  json_params: {paths: transfer.source_list}
213
316
  )[:data]
317
+ # well, we asked a TS for connect, but we actually want a generic one
214
318
  transfer_spec.delete('authentication')
215
319
  return Main.result_transfer(transfer.start(transfer_spec))
216
320
  else
217
321
  if !shared_folder.to_i.to_s.eql?(shared_folder)
218
- shared_folder = lookup_entity('shared_folders', 'name', shared_folder)['id']
322
+ shared_folder = lookup_name_to_id('shared_folders', shared_folder)
219
323
  end
220
324
  transfer_request = {shared_folder_id: shared_folder, paths: transfer.source_list}
221
325
  # start remote transfer and get first status
@@ -223,40 +327,50 @@ module Aspera
223
327
  result['id'] = package['id']
224
328
  unless result['status'].eql?('completed')
225
329
  formatter.display_status("Package #{package['id']}")
226
- result = wait_for_complete_upload(package['id'])
330
+ result = wait_package_status(package['id'])
227
331
  end
228
332
  return {type: :single_object, data: result}
229
333
  end
230
334
  when :receive
231
- pkg_type = 'received'
232
- pack_id = instance_identifier
233
- package_ids = [pack_id]
234
- skip_ids_data = []
335
+ # prepare persistency if needed
235
336
  skip_ids_persistency = nil
236
337
  if options.get_option(:once_only, is_type: :mandatory)
237
338
  # read ids from persistency
238
339
  skip_ids_persistency = PersistencyActionOnce.new(
239
340
  manager: @agents[:persistency],
240
- data: skip_ids_data,
341
+ data: [],
241
342
  id: IdGenerator.from_list([
242
343
  'faspex_recv',
243
344
  options.get_option(:url, is_type: :mandatory),
244
345
  options.get_option(:username, is_type: :mandatory),
245
- pkg_type]))
346
+ PACKAGE_TYPE_RECEIVED]))
246
347
  end
247
- if VAL_ALL.eql?(pack_id)
248
- # TODO: if packages have same name, they will overwrite
249
- parameters = options.get_option(:value)
250
- parameters ||= {'type' => 'received', 'subtype' => 'mypackages', 'limit' => 1000}
251
- raise CliBadArgument, 'value filter must be Hash (API GET)' unless parameters.is_a?(Hash)
252
- package_ids = @api_v5.read('packages', parameters)[:data]['packages'].map{|p|p['id']}
253
- package_ids.reject!{|i|skip_ids_data.include?(i)}
348
+ # one or several packages
349
+ package_ids = @pub_link_context['package_id'] if @pub_link_context&.key?('package_id')
350
+ package_ids ||= instance_identifier
351
+ case package_ids
352
+ when PACKAGE_ALL_INIT
353
+ raise 'Only with option once_only' unless skip_ids_persistency
354
+ skip_ids_persistency.data.clear.concat(list_packages.map{|p|p['id']})
355
+ skip_ids_persistency.save
356
+ return Main.result_status("Initialized skip for #{skip_ids_persistency.data.count} package(s)")
357
+ when VAL_ALL
358
+ # TODO: if packages have same name, they will overwrite ?
359
+ package_ids = list_packages.map{|p|p['id']}
360
+ Log.dump(:package_ids, package_ids)
361
+ Log.dump(:package_ids, skip_ids_persistency.data)
362
+ package_ids.reject!{|i|skip_ids_persistency.data.include?(i)} if skip_ids_persistency
363
+ Log.dump(:package_ids, package_ids)
254
364
  end
365
+ # a single id was provided
366
+ # TODO: check package_ids is a list of strings
367
+ package_ids = [package_ids] if package_ids.is_a?(String)
255
368
  result_transfer = []
256
369
  package_ids.each do |pkg_id|
370
+ formatter.display_status("Receiving package #{pkg_id}")
257
371
  param_file_list = {}
258
372
  begin
259
- param_file_list['paths'] = transfer.source_list
373
+ param_file_list['paths'] = transfer.source_list.map{|source|{'path'=>source}}
260
374
  rescue Aspera::Cli::CliBadArgument
261
375
  # paths is optional
262
376
  end
@@ -265,16 +379,19 @@ module Aspera
265
379
  operation: 'POST',
266
380
  subpath: "packages/#{pkg_id}/transfer_spec/download",
267
381
  headers: {'Accept' => 'application/json'},
268
- url_params: {transfer_type: TRANSFER_CONNECT, type: pkg_type},
382
+ url_params: {transfer_type: TRANSFER_CONNECT, type: PACKAGE_TYPE_RECEIVED},
269
383
  json_params: param_file_list
270
384
  )[:data]
385
+ # delete flag for Connect Client
271
386
  transfer_spec.delete('authentication')
272
387
  statuses = transfer.start(transfer_spec)
273
388
  result_transfer.push({'package' => pkg_id, Main::STATUS_FIELD => statuses})
274
389
  # skip only if all sessions completed
275
- skip_ids_data.push(pkg_id) if TransferAgent.session_status(statuses).eql?(:success)
390
+ if TransferAgent.session_status(statuses).eql?(:success) && skip_ids_persistency
391
+ skip_ids_persistency.data.push(pkg_id)
392
+ skip_ids_persistency.save
393
+ end
276
394
  end
277
- skip_ids_persistency&.save
278
395
  return Main.result_transfer_multiple(result_transfer)
279
396
  end # case package
280
397
  when :shared_folders
@@ -301,7 +418,7 @@ module Aspera
301
418
  end
302
419
  end
303
420
  when :admin
304
- case options.get_next_command(%i[resource])
421
+ case options.get_next_command(%i[resource smtp])
305
422
  when :resource
306
423
  res_type = options.get_next_command(%i[accounts contacts jobs workgroups shared_inboxes nodes oauth_clients registrations saml_configs metadata_profiles
307
424
  email_notifications])
@@ -325,6 +442,22 @@ module Aspera
325
442
  adm_api = Rest.new(@api_v5.params.merge({base_url: @faspex5_api_auth_url}))
326
443
  end
327
444
  return entity_action(adm_api, res_path, item_list_key: list_key, display_fields: display_fields, id_as_arg: id_as_arg)
445
+ when :smtp
446
+ smtp_path = 'configuration/smtp'
447
+ case options.get_next_command(%i[show create modify delete test])
448
+ when :show
449
+ return { type: :single_object, data: @api_v5.read(smtp_path)[:data] }
450
+ when :create
451
+ return { type: :single_object, data: @api_v5.create(smtp_path, options.get_option(:value, is_type: :mandatory))[:data] }
452
+ when :modify
453
+ return { type: :single_object, data: @api_v5.modify(smtp_path, options.get_option(:value, is_type: :mandatory))[:data] }
454
+ when :delete
455
+ return { type: :single_object, data: @api_v5.delete(smtp_path)[:data] }
456
+ when :test
457
+ test_data = options.get_next_argument('Email or test data, see API')
458
+ test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
459
+ return { type: :single_object, data: @api_v5.create(File.join(smtp_path, 'test'), test_data)[:data] }
460
+ end
328
461
  end
329
462
  when :gateway
330
463
  require 'aspera/faspex_gw'