aspera-cli 4.13.0 → 4.14.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 (64) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +28 -5
  4. data/CONTRIBUTING.md +17 -1
  5. data/README.md +782 -401
  6. data/examples/dascli +1 -1
  7. data/examples/rubyc +24 -0
  8. data/lib/aspera/aoc.rb +21 -32
  9. data/lib/aspera/ascmd.rb +1 -0
  10. data/lib/aspera/cli/basic_auth_plugin.rb +6 -6
  11. data/lib/aspera/cli/formatter.rb +17 -25
  12. data/lib/aspera/cli/main.rb +21 -27
  13. data/lib/aspera/cli/manager.rb +128 -114
  14. data/lib/aspera/cli/plugin.rb +87 -38
  15. data/lib/aspera/cli/plugins/alee.rb +2 -2
  16. data/lib/aspera/cli/plugins/aoc.rb +216 -102
  17. data/lib/aspera/cli/plugins/ats.rb +16 -18
  18. data/lib/aspera/cli/plugins/bss.rb +3 -3
  19. data/lib/aspera/cli/plugins/config.rb +177 -367
  20. data/lib/aspera/cli/plugins/console.rb +4 -6
  21. data/lib/aspera/cli/plugins/cos.rb +12 -13
  22. data/lib/aspera/cli/plugins/faspex.rb +17 -18
  23. data/lib/aspera/cli/plugins/faspex5.rb +332 -216
  24. data/lib/aspera/cli/plugins/node.rb +171 -142
  25. data/lib/aspera/cli/plugins/orchestrator.rb +15 -18
  26. data/lib/aspera/cli/plugins/preview.rb +38 -60
  27. data/lib/aspera/cli/plugins/server.rb +22 -15
  28. data/lib/aspera/cli/plugins/shares.rb +24 -33
  29. data/lib/aspera/cli/plugins/sync.rb +3 -3
  30. data/lib/aspera/cli/transfer_agent.rb +29 -26
  31. data/lib/aspera/cli/version.rb +1 -1
  32. data/lib/aspera/colors.rb +9 -7
  33. data/lib/aspera/data/6 +0 -0
  34. data/lib/aspera/environment.rb +7 -3
  35. data/lib/aspera/fasp/agent_connect.rb +5 -0
  36. data/lib/aspera/fasp/agent_direct.rb +5 -5
  37. data/lib/aspera/fasp/agent_httpgw.rb +138 -60
  38. data/lib/aspera/fasp/agent_trsdk.rb +2 -0
  39. data/lib/aspera/fasp/error_info.rb +2 -0
  40. data/lib/aspera/fasp/installation.rb +18 -19
  41. data/lib/aspera/fasp/parameters.rb +18 -17
  42. data/lib/aspera/fasp/parameters.yaml +2 -1
  43. data/lib/aspera/fasp/resume_policy.rb +3 -3
  44. data/lib/aspera/fasp/transfer_spec.rb +6 -5
  45. data/lib/aspera/fasp/uri.rb +23 -21
  46. data/lib/aspera/faspex_postproc.rb +1 -1
  47. data/lib/aspera/hash_ext.rb +12 -2
  48. data/lib/aspera/keychain/macos_security.rb +13 -13
  49. data/lib/aspera/log.rb +1 -0
  50. data/lib/aspera/node.rb +62 -80
  51. data/lib/aspera/oauth.rb +1 -1
  52. data/lib/aspera/persistency_action_once.rb +1 -1
  53. data/lib/aspera/preview/terminal.rb +61 -15
  54. data/lib/aspera/preview/utils.rb +3 -3
  55. data/lib/aspera/proxy_auto_config.js +2 -2
  56. data/lib/aspera/rest.rb +37 -0
  57. data/lib/aspera/secret_hider.rb +6 -1
  58. data/lib/aspera/ssh.rb +1 -1
  59. data/lib/aspera/sync.rb +2 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +3 -4
  62. metadata.gz.sig +0 -0
  63. data/docs/test_env.conf +0 -186
  64. data/lib/aspera/data/7 +0 -0
@@ -18,11 +18,13 @@ 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'
21
+ # list of supported mailbox types (to list packages)
22
+ API_LIST_MAILBOX_TYPES = %w[inbox inbox_history inbox_all inbox_all_history outbox outbox_history pending pending_history all].freeze
24
23
  PACKAGE_ALL_INIT = 'INIT'
25
- private_constant(*%i[RECIPIENT_TYPES PACKAGE_TERMINATED API_DETECT API_MAILBOXES PACKAGE_TYPE_RECEIVED])
24
+ PACKAGE_SEND_FROM_REMOTE_SOURCE = 'remote_source'
25
+ ADMIN_RESOURCES = %i[accounts contacts jobs workgroups shared_inboxes nodes oauth_clients registrations saml_configs metadata_profiles
26
+ email_notifications].freeze
27
+ private_constant(*%i[RECIPIENT_TYPES PACKAGE_TERMINATED API_DETECT API_LIST_MAILBOX_TYPES PACKAGE_SEND_FROM_REMOTE_SOURCE])
26
28
  class << self
