aspera-cli 4.12.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 (80) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +45 -5
  4. data/CONTRIBUTING.md +113 -22
  5. data/README.md +1289 -754
  6. data/bin/ascli +3 -3
  7. data/examples/dascli +1 -1
  8. data/examples/rubyc +24 -0
  9. data/lib/aspera/aoc.rb +63 -74
  10. data/lib/aspera/ascmd.rb +5 -3
  11. data/lib/aspera/cli/basic_auth_plugin.rb +6 -6
  12. data/lib/aspera/cli/extended_value.rb +24 -37
  13. data/lib/aspera/cli/formatter.rb +23 -25
  14. data/lib/aspera/cli/info.rb +2 -4
  15. data/lib/aspera/cli/main.rb +27 -27
  16. data/lib/aspera/cli/manager.rb +143 -120
  17. data/lib/aspera/cli/plugin.rb +88 -43
  18. data/lib/aspera/cli/plugins/alee.rb +2 -2
  19. data/lib/aspera/cli/plugins/aoc.rb +235 -104
  20. data/lib/aspera/cli/plugins/ats.rb +16 -18
  21. data/lib/aspera/cli/plugins/bss.rb +3 -3
  22. data/lib/aspera/cli/plugins/config.rb +190 -373
  23. data/lib/aspera/cli/plugins/console.rb +4 -6
  24. data/lib/aspera/cli/plugins/cos.rb +12 -13
  25. data/lib/aspera/cli/plugins/faspex.rb +21 -21
  26. data/lib/aspera/cli/plugins/faspex5.rb +399 -150
  27. data/lib/aspera/cli/plugins/node.rb +260 -174
  28. data/lib/aspera/cli/plugins/orchestrator.rb +15 -18
  29. data/lib/aspera/cli/plugins/preview.rb +40 -62
  30. data/lib/aspera/cli/plugins/server.rb +33 -16
  31. data/lib/aspera/cli/plugins/shares.rb +24 -33
  32. data/lib/aspera/cli/plugins/sync.rb +6 -6
  33. data/lib/aspera/cli/transfer_agent.rb +47 -30
  34. data/lib/aspera/cli/version.rb +2 -1
  35. data/lib/aspera/colors.rb +9 -7
  36. data/lib/aspera/command_line_builder.rb +2 -1
  37. data/lib/aspera/cos_node.rb +1 -1
  38. data/lib/aspera/data/6 +0 -0
  39. data/lib/aspera/environment.rb +7 -3
  40. data/lib/aspera/fasp/agent_connect.rb +6 -1
  41. data/lib/aspera/fasp/agent_direct.rb +17 -17
  42. data/lib/aspera/fasp/agent_httpgw.rb +138 -60
  43. data/lib/aspera/fasp/agent_node.rb +14 -4
  44. data/lib/aspera/fasp/agent_trsdk.rb +2 -0
  45. data/lib/aspera/fasp/error_info.rb +2 -0
  46. data/lib/aspera/fasp/installation.rb +19 -19
  47. data/lib/aspera/fasp/parameters.rb +29 -20
  48. data/lib/aspera/fasp/parameters.yaml +5 -2
  49. data/lib/aspera/fasp/resume_policy.rb +3 -3
  50. data/lib/aspera/fasp/transfer_spec.rb +8 -5
  51. data/lib/aspera/fasp/uri.rb +23 -21
  52. data/lib/aspera/faspex_gw.rb +1 -0
  53. data/lib/aspera/faspex_postproc.rb +3 -3
  54. data/lib/aspera/hash_ext.rb +12 -2
  55. data/lib/aspera/keychain/macos_security.rb +13 -13
  56. data/lib/aspera/log.rb +1 -0
  57. data/lib/aspera/node.rb +73 -84
  58. data/lib/aspera/oauth.rb +4 -3
  59. data/lib/aspera/persistency_action_once.rb +1 -1
  60. data/lib/aspera/preview/file_types.rb +8 -6
  61. data/lib/aspera/preview/generator.rb +23 -11
  62. data/lib/aspera/preview/options.rb +3 -2
  63. data/lib/aspera/preview/terminal.rb +80 -0
  64. data/lib/aspera/preview/utils.rb +11 -11
  65. data/lib/aspera/proxy_auto_config.js +2 -2
  66. data/lib/aspera/rest.rb +42 -4
  67. data/lib/aspera/rest_call_error.rb +3 -1
  68. data/lib/aspera/secret_hider.rb +10 -5
  69. data/lib/aspera/ssh.rb +1 -1
  70. data/lib/aspera/sync.rb +41 -33
  71. data/lib/aspera/web_server_simple.rb +22 -18
  72. data.tar.gz.sig +0 -0
  73. metadata +40 -48
  74. metadata.gz.sig +0 -0
  75. data/docs/test_env.conf +0 -179
  76. data/examples/aoc.rb +0 -30
  77. data/examples/faspex4.rb +0 -94
  78. data/examples/node.rb +0 -96
  79. data/examples/server.rb +0 -93
  80. data/lib/aspera/data/7 +0 -0