27
29
  def detect(base_url)
28
30
  api = Rest.new(base_url: base_url, redirect_max: 1)
@@ -31,27 +33,29 @@ module Aspera
31
33
  suffix_length = -2 - API_DETECT.length
32
34
  return {
33
35
  version: result[:http]['x-ibm-aspera'] || '5',
34
- url: result[:http].uri.to_s[0..suffix_length]}
36
+ url: result[:http].uri.to_s[0..suffix_length],
37
+ name: 'Faspex 5'
38
+ }
35
39
  end
36
40
  return nil
37
41
  end
38
42
  end
39
43
 
44
+ # Faspex API v5: get transfer spec for connect
40
45
  TRANSFER_CONNECT = 'connect'
41
46
 
42
47
  def initialize(env)
43
48
  super(env)
44
- options.add_opt_simple(:client_id, 'OAuth client identifier')
45
- options.add_opt_simple(:client_secret, 'OAuth client secret')
46
- options.add_opt_simple(:redirect_uri, 'OAuth redirect URI for web 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}")
49
- options.add_opt_simple(:private_key, 'OAuth JWT RSA private key PEM value (prefix file path with @file:)')
50
- options.add_opt_simple(:passphrase, 'RSA private key passphrase')
51
- options.add_opt_simple(:shared_folder, 'Shared folder source for package files')
52
- options.add_opt_simple(:link, 'public link for specific operation')
53
- options.set_option(:auth, :jwt)
54
- options.set_option(:box, 'inbox')
49
+ options.declare(:client_id, 'OAuth client identifier')
50
+ options.declare(:client_secret, 'OAuth client secret')
51
+ options.declare(:redirect_uri, 'OAuth redirect URI for web authentication')
52
+ options.declare(:auth, 'OAuth type of authentication', values: %i[boot link].concat(Oauth::STD_AUTH_TYPES), default: :jwt)
53
+ options.declare(:private_key, 'OAuth JWT RSA private key PEM value (prefix file path with @file:)')
54
+ options.declare(:passphrase, 'OAuth JWT RSA private key passphrase')
55
+ options.declare(:link, 'Public link authorization (specific operations)')
56
+ options.declare(:box, "Package inbox, either shared inbox name or one of #{API_LIST_MAILBOX_TYPES} or #{VAL_ALL}", default: 'inbox')
57
+ options.declare(:shared_folder, 'Send package with files from shared folder')
58
+ options.declare(:group_type, 'Shared inbox or workgroup', values: %i[shared_inboxes workgroups], default: :shared_inboxes)
55
59
  options.parse_options!
56
60
  end
57
61
 
@@ -61,10 +65,10 @@ module Aspera
61
65
  @faspex5_api_base_url = public_link.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
62
66
  options.set_option(:auth, :link)
63
67
  end
64
- @faspex5_api_base_url ||= options.get_option(:url, is_type: :mandatory).gsub(%r{/+$}, '')
68
+ @faspex5_api_base_url ||= options.get_option(:url, mandatory: true).gsub(%r{/+$}, '')
65
69
  @faspex5_api_auth_url = "#{@faspex5_api_base_url}/auth"
66
70
  faspex5_api_v5_url = "#{@faspex5_api_base_url}/api/v5"
67
- case options.get_option(:auth, is_type: :mandatory)
71
+ case options.get_option(:auth, mandatory: true)
68
72
  when :link
69
73
  uri = URI.parse(public_link)
70
74
  args = URI.decode_www_form(uri.query).each_with_object({}){|v, h|h[v.first] = v.last; }
@@ -81,7 +85,7 @@ module Aspera
81
85
  # the password here is the token copied directly from browser in developer mode
82
86
  @api_v5 = Rest.new({
83
87
  base_url: faspex5_api_v5_url,
84
- headers: {'Authorization' => options.get_option(:password, is_type: :mandatory)}
88
+ headers: {'Authorization' => options.get_option(:password, mandatory: true)}
85
89
  })
86
90
  when :web
87
91
  # opens a browser and ask user to auth using web
@@ -91,11 +95,11 @@ module Aspera
91
95
  type: :oauth2,
92
96
  base_url: @faspex5_api_auth_url,
93
97
  grant_method: :web,
94
- client_id: options.get_option(:client_id, is_type: :mandatory),
95
- web: {redirect_uri: options.get_option(:redirect_uri, is_type: :mandatory)}
98
+ client_id: options.get_option(:client_id, mandatory: true),
99
+ web: {redirect_uri: options.get_option(:redirect_uri, mandatory: true)}
96
100
  }})
97
101
  when :jwt