@@ -18,42 +18,74 @@ 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 (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
23
+ PACKAGE_ALL_INIT = 'INIT'
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])
21
28
  class << self
22
29
  def detect(base_url)
23
30
  api = Rest.new(base_url: base_url, redirect_max: 1)
24
31
  result = api.read(API_DETECT)
25
32
  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)]}
33
+ suffix_length = -2 - API_DETECT.length
34
+ return {
35
+ version: result[:http]['x-ibm-aspera'] || '5',
36
+ url: result[:http].uri.to_s[0..suffix_length],
37
+ name: 'Faspex 5'
38
+ }
27
39
  end
28
40
  return nil
29
41
  end
30
42
  end
31
43
 
44
+ # Faspex API v5: get transfer spec for connect
32
45
  TRANSFER_CONNECT = 'connect'
33
46
 
34
47
  def initialize(env)
35
48
  super(env)
36
- options.add_opt_simple(:client_id, 'OAuth client identifier')
37
- options.add_opt_simple(:client_secret, 'OAuth client secret')
38
- 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')
40
- options.add_opt_simple(:private_key, 'OAuth JWT RSA private key PEM value (prefix file path with @file:)')
41
- options.add_opt_simple(:passphrase, 'RSA private key passphrase')
42
- options.add_opt_simple(:shared_folder, 'Shared folder source for package files')
43
- options.set_option(:auth, :jwt)
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)
44
59
  options.parse_options!
45
60
  end
46
61
 
47
62
  def set_api
48
- @faspex5_api_base_url = options.get_option(:url, is_type: :mandatory).gsub(%r{/+$}, '')
63
+ public_link = options.get_option(:link)
64
+ unless public_link.nil?
65
+ @faspex5_api_base_url = public_link.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
66
+ options.set_option(:auth, :link)
67
+ end
68
+ @faspex5_api_base_url ||= options.get_option(:url, mandatory: true).gsub(%r{/+$}, '')
49
69
  @faspex5_api_auth_url = "#{@faspex5_api_base_url}/auth"
50
70
  faspex5_api_v5_url = "#{@faspex5_api_base_url}/api/v5"
51
- case options.get_option(:auth, is_type: :mandatory)
71
+ case options.get_option(:auth, mandatory: true)
72
+ when :link
73
+ uri = URI.parse(public_link)
74
+ args = URI.decode_www_form(uri.query).each_with_object({}){|v, h|h[v.first] = v.last; }
75
+ Log.dump(:args, args)
76
+ context = args['context']
77
+ raise 'missing context' if context.nil?
78
+ @pub_link_context = JSON.parse(Base64.decode64(context))
79
+ Log.dump(:@pub_link_context, @pub_link_context)
80
+ @api_v5 = Rest.new({
81
+ base_url: faspex5_api_v5_url,
82
+ headers: {'Passcode' => @pub_link_context['passcode']}
83
+ })
52
84
  when :boot
53
85
  # the password here is the token copied directly from browser in developer mode
54
86
  @api_v5 = Rest.new({
55
87
  base_url: faspex5_api_v5_url,
56
- headers: {'Authorization' => options.get_option(:password, is_type: :mandatory)}
88
+ headers: {'Authorization' => options.get_option(:password, mandatory: true)}
57
89
  })
58
90
  when :web
59
91
  # opens a browser and ask user to auth using web
@@ -63,11 +95,11 @@ module Aspera
63
95
  type: :oauth2,
64
96
  base_url: @faspex5_api_auth_url,
65
97
  grant_method: :web,
66
- client_id: options.get_option(:client_id, is_type: :mandatory),
67
- 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)}
68
100
  }})
69
101
  when :jwt
70
- app_client_id = options.get_option(:client_id, is_type: :mandatory)
102
+ app_client_id = options.get_option(:client_id, mandatory: true)
71
103
  @api_v5 = Rest.new({
72
104
  base_url: faspex5_api_v5_url,
73
105
  auth: {
@@ -78,27 +110,31 @@ module Aspera
78
110
  jwt: {
79
111
  payload: {
80
112
  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
113
+ aud: app_client_id, # audience (this field is not clear...)
114
+ sub: "user:#{options.get_option(:username, mandatory: true)}" # subject is a user
83
115
  },
84
- # auth: {type: :basic, options.get_option(:username,is_type: :mandatory), options.get_option(:password,is_type: :mandatory),
85
- 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)),
86
117
  headers: {typ: 'JWT'}
87
118
  }
88
119
  }})
120
+ else raise 'Unexpected case for option: auth'
89
121
  end
90
122
  end
91
123
 
124
+ # if recipient is just an email, then convert to expected API hash : name and type
92
125
  def normalize_recipients(parameters)
93
126
  return unless parameters.key?('recipients')
94
127
  raise 'Field recipients must be an Array' unless parameters['recipients'].is_a?(Array)
95
- 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|
96
135
  # if just a string, assume it is the name
97
136
  if recipient_data.is_a?(String)
98
- 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?
100
- raise "Multiple matching contact for #{recipient_data} : #{result['contacts'].map{|i|i['name']}.join(', ')}" unless 1.eql?(result['total_count'])
101
- matched = result['contacts'].first
137
+ matched = @api_v5.lookup_by_name('contacts', recipient_data, {context: 'packages', type: Rest.array_params(recipient_types)})
102
138
  recipient_data = {
103
139
  name: matched['name'],
104
140
  recipient_type: matched['type']
@@ -109,14 +145,14 @@ module Aspera
109
145
  end
110
146
  end
111
147
 
112
- def wait_for_complete_upload(id)
113
- parameters = options.get_option(:value)
148
+ # wait for package status to be in provided list
149
+ def wait_package_status(id, status_list: PACKAGE_TERMINATED)
114
150
  spinner = nil
115
151
  progress = nil
116
152
  while true
117
153
  status = @api_v5.read("packages/#{id}/upload_details")[:data]
118
154
  # user asked to not follow
119
- break unless parameters
155
+ break if status_list.nil? || status_list.include?(status['upload_status'])
120
156
  if status['upload_status'].eql?('submitted')
121
157
  if spinner.nil?
122
158
  spinner = TTY::Spinner.new('[:spinner] :title', format: :classic)
@@ -133,22 +169,233 @@ module Aspera
133
169
  else
134
170
  progress.progress = status['bytes_written'].to_i
135
171
  end
136
- break if PACKAGE_TERMINATED.include?(status['upload_status'])
137
172
  sleep(0.5)
138
173
  end
139
174
  status['id'] = id
140
175
  return status
141
176
  end
142
177
 
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
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?
190
+ result = []
191
+ offset = 0
192
+ max_items = query.delete(MAX_ITEMS)
193
+ remain_pages = query.delete(MAX_PAGES)
194
+ # merge default parameters, by default 100 per page
195
+ query = {'limit'=> 100}.merge(query)
196
+ loop do
197
+ query['offset'] = offset
198
+ page_result = @api_v5.read(full_path, query)[:data]
199
+ result.concat(page_result[item_list_key])
200
+ # reach the limit set by user ?
201
+ if !max_items.nil? && (result.length >= max_items)
202
+ result = result.slice(0, max_items)
203
+ break
204
+ end
205
+ break if result.length >= page_result['total_count']
206
+ remain_pages -= 1 unless remain_pages.nil?
207
+ break if remain_pages == 0
208
+ offset += page_result[item_list_key].length
209
+ end
210
+ return result
211
+ end
212
+
213
+ # lookup an entity id from its 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)}
217
+ case found.length
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}"
221
+ end
222
+ end
223
+
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)
149
241
  end
150
242
 
151
- ACTIONS = %i[health version user bearer_token package shared_folders admin gateway postprocessing].freeze
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
317
+ end
318
+ return Main.result_transfer_multiple(result_transfer)
319
+ end
320
+
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
396
+ end
397
+
398
+ ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing].freeze
152
399
 
153
400
  def execute_action
154
401
  command = options.get_next_command(ACTIONS)
@@ -180,110 +427,20 @@ module Aspera
180
427
  end
181
428
  when :bearer_token
182
429
  return {type: :text, data: @api_v5.oauth_token}