98
- app_client_id = options.get_option(:client_id, is_type: :mandatory)
102
+ app_client_id = options.get_option(:client_id, mandatory: true)
99
103
  @api_v5 = Rest.new({
100
104
  base_url: faspex5_api_v5_url,
101
105
  auth: {
@@ -107,9 +111,9 @@ module Aspera
107
111
  payload: {
108
112
  iss: app_client_id, # issuer
109
113
  aud: app_client_id, # audience (this field is not clear...)
110
- sub: "user:#{options.get_option(:username, is_type: :mandatory)}" # subject is a user
114
+ sub: "user:#{options.get_option(:username, mandatory: true)}" # subject is a user
111
115
  },
112
- private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, is_type: :mandatory), options.get_option(:passphrase)),
116
+ private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, mandatory: true), options.get_option(:passphrase)),
113
117
  headers: {typ: 'JWT'}
114
118
  }
115
119
  }})
@@ -121,13 +125,16 @@ module Aspera
121
125
  def normalize_recipients(parameters)
122
126
  return unless parameters.key?('recipients')
123
127
  raise 'Field recipients must be an Array' unless parameters['recipients'].is_a?(Array)
124
- parameters['recipients'] = parameters['recipients'].map do |recipient_data|
128
+ recipient_types = RECIPIENT_TYPES
129
+ if parameters.key?('recipient_types')
130
+ recipient_types = parameters['recipient_types']
131
+ parameters.delete('recipient_types')
132
+ recipient_types = [recipient_types] unless recipient_types.is_a?(Array)
133
+ end
134
+ parameters['recipients'].map! do |recipient_data|
125
135
  # if just a string, assume it is the name
126
136
  if recipient_data.is_a?(String)
127
- result = @api_v5.read('contacts', {q: recipient_data, context: 'packages', type: [Rest::ARRAY_PARAMS, *RECIPIENT_TYPES]})[:data]
128
- raise "No matching contact for #{recipient_data}" if 0.eql?(result['total_count'])
129
- raise "Multiple matching contact for #{recipient_data} : #{result['contacts'].map{|i|i['name']}.join(', ')}" unless 1.eql?(result['total_count'])
130
- matched = result['contacts'].first
137
+ matched = @api_v5.lookup_by_name('contacts', recipient_data, {context: 'packages', type: Rest.array_params(recipient_types)})
131
138
  recipient_data = {
132
139
  name: matched['name'],
133
140
  recipient_type: matched['type']
@@ -139,14 +146,13 @@ module Aspera
139
146
  end
140
147
 
141
148
  # wait for package status to be in provided list
142
- def wait_package_status(id, status_list=PACKAGE_TERMINATED)
143
- parameters = options.get_option(:value)
149
+ def wait_package_status(id, status_list: PACKAGE_TERMINATED)
144
150
  spinner = nil
145
151
  progress = nil
146
152
  while true
147
153
  status = @api_v5.read("packages/#{id}/upload_details")[:data]
148
154
  # user asked to not follow
149
- break unless parameters
155
+ break if status_list.nil? || status_list.include?(status['upload_status'])
150
156
  if status['upload_status'].eql?('submitted')
151
157
  if spinner.nil?
152
158
  spinner = TTY::Spinner.new('[:spinner] :title', format: :classic)
@@ -163,20 +169,24 @@ module Aspera
163
169
  else
164
170
  progress.progress = status['bytes_written'].to_i
165
171
  end
166
- break if status_list.include?(status['upload_status'])
167
172
  sleep(0.5)
168
173
  end
169
174
  status['id'] = id
170
175
  return status
171
176
  end
172
177
 
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?
178
+ # get a (full or partial) list of all entities of a given type
179
+ # @param type [String] the type of entity to list (just a name)
180
+ # @param query [Hash,nil] additional query parameters
181
+ # @param path [String] optional prefix to add to the path (nil or empty string: no prefix)
182
+ # @param item_list_key [String] key in the result to get the list of items
183
+ def list_entities(type:, path: nil, query: nil, item_list_key: nil)
184
+ query = {} if query.nil?
185
+ type = type.to_s if type.is_a?(Symbol)
186
+ item_list_key = type if item_list_key.nil?
187
+ raise "internal error: Invalid type #{type.class}" unless type.is_a?(String)
188
+ full_path = type
189
+ full_path = "#{path}/#{full_path}" unless path.nil? || path.empty?
180
190
  result = []
181
191
  offset = 0
182
192
  max_items = query.delete(MAX_ITEMS)
@@ -185,45 +195,204 @@ module Aspera
185
195
  query = {'limit'=> 100}.merge(query)
186
196
  loop do
187
197
  query['offset'] = offset
188
- page = @api_v5.read(path, query)[:data]
189
- result.concat(page[entity_type])
198
+ page_result = @api_v5.read(full_path, query)[:data]
199
+ result.concat(page_result[item_list_key])
190
200
  # reach the limit set by user ?
191
201
  if !max_items.nil? && (result.length >= max_items)
192
202
  result = result.slice(0, max_items)
193
203
  break
194
204
  end
195
- break if result.length >= page['total_count']
205
+ break if result.length >= page_result['total_count']
196
206
  remain_pages -= 1 unless remain_pages.nil?
197
207
  break if remain_pages == 0
198
- offset += page[entity_type].length
208
+ offset += page_result[item_list_key].length
199
209
  end
200
210
  return result
201
211
  end
202
212
 
203
213
  # 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)}
214
+ def lookup_entity_by_field(type:, value:, field: 'name', query: :default, path: nil, item_list_key: nil)
215
+ query = {'q'=> value} if query.eql?(:default)
216
+ found = list_entities(type: type, path: path, query: query, item_list_key: item_list_key).select{|i|i[field].eql?(value)}
206
217
  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}"
218
+ when 0 then raise "No #{type} with #{field} = #{value}"
219
+ when 1 then return found.first
220
+ else raise "Found #{found.length} #{path} with #{field} = #{value}"
210
221
  end
211
222
  end
212
223
 
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)}"
224
+ # list all packages with optional filter
225
+ def list_packages_with_filter
226
+ filter = options.get_next_argument('filter', mandatory: false, type: Proc, default: ->(_x){true})
227
+ # translate box name to API prefix (with ending slash)
228
+ box = options.get_option(:box)
229
+ api_path =
230
+ case box
231
+ when VAL_ALL then '' # only admin can list all packages globally
232
+ when *API_LIST_MAILBOX_TYPES then box
233
+ else
234
+ group_type = options.get_option(:group_type)
235
+ "#{group_type}/#{lookup_entity_by_field(type: group_type, value: box)['id']}"
236
+ end
237
+ return list_entities(
238
+ type: 'packages',
239
+ query: query_read_delete(default: {}),
240
+ path: api_path).select(&filter)
241
+ end
242
+
243
+ def package_receive
244
+ # prepare persistency if needed
245
+ skip_ids_persistency = nil
246
+ if options.get_option(:once_only, mandatory: true)
247
+ # read ids from persistency
248
+ skip_ids_persistency = PersistencyActionOnce.new(
249
+ manager: @agents[:persistency],
250
+ data: [],
251
+ id: IdGenerator.from_list([
252
+ 'faspex_recv',
253
+ options.get_option(:url, mandatory: true),
254
+ options.get_option(:username, mandatory: true)]))
255
+ end
256
+ package_ids =
257
+ if @pub_link_context&.key?('package_id')
258
+ @pub_link_context['package_id']
259
+ else
260
+ # one or several packages
261
+ instance_identifier
262
+ end
263
+ case package_ids
264
+ when PACKAGE_ALL_INIT
265
+ raise 'Only with option once_only' unless skip_ids_persistency
266
+ skip_ids_persistency.data.clear.concat(list_packages_with_filter.map{|p|p['id']})
267
+ skip_ids_persistency.save
268
+ return Main.result_status("Initialized skip for #{skip_ids_persistency.data.count} package(s)")
269
+ when VAL_ALL
270
+ # TODO: if packages have same name, they will overwrite ?
271
+ package_ids = list_packages_with_filter.map{|p|p['id']}
272
+ Log.dump(:package_ids, package_ids)
273
+ Log.dump(:package_ids, skip_ids_persistency.data)
274
+ package_ids.reject!{|i|skip_ids_persistency.data.include?(i)} if skip_ids_persistency
275
+ Log.dump(:package_ids, package_ids)
276
+ end
277
+ # a single id was provided
278
+ # TODO: check package_ids is a list of strings
279
+ package_ids = [package_ids] if package_ids.is_a?(String)
280
+ result_transfer = []
281
+ package_ids.each do |pkg_id|
282
+ formatter.display_status("Receiving package #{pkg_id}")
283
+ param_file_list = {}
284
+ begin
285
+ param_file_list['paths'] = transfer.source_list.map{|source|{'path'=>source}}
286
+ rescue Aspera::Cli::CliBadArgument
287
+ # paths is optional
288
+ end
289
+ download_params = {
290
+ type: 'received',
291
+ transfer_type: TRANSFER_CONNECT
292
+ }
293
+ box = options.get_option(:box)
294
+ case box
295
+ when /outbox/ then download_params[:type] = 'sent'
296
+ when *API_LIST_MAILBOX_TYPES then nil # nothing to do
297
+ else # shared inbox / workgroup
298
+ download_params[:recipient_workgroup_id] = lookup_entity_by_field(type: options.get_option(:group_type), value: box)['id']
299
+ end
300
+ # TODO: allow from sent as well ?
301
+ transfer_spec = @api_v5.call(
302
+ operation: 'POST',
303
+ subpath: "packages/#{pkg_id}/transfer_spec/download",
304
+ headers: {'Accept' => 'application/json'},
305
+ url_params: download_params,
306
+ json_params: param_file_list
307
+ )[:data]
308
+ # delete flag for Connect Client
309
+ transfer_spec.delete('authentication')
310
+ statuses = transfer.start(transfer_spec)
311
+ result_transfer.push({'package' => pkg_id, Main::STATUS_FIELD => statuses})
312
+ # skip only if all sessions completed
313
+ if TransferAgent.session_status(statuses).eql?(:success) && skip_ids_persistency
314
+ skip_ids_persistency.data.push(pkg_id)
315
+ skip_ids_persistency.save
316
+ end
220
317
  end