183
- when :package
184
- command = options.get_next_command(%i[list show send receive status])
185
- case command
186
- when :list
187
- parameters = options.get_option(:value)
188
- return {
189
- type: :object_list,
190
- data: @api_v5.read('packages', parameters)[:data]['packages'],
191
- fields: %w[id title release_date total_bytes total_files created_time state]
192
- }
193
- when :show
194
- id = instance_identifier
195
- return {type: :single_object, data: @api_v5.read("packages/#{id}")[:data]}
196
- when :status
197
- status = wait_for_complete_upload(instance_identifier)
198
- return {type: :single_object, data: status}
199
- when :send
200
- parameters = options.get_option(:value, is_type: :mandatory)
201
- raise CliBadArgument, 'Value must be Hash, refer to API' unless parameters.is_a?(Hash)
202
- normalize_recipients(parameters)
203
- package = @api_v5.create('packages', parameters)[:data]
204
- shared_folder = options.get_option(:shared_folder)
205
- if shared_folder.nil?
206
- # TODO: option to send from remote source or httpgw
207
- transfer_spec = @api_v5.call(
208
- operation: 'POST',
209
- subpath: "packages/#{package['id']}/transfer_spec/upload",
210
- headers: {'Accept' => 'application/json'},
211
- url_params: {transfer_type: TRANSFER_CONNECT},
212
- json_params: {paths: transfer.source_list}
213
- )[:data]
214
- transfer_spec.delete('authentication')
215
- return Main.result_transfer(transfer.start(transfer_spec))
216
- else
217
- if !shared_folder.to_i.to_s.eql?(shared_folder)
218
- shared_folder = lookup_entity('shared_folders', 'name', shared_folder)['id']
219
- end
220
- transfer_request = {shared_folder_id: shared_folder, paths: transfer.source_list}
221
- # start remote transfer and get first status
222
- result = @api_v5.create("packages/#{package['id']}/remote_transfer", transfer_request)[:data]
223
- result['id'] = package['id']
224
- unless result['status'].eql?('completed')
225
- formatter.display_status("Package #{package['id']}")
226
- result = wait_for_complete_upload(package['id'])
227
- end
228
- return {type: :single_object, data: result}
229
- end
230
- when :receive
231
- pkg_type = 'received'
232
- pack_id = instance_identifier
233
- package_ids = [pack_id]
234
- skip_ids_data = []
235
- skip_ids_persistency = nil
236
- if options.get_option(:once_only, is_type: :mandatory)
237
- # read ids from persistency
238
- skip_ids_persistency = PersistencyActionOnce.new(
239
- manager: @agents[:persistency],
240
- data: skip_ids_data,
241
- id: IdGenerator.from_list([
242
- 'faspex_recv',
243
- options.get_option(:url, is_type: :mandatory),
244
- options.get_option(:username, is_type: :mandatory),
245
- pkg_type]))
246
- 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)}
254
- end
255
- result_transfer = []
256
- package_ids.each do |pkg_id|
257
- param_file_list = {}
258
- begin
259
- param_file_list['paths'] = transfer.source_list
260
- rescue Aspera::Cli::CliBadArgument
261
- # paths is optional
262
- end
263
- # TODO: allow from sent as well ?
264
- transfer_spec = @api_v5.call(
265
- operation: 'POST',
266
- subpath: "packages/#{pkg_id}/transfer_spec/download",
267
- headers: {'Accept' => 'application/json'},
268
- url_params: {transfer_type: TRANSFER_CONNECT, type: pkg_type},
269
- json_params: param_file_list
270
- )[:data]
271
- transfer_spec.delete('authentication')
272
- statuses = transfer.start(transfer_spec)
273
- result_transfer.push({'package' => pkg_id, Main::STATUS_FIELD => statuses})
274
- # skip only if all sessions completed
275
- skip_ids_data.push(pkg_id) if TransferAgent.session_status(statuses).eql?(:success)
276
- end
277
- skip_ids_persistency&.save
278
- return Main.result_transfer_multiple(result_transfer)
279
- end # case package
430
+ when :packages
431
+ return package_action
280
432
  when :shared_folders
281
433
  all_shared_folders = @api_v5.read('shared_folders')[:data]['shared_folders']
282
434
  case options.get_next_command(%i[list browse])
283
435
  when :list
284
436
  return {type: :object_list, data: all_shared_folders}
285
437
  when :browse
286
- 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
287
444
  path = options.get_next_argument('folder path', mandatory: false) || '/'