318
+ return Main.result_transfer_multiple(result_transfer)
221
319
  end
222
320
 
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)))
321
+ def package_action
322
+ command = options.get_next_command(%i[list show browse status delete send receive])
323
+ case command
324
+ when :list
325
+ return {
326
+ type: :object_list,
327
+ data: list_packages_with_filter,
328
+ fields: %w[id title release_date total_bytes total_files created_time state]
329
+ }
330
+ when :show
331
+ id = @pub_link_context['package_id'] if @pub_link_context&.key?('package_id')
332
+ id ||= instance_identifier
333
+ return {type: :single_object, data: @api_v5.read("packages/#{id}")[:data]}
334
+ when :browse
335
+ id = @pub_link_context['package_id'] if @pub_link_context&.key?('package_id')
336
+ id ||= instance_identifier
337
+ path = options.get_next_argument('path', expected: :single, mandatory: false) || '/'
338
+ # TODO: support multi-page listing ?
339
+ params = {
340
+ # recipient_user_id: 25,
341
+ # offset: 0,
342
+ # limit: 25
343
+ }
344
+ result = @api_v5.call({
345
+ operation: 'POST',
346
+ subpath: "packages/#{id}/files/received",
347
+ headers: {'Accept' => 'application/json'},
348
+ url_params: params,
349
+ json_params: {'path' => path, 'filters' => {'basenames'=>[]}}})[:data]
350
+ formatter.display_item_count(result['item_count'], result['total_count'])
351
+ return {type: :object_list, data: result['items']}
352
+ when :status
353
+ status = wait_package_status(instance_identifier, status_list: nil)
354
+ return {type: :single_object, data: status}
355
+ when :delete
356
+ ids = instance_identifier
357
+ ids = [ids] unless ids.is_a?(Array)
358
+ raise 'Package identifier must be a single id or an Array' unless ids.is_a?(Array) && ids.all?(String)
359
+ # API returns 204, empty on success
360
+ @api_v5.call({operation: 'DELETE', subpath: 'packages', headers: {'Accept' => 'application/json'}, json_params: {ids: ids}})
361
+ return Main.result_status('Package(s) deleted')
362
+ when :send
363
+ parameters = value_create_modify(command: command, type: Hash)
364
+ normalize_recipients(parameters)
365
+ package = @api_v5.create('packages', parameters)[:data]
366
+ shared_folder = options.get_option(:shared_folder)
367
+ if shared_folder.nil?
368
+ # TODO: option to send from remote source or httpgw
369
+ transfer_spec = @api_v5.call(
370
+ operation: 'POST',
371
+ subpath: "packages/#{package['id']}/transfer_spec/upload",
372
+ headers: {'Accept' => 'application/json'},
373
+ url_params: {transfer_type: TRANSFER_CONNECT},
374
+ json_params: {paths: transfer.source_list}
375
+ )[:data]
376
+ # well, we asked a TS for connect, but we actually want a generic one
377
+ transfer_spec.delete('authentication')
378
+ return Main.result_transfer(transfer.start(transfer_spec))
379
+ else
380
+ if (m = shared_folder.match(REGEX_LOOKUP_ID_BY_FIELD))
381
+ shared_folder = lookup_entity_by_field(type: 'shared_folders', value: m[2])['id']
382
+ end
383
+ transfer_request = {shared_folder_id: shared_folder, paths: transfer.source_list}
384
+ # start remote transfer and get first status
385
+ result = @api_v5.create("packages/#{package['id']}/remote_transfer", transfer_request)[:data]
386
+ result['id'] = package['id']
387
+ unless result['status'].eql?('completed')
388
+ formatter.display_status("Package #{package['id']}")
389
+ result = wait_package_status(package['id'])
390
+ end
391
+ return {type: :single_object, data: result}
392
+ end
393
+ when :receive
394
+ return package_receive
395
+ end # case package
227
396
  end
228
397
 
229
398
  ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing].freeze
@@ -259,148 +428,19 @@ module Aspera
259
428
  when :bearer_token
260
429
  return {type: :text, data: @api_v5.oauth_token}
261
430
  when :packages
262
- command = options.get_next_command(%i[list show browse status delete send receive])
263
- case command
264
- when :list
265
- return {
266
- type: :object_list,
267
- data: list_packages,
268
- fields: %w[id title release_date total_bytes total_files created_time state]
269
- }
270
- when :show
271
- id = @pub_link_context['package_id'] if @pub_link_context&.key?('package_id')
272
- id ||= instance_identifier
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']}
292
- when :status
293
- status = wait_package_status(instance_identifier)
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')
302
- when :send
303
- parameters = options.get_option(:value, is_type: :mandatory)
304
- raise CliBadArgument, 'Value must be Hash, refer to API' unless parameters.is_a?(Hash)
305
- normalize_recipients(parameters)
306
- package = @api_v5.create('packages', parameters)[:data]
307
- shared_folder = options.get_option(:shared_folder)
308
- if shared_folder.nil?
309
- # TODO: option to send from remote source or httpgw
310
- transfer_spec = @api_v5.call(
311
- operation: 'POST',
312
- subpath: "packages/#{package['id']}/transfer_spec/upload",
313
- headers: {'Accept' => 'application/json'},
314
- url_params: {transfer_type: TRANSFER_CONNECT},
315
- json_params: {paths: transfer.source_list}
316
- )[:data]
317
- # well, we asked a TS for connect, but we actually want a generic one
318
- transfer_spec.delete('authentication')
319
- return Main.result_transfer(transfer.start(transfer_spec))
320
- else
321
- if !shared_folder.to_i.to_s.eql?(shared_folder)
322
- shared_folder = lookup_name_to_id('shared_folders', shared_folder)
323
- end
324
- transfer_request = {shared_folder_id: shared_folder, paths: transfer.source_list}
325
- # start remote transfer and get first status
326
- result = @api_v5.create("packages/#{package['id']}/remote_transfer", transfer_request)[:data]
327
- result['id'] = package['id']
328
- unless result['status'].eql?('completed')
329
- formatter.display_status("Package #{package['id']}")
330
- result = wait_package_status(package['id'])
331
- end
332
- return {type: :single_object, data: result}
333
- end
334
- when :receive
335
- # prepare persistency if needed
336
- skip_ids_persistency = nil
337
- if options.get_option(:once_only, is_type: :mandatory)
338
- # read ids from persistency
339
- skip_ids_persistency = PersistencyActionOnce.new(
340
- manager: @agents[:persistency],
341
- data: [],
342
- id: IdGenerator.from_list([
343
- 'faspex_recv',
344
- options.get_option(:url, is_type: :mandatory),
345
- options.get_option(:username, is_type: :mandatory),
346
- PACKAGE_TYPE_RECEIVED]))
347
- end
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)
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)
368
- result_transfer = []
369
- package_ids.each do |pkg_id|
370
- formatter.display_status("Receiving package #{pkg_id}")
371
- param_file_list = {}
372
- begin
373
- param_file_list['paths'] = transfer.source_list.map{|source|{'path'=>source}}
374
- rescue Aspera::Cli::CliBadArgument
375
- # paths is optional
376
- end
377
- # TODO: allow from sent as well ?
378
- transfer_spec = @api_v5.call(
379
- operation: 'POST',
380
- subpath: "packages/#{pkg_id}/transfer_spec/download",
381
- headers: {'Accept' => 'application/json'},
382
- url_params: {transfer_type: TRANSFER_CONNECT, type: PACKAGE_TYPE_RECEIVED},
383
- json_params: param_file_list
384
- )[:data]
385
- # delete flag for Connect Client
386
- transfer_spec.delete('authentication')
387
- statuses = transfer.start(transfer_spec)
388
- result_transfer.push({'package' => pkg_id, Main::STATUS_FIELD => statuses})
389
- # skip only if all sessions completed
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
394
- end
395
- return Main.result_transfer_multiple(result_transfer)
396
- end # case package
431
+ return package_action
397
432
  when :shared_folders
398
433
  all_shared_folders = @api_v5.read('shared_folders')[:data]['shared_folders']
399
434
  case options.get_next_command(%i[list browse])
400
435
  when :list
401
436
  return {type: :object_list, data: all_shared_folders}
402
437
  when :browse
403
- shared_folder_id = instance_identifier
438
+ shared_folder_id = instance_identifier do |field, value|
439
+ matches = all_shared_folders.select{|i|i[field].eql?(value)}
440
+ raise "no match for #{field} = #{value}" if matches.empty?
441
+ raise "multiple matches for #{field} = #{value}" if matches.length > 1
442
+ matches.first['id']
443
+ end
404
444
  path = options.get_next_argument('folder path', mandatory: false) || '/'
405
445
  node = all_shared_folders.find{|i|i['id'].eql?(shared_folder_id)}
406
446
  raise "No such shared folder id #{shared_folder_id}" if node.nil?
@@ -418,12 +458,14 @@ module Aspera
418
458
  end
419
459
  end
420
460
  when :admin