288
445
  node = all_shared_folders.find{|i|i['id'].eql?(shared_folder_id)}
289
446
  raise "No such shared folder id #{shared_folder_id}" if node.nil?
@@ -301,12 +458,14 @@ module Aspera
301
458
  end
302
459
  end
303
460
  when :admin
304
- case options.get_next_command(%i[resource])
461
+ case options.get_next_command(%i[resource smtp].freeze)
305
462
  when :resource
306
- res_type = options.get_next_command(%i[accounts contacts jobs workgroups shared_inboxes nodes oauth_clients registrations saml_configs metadata_profiles
307
- email_notifications])
463
+ res_type = options.get_next_command(ADMIN_RESOURCES)
308
464
  res_path = list_key = res_type.to_s
309
465
  id_as_arg = false
466
+ display_fields = nil
467
+ adm_api = @api_v5
468
+ available_commands = [].concat(Plugin::ALL_OPS)
310
469
  case res_type
311
470
  when :metadata_profiles
312
471
  res_path = 'configuration/metadata_profiles'
@@ -314,34 +473,93 @@ module Aspera
314
473
  when :email_notifications
315
474
  list_key = false
316
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)
317
483
  end
318
- display_fields =
319
- case res_type
320
- when :accounts then [:all_but, 'user_profile_data_attributes']
321
- 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}}})
322
522
  end
323
- adm_api = @api_v5
324
- if res_type.eql?(:oauth_clients)
325
- 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
528
+ end
529
+ when :smtp
530
+ smtp_path = 'configuration/smtp'
531
+ smtp_cmd = options.get_next_command(%i[show create modify delete test])
532
+ case smtp_cmd
533
+ when :show
534
+ return { type: :single_object, data: @api_v5.read(smtp_path)[:data] }
535
+ when :create
536
+ return { type: :single_object, data: @api_v5.create(smtp_path, value_create_modify(command: smtp_cmd, type: Hash))[:data] }
537
+ when :modify
538
+ return { type: :single_object, data: @api_v5.modify(smtp_path, value_create_modify(command: smtp_cmd, type: Hash))[:data] }
539
+ when :delete
540
+ return { type: :single_object, data: @api_v5.delete(smtp_path)[:data] }
541
+ when :test
542
+ test_data = options.get_next_argument('Email or test data, see API')
543
+ test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
544
+ return { type: :single_object, data: @api_v5.create(File.join(smtp_path, 'test'), test_data)[:data] }
326
545
  end
327
- return entity_action(adm_api, res_path, item_list_key: list_key, display_fields: display_fields, id_as_arg: id_as_arg)
328
546
  end
329
547
  when :gateway
330
548
  require 'aspera/faspex_gw'
331
- url = options.get_option(:value, is_type: :mandatory)
549
+ url = value_create_modify(type: String)
332
550
  uri = URI.parse(url)
333
551
  server = WebServerSimple.new(uri)
334
552
  server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
553
+ # on ctrl-c, tell server main loop to exit
335
554
  trap('INT') { server.shutdown }
336
- formatter.display_status("Faspex 4 gateway listening on #{url}")
555
+ formatter.display_status("Gateway for Faspex 4-style API listening on #{url}")
337
556
  Log.log.info("Listening on #{url}")
338
557
  # this is blocking until server exits
339
558
  server.start
340
559
  return Main.result_status('Gateway terminated')
341
560
  when :postprocessing
342
- require 'aspera/faspex_postproc'
343
- parameters = options.get_option(:value, is_type: :mandatory)
344
- 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)
345
563
  parameters = parameters.symbolize_keys
346
564
  raise 'Missing key: url' unless parameters.key?(:url)
347
565
  uri = URI.parse(parameters[:url])
@@ -349,14 +567,45 @@ module Aspera
349
567
  parameters[:processing][:root] = uri.path
350
568
  server = WebServerSimple.new(uri, certificate: parameters[:certificate])
351
569
  server.mount(uri.path, Faspex4PostProcServlet, parameters[:processing])
570
+ # on ctrl-c, tell server main loop to exit
352
571
  trap('INT') { server.shutdown }
353
- 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}")
354
573
  Log.log.info("Listening on #{uri.port}")
355
574
  # this is blocking until server exits
356
575
  server.start
357
576
  return Main.result_status('Gateway terminated')
358
577
  end # case command
359
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
360
609
  end # Faspex5
361
610
  end # Plugins
362
611
  end # Cli