421
- case options.get_next_command(%i[resource smtp])
461
+ case options.get_next_command(%i[resource smtp].freeze)
422
462
  when :resource
423
- res_type = options.get_next_command(%i[accounts contacts jobs workgroups shared_inboxes nodes oauth_clients registrations saml_configs metadata_profiles
424
- email_notifications])
463
+ res_type = options.get_next_command(ADMIN_RESOURCES)
425
464
  res_path = list_key = res_type.to_s
426
465
  id_as_arg = false
466
+ display_fields = nil
467
+ adm_api = @api_v5
468
+ available_commands = [].concat(Plugin::ALL_OPS)
427
469
  case res_type
428
470
  when :metadata_profiles
429
471
  res_path = 'configuration/metadata_profiles'
@@ -431,26 +473,69 @@ module Aspera
431
473
  when :email_notifications
432
474
  list_key = false
433
475
  id_as_arg = 'type'
476
+ when :accounts
477
+ display_fields = [:all_but, 'user_profile_data_attributes']
478
+ when :oauth_clients
479
+ display_fields = [:all_but, 'public_key']
480
+ adm_api = Rest.new(@api_v5.params.merge({base_url: @faspex5_api_auth_url}))
481
+ when :shared_inboxes, :workgroups
482
+ available_commands.push(:members, :saml_groups, :invite_external_collaborator)
434
483
  end
435
- display_fields =
436
- case res_type
437
- when :accounts then [:all_but, 'user_profile_data_attributes']
438
- when :oauth_clients then [:all_but, 'public_key']
484
+ res_command = options.get_next_command(available_commands)
485
+ case res_command
486
+ when *Plugin::ALL_OPS
487
+ return entity_command(res_command, adm_api, res_path, item_list_key: list_key, display_fields: display_fields, id_as_arg: id_as_arg)
488
+ when :invite_external_collaborator
489
+ shared_inbox_id = instance_identifier { |field, value| lookup_entity_by_field(type: res_type.to_s, field: field, value: value)['id']}
490
+ creation_payload = value_create_modify(command: res_command, type: [Hash, String])
491
+ creation_payload = {'email_address' => creation_payload} if creation_payload.is_a?(String)
492
+ res_path = "#{res_type}/#{shared_inbox_id}/external_collaborator"
493
+ result = adm_api.create(res_path, creation_payload)[:data]
494
+ formatter.display_status(result['message'])
495
+ result = lookup_entity_by_field(type: 'members', path: "#{res_type}/#{shared_inbox_id}", value: creation_payload['email_address'], query: {})
496
+ return {type: :single_object, data: result}
497
+ when :members, :saml_groups
498
+ res_id = instance_identifier { |field, value| lookup_entity_by_field(type: res_type.to_s, field: field, value: value)['id']}
499
+ res_prefix = "#{res_type}/#{res_id}"
500
+ res_path = "#{res_prefix}/#{res_command}"
501
+ list_key = res_command.to_s
502
+ list_key = 'groups' if res_command.eql?(:saml_groups)
503
+ sub_command = options.get_next_command(%i[create list modify delete])
504
+ if sub_command.eql?(:create) && options.get_option(:value).nil?
505
+ raise "use option 'value' to provide saml group_id and access (refer to API)" unless res_command.eql?(:members)
506
+ # first arg is one user name or list of users
507
+ users = options.get_next_argument('user id, or email, or list of')
508
+ users = [users] unless users.is_a?(Array)
509
+ users = users.map do |user|
510
+ if (m = user.match(REGEX_LOOKUP_ID_BY_FIELD))
511
+ lookup_entity_by_field(
512
+ type: 'accounts', field: m[1], value: m[2],
513
+ query: {type: Rest.array_params(%w{local_user saml_user self_registered_user external_user})})['id']
514
+ else
515
+ # it's the user id (not member id...)
516
+ user
517
+ end
518
+ end
519
+ access = options.get_next_argument('level', mandatory: false, expected: %i[submit_only standard shared_inbox_admin], default: :standard)
520
+ # TODO: unshift to command line parameters instead of using deprecated option "value"
521
+ options.set_option(:value, {user: users.map{|u|{id: u, access: access}}})
439
522
  end
440
- adm_api = @api_v5
441
- if res_type.eql?(:oauth_clients)
442
- adm_api = Rest.new(@api_v5.params.merge({base_url: @faspex5_api_auth_url}))
523
+ return entity_command(sub_command, adm_api, res_path, item_list_key: list_key) do |field, value|
524
+ lookup_entity_by_field(
525
+ type: 'accounts', field: field, value: value,
526
+ query: {type: Rest.array_params(%w{local_user saml_user self_registered_user external_user})})['id']
527
+ end
443
528
  end
444
- return entity_action(adm_api, res_path, item_list_key: list_key, display_fields: display_fields, id_as_arg: id_as_arg)
445
529
  when :smtp
446
530
  smtp_path = 'configuration/smtp'
447
- case options.get_next_command(%i[show create modify delete test])
531
+ smtp_cmd = options.get_next_command(%i[show create modify delete test])
532
+ case smtp_cmd
448
533
  when :show
449
534
  return { type: :single_object, data: @api_v5.read(smtp_path)[:data] }
450
535
  when :create
451
- return { type: :single_object, data: @api_v5.create(smtp_path, options.get_option(:value, is_type: :mandatory))[:data] }
536
+ return { type: :single_object, data: @api_v5.create(smtp_path, value_create_modify(command: smtp_cmd, type: Hash))[:data] }
452
537
  when :modify
453
- return { type: :single_object, data: @api_v5.modify(smtp_path, options.get_option(:value, is_type: :mandatory))[:data] }
538
+ return { type: :single_object, data: @api_v5.modify(smtp_path, value_create_modify(command: smtp_cmd, type: Hash))[:data] }
454
539
  when :delete
455
540
  return { type: :single_object, data: @api_v5.delete(smtp_path)[:data] }
456
541
  when :test
@@ -461,20 +546,20 @@ module Aspera
461
546
  end
462
547
  when :gateway
463
548
  require 'aspera/faspex_gw'
464
- url = options.get_option(:value, is_type: :mandatory)
549
+ url = value_create_modify(type: String)
465
550
  uri = URI.parse(url)
466
551
  server = WebServerSimple.new(uri)
467
552
  server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
553
+ # on ctrl-c, tell server main loop to exit
468
554
  trap('INT') { server.shutdown }
469
- formatter.display_status("Faspex 4 gateway listening on #{url}")
555
+ formatter.display_status("Gateway for Faspex 4-style API listening on #{url}")
470
556
  Log.log.info("Listening on #{url}")
471
557
  # this is blocking until server exits
472
558
  server.start
473
559
  return Main.result_status('Gateway terminated')
474
560
  when :postprocessing
475
- require 'aspera/faspex_postproc'
476
- parameters = options.get_option(:value, is_type: :mandatory)
477
- raise 'parameters must be Hash' unless parameters.is_a?(Hash)
561
+ require 'aspera/faspex_postproc' # cspell:disable-line
562
+ parameters = value_create_modify(type: Hash)
478
563
  parameters = parameters.symbolize_keys
479
564
  raise 'Missing key: url' unless parameters.key?(:url)
480
565
  uri = URI.parse(parameters[:url])
@@ -482,14 +567,45 @@ module Aspera
482
567
  parameters[:processing][:root] = uri.path
483
568
  server = WebServerSimple.new(uri, certificate: parameters[:certificate])
484
569
  server.mount(uri.path, Faspex4PostProcServlet, parameters[:processing])
570
+ # on ctrl-c, tell server main loop to exit
485
571
  trap('INT') { server.shutdown }
486
- formatter.display_status("Faspex 4 post processing listening on #{uri.port}")
572
+ formatter.display_status("Web-hook for Faspex 4-style post processing listening on #{uri.port}")
487
573
  Log.log.info("Listening on #{uri.port}")
488
574
  # this is blocking until server exits
489
575
  server.start
490
576
  return Main.result_status('Gateway terminated')
491
577
  end # case command
492
578
  end # action
579
+
580
+ def wizard(params)
581
+ if params[:prepare]
582
+ # if not defined by user, generate unique name
583
+ params[:preset_name] ||= [params[:plugin_sym]].concat(URI.parse(params[:instance_url]).host.gsub(/[^a-z0-9.]/, '').split('.')).join('_')
584
+ params[:need_private_key] = true
585
+ return
586
+ end
587
+ formatter.display_status('Ask the ascli client id and secret to your Administrator, or ask them to go to:'.red)
588
+ OpenApplication.instance.uri(params[:instance_url])
589
+ formatter.display_status('Then: 𓃑 → Admin → Configurations → API clients')
590
+ formatter.display_status('Create an API client with:')
591
+ formatter.display_status('- name: ascli')
592
+ formatter.display_status('- JWT: enabled')
593
+ formatter.display_status('Then, logged in as user go to your profile:')
594
+ formatter.display_status('👤 → Account Settings → Preferences -> Public Key in PEM:')
595
+ formatter.display_status(params[:pub_key_pem])
596
+ formatter.display_status('Once set, fill in the parameters:')
597
+ return {
598
+ preset_value: {
599
+ url: params[:instance_url],
600
+ username: options.get_option(:username, mandatory: true),
601
+ auth: :jwt.to_s,
602
+ private_key: '@file:' + params[:private_key_path],
603
+ client_id: options.get_option(:client_id, mandatory: true),
604
+ client_secret: options.get_option(:client_secret, mandatory: true)
605
+ },
606
+ test_args: "#{params[:plugin_sym]} user profile show"
607
+ }
608
+ end
493
609
  end # Faspex5
494
610
  end # Plugins
495
611
  end # Cli