aspera-cli 4.15.0 → 4.17.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 (108) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/BUGS.md +29 -3
  4. data/CHANGELOG.md +375 -280
  5. data/CONTRIBUTING.md +71 -18
  6. data/README.md +1978 -1656
  7. data/bin/ascli +13 -31
  8. data/bin/asession +32 -22
  9. data/examples/dascli +2 -2
  10. data/lib/aspera/agent/alpha.rb +117 -0
  11. data/lib/aspera/agent/base.rb +61 -0
  12. data/lib/aspera/{fasp/agent_connect.rb → agent/connect.rb} +13 -11
  13. data/lib/aspera/{fasp/agent_direct.rb → agent/direct.rb} +116 -116
  14. data/lib/aspera/{fasp/agent_httpgw.rb → agent/httpgw.rb} +21 -19
  15. data/lib/aspera/{fasp/agent_node.rb → agent/node.rb} +21 -33
  16. data/lib/aspera/agent/trsdk.rb +188 -0
  17. data/lib/aspera/api/aoc.rb +586 -0
  18. data/lib/aspera/api/ats.rb +46 -0
  19. data/lib/aspera/api/cos_node.rb +95 -0
  20. data/lib/aspera/api/node.rb +344 -0
  21. data/lib/aspera/ascmd.rb +47 -14
  22. data/lib/aspera/{fasp → ascp}/installation.rb +54 -15
  23. data/lib/aspera/{fasp → ascp}/management.rb +14 -14
  24. data/lib/aspera/{fasp → ascp}/products.rb +1 -1
  25. data/lib/aspera/assert.rb +45 -0
  26. data/lib/aspera/cli/basic_auth_plugin.rb +11 -10
  27. data/lib/aspera/cli/extended_value.rb +5 -5
  28. data/lib/aspera/cli/formatter.rb +27 -14
  29. data/lib/aspera/cli/hints.rb +7 -6
  30. data/lib/aspera/cli/main.rb +49 -29
  31. data/lib/aspera/cli/manager.rb +46 -36
  32. data/lib/aspera/cli/plugin.rb +34 -20
  33. data/lib/aspera/cli/plugin_factory.rb +61 -0
  34. data/lib/aspera/cli/plugins/alee.rb +7 -7
  35. data/lib/aspera/cli/plugins/aoc.rb +168 -132
  36. data/lib/aspera/cli/plugins/ats.rb +33 -33
  37. data/lib/aspera/cli/plugins/bss.rb +3 -4
  38. data/lib/aspera/cli/plugins/config.rb +250 -272
  39. data/lib/aspera/cli/plugins/console.rb +8 -6
  40. data/lib/aspera/cli/plugins/cos.rb +20 -19
  41. data/lib/aspera/cli/plugins/faspex.rb +71 -60
  42. data/lib/aspera/cli/plugins/faspex5.rb +212 -133
  43. data/lib/aspera/cli/plugins/node.rb +83 -75
  44. data/lib/aspera/cli/plugins/orchestrator.rb +36 -44
  45. data/lib/aspera/cli/plugins/preview.rb +33 -31
  46. data/lib/aspera/cli/plugins/server.rb +33 -32
  47. data/lib/aspera/cli/plugins/shares.rb +39 -33
  48. data/lib/aspera/cli/sync_actions.rb +9 -9
  49. data/lib/aspera/cli/transfer_agent.rb +45 -25
  50. data/lib/aspera/cli/transfer_progress.rb +2 -3
  51. data/lib/aspera/cli/version.rb +1 -1
  52. data/lib/aspera/colors.rb +5 -0
  53. data/lib/aspera/command_line_builder.rb +16 -14
  54. data/lib/aspera/coverage.rb +21 -0
  55. data/lib/aspera/data_repository.rb +33 -2
  56. data/lib/aspera/environment.rb +5 -4
  57. data/lib/aspera/faspex_gw.rb +13 -11
  58. data/lib/aspera/faspex_postproc.rb +6 -5
  59. data/lib/aspera/id_generator.rb +4 -2
  60. data/lib/aspera/json_rpc.rb +10 -8
  61. data/lib/aspera/keychain/encrypted_hash.rb +46 -11
  62. data/lib/aspera/keychain/macos_security.rb +29 -22
  63. data/lib/aspera/log.rb +5 -4
  64. data/lib/aspera/nagios.rb +7 -2
  65. data/lib/aspera/node_simulator.rb +213 -0
  66. data/lib/aspera/oauth/base.rb +143 -0
  67. data/lib/aspera/oauth/factory.rb +124 -0
  68. data/lib/aspera/oauth/generic.rb +34 -0
  69. data/lib/aspera/oauth/jwt.rb +51 -0
  70. data/lib/aspera/oauth/url_json.rb +31 -0
  71. data/lib/aspera/oauth/web.rb +50 -0
  72. data/lib/aspera/oauth.rb +5 -328
  73. data/lib/aspera/open_application.rb +7 -7
  74. data/lib/aspera/persistency_action_once.rb +13 -14
  75. data/lib/aspera/persistency_folder.rb +3 -2
  76. data/lib/aspera/preview/file_types.rb +53 -267
  77. data/lib/aspera/preview/generator.rb +7 -5
  78. data/lib/aspera/preview/terminal.rb +17 -7
  79. data/lib/aspera/preview/utils.rb +8 -7
  80. data/lib/aspera/proxy_auto_config.rb +6 -3
  81. data/lib/aspera/rest.rb +187 -140
  82. data/lib/aspera/rest_error_analyzer.rb +1 -0
  83. data/lib/aspera/rest_errors_aspera.rb +5 -3
  84. data/lib/aspera/resumer.rb +77 -0
  85. data/lib/aspera/secret_hider.rb +5 -2
  86. data/lib/aspera/ssh.rb +15 -8
  87. data/lib/aspera/temp_file_manager.rb +1 -1
  88. data/lib/aspera/{fasp → transfer}/error.rb +3 -3
  89. data/lib/aspera/{fasp → transfer}/error_info.rb +1 -1
  90. data/lib/aspera/{fasp → transfer}/faux_file.rb +1 -1
  91. data/lib/aspera/{fasp → transfer}/parameters.rb +95 -120
  92. data/lib/aspera/{fasp/transfer_spec.rb → transfer/spec.rb} +23 -19
  93. data/lib/aspera/{fasp/parameters.yaml → transfer/spec.yaml} +4 -99
  94. data/lib/aspera/transfer/sync.rb +273 -0
  95. data/lib/aspera/{fasp → transfer}/uri.rb +10 -9
  96. data/lib/aspera/web_server_simple.rb +12 -3
  97. data.tar.gz.sig +0 -0
  98. metadata +92 -68
  99. metadata.gz.sig +0 -0
  100. data/lib/aspera/aoc.rb +0 -606
  101. data/lib/aspera/ats_api.rb +0 -47
  102. data/lib/aspera/cos_node.rb +0 -93
  103. data/lib/aspera/fasp/agent_aspera.rb +0 -126
  104. data/lib/aspera/fasp/agent_base.rb +0 -48
  105. data/lib/aspera/fasp/agent_trsdk.rb +0 -146
  106. data/lib/aspera/fasp/resume_policy.rb +0 -77
  107. data/lib/aspera/node.rb +0 -338
  108. data/lib/aspera/sync.rb +0 -219
@@ -3,57 +3,73 @@
3
3
  # spellchecker: ignore workgroups mypackages passcode
4
4
 
5
5
  require 'aspera/cli/basic_auth_plugin'
6
+ require 'aspera/cli/extended_value'
6
7
  require 'aspera/persistency_action_once'
7
8
  require 'aspera/id_generator'
8
9
  require 'aspera/nagios'
9
10
  require 'aspera/environment'
11
+ require 'aspera/assert'
10
12
  require 'securerandom'
11
13
  require 'tty-spinner'
12
14
 
13
15
  module Aspera
14
16
  module Cli
15
17
  module Plugins
16
- class Faspex5 < Aspera::Cli::BasicAuthPlugin
18
+ class Faspex5 < Cli::BasicAuthPlugin
17
19
  RECIPIENT_TYPES = %w[user workgroup external_user distribution_list shared_inbox].freeze
18
20
  PACKAGE_TERMINATED = %w[completed failed].freeze
19
- API_DETECT = 'api/v5/configuration/ping'
20
21
  # list of supported mailbox types (to list packages)
21
22
  API_LIST_MAILBOX_TYPES = %w[inbox inbox_history inbox_all inbox_all_history outbox outbox_history pending pending_history all].freeze
22
- PACKAGE_ALL_INIT = 'INIT'
23
23
  PACKAGE_SEND_FROM_REMOTE_SOURCE = 'remote_source'
24
24
  # Faspex API v5: get transfer spec for connect
25
25
  TRANSFER_CONNECT = 'connect'
26
26
  ADMIN_RESOURCES = %i[
27
27
  accounts contacts jobs workgroups shared_inboxes nodes oauth_clients registrations saml_configs
28
- metadata_profiles email_notifications
28
+ metadata_profiles email_notifications alternate_addresses
29
29
  ].freeze
30
+ # states for jobs not in final state
30
31
  JOB_RUNNING = %w[queued working].freeze
31
- STANDARD_PATH = '/aspera/faspex'
32
- private_constant(*%i[JOB_RUNNING RECIPIENT_TYPES PACKAGE_TERMINATED API_DETECT API_LIST_MAILBOX_TYPES PACKAGE_SEND_FROM_REMOTE_SOURCE])
32
+ PATH_STANDARD_ROOT = '/aspera/faspex'
33
+ PATH_API_V5 = 'api/v5'
34
+ # endpoint for authentication API
35
+ PATH_AUTH = 'auth'
36
+ PATH_HEALTH = 'configuration/ping'
37
+ PER_PAGE_DEFAULT = 100
38
+ # OAuth methods supported
39
+ STD_AUTH_TYPES = %i[web jwt boot].freeze
40
+ HEADER_ITERATION_TOKEN = 'X-Aspera-Next-Iteration-Token'
41
+ private_constant(*%i[JOB_RUNNING RECIPIENT_TYPES PACKAGE_TERMINATED PATH_HEALTH API_LIST_MAILBOX_TYPES PACKAGE_SEND_FROM_REMOTE_SOURCE PER_PAGE_DEFAULT
42
+ STD_AUTH_TYPES])
33
43
  class << self
34
44
  def application_name
35
45
  'Faspex'
36
46
  end
37
47
 
38
48
  def detect(address_or_url)
49
+ # add scheme if missing
39
50
  address_or_url = "https://#{address_or_url}" unless address_or_url.match?(%r{^[a-z]{1,6}://})
40
51
  urls = [address_or_url]
41
- urls.push("#{address_or_url}#{STANDARD_PATH}") unless address_or_url.end_with?(STANDARD_PATH)
42
-
52
+ urls.push("#{address_or_url}#{PATH_STANDARD_ROOT}") unless address_or_url.end_with?(PATH_STANDARD_ROOT)
53
+ error = nil
43
54
  urls.each do |base_url|
55
+ # Faspex is always HTTPS
44
56
  next unless base_url.start_with?('https://')
45
57
  api = Rest.new(base_url: base_url, redirect_max: 1)
46
- result = api.read(API_DETECT)
58
+ path_api_detect = "#{PATH_API_V5}/#{PATH_HEALTH}"
59
+ result = api.read(path_api_detect)
47
60
  next unless result[:http].code.start_with?('2') && result[:http].body.strip.empty?
48
- url_length = -2 - API_DETECT.length
61
+ # end is at -1, and substract 1 for "/"
62
+ url_length = -2 - path_api_detect.length
49
63
  # take redirect if any
50
64
  return {
51
65
  version: result[:http]['x-ibm-aspera'] || '5',
52
66
  url: result[:http].uri.to_s[0..url_length]
53
67
  }
54
68
  rescue StandardError => e
69
+ error = e
55
70
  Log.log.debug{"detect error: #{e}"}
56
71
  end
72
+ raise error if error
57
73
  return nil
58
74
  end
59
75
 
@@ -89,31 +105,25 @@ module Aspera
89
105
  }
90
106
  end
91
107
 
108
+ # @return true if the URL is a public link
92
109
  def public_link?(url)
93
- url.include?('/public/')
110
+ url.include?('?context=')
94
111
  end
95
112
  end
96
113
 
97
- def initialize(env)
98
- super(env)
114
+ def initialize(**env)
115
+ super
99
116
  options.declare(:client_id, 'OAuth client identifier')
100
117
  options.declare(:client_secret, 'OAuth client secret')
101
118
  options.declare(:redirect_uri, 'OAuth redirect URI for web authentication')
102
- options.declare(:auth, 'OAuth type of authentication', values: %i[boot].concat(Oauth::STD_AUTH_TYPES), default: :jwt)
119
+ options.declare(:auth, 'OAuth type of authentication', values: STD_AUTH_TYPES, default: :jwt)
103
120
  options.declare(:private_key, 'OAuth JWT RSA private key PEM value (prefix file path with @file:)')
104
121
  options.declare(:passphrase, 'OAuth JWT RSA private key passphrase')
105
- options.declare(:box, "Package inbox, either shared inbox name or one of #{API_LIST_MAILBOX_TYPES} or #{ExtendedValue::ALL}", default: 'inbox')
122
+ options.declare(:box, "Package inbox, either shared inbox name or one of: #{API_LIST_MAILBOX_TYPES.join(', ')} or #{ExtendedValue::ALL}", default: 'inbox')
106
123
  options.declare(:shared_folder, 'Send package with files from shared folder')
107
124
  options.declare(:group_type, 'Type of shared box', values: %i[shared_inboxes workgroups], default: :shared_inboxes)
108
125
  options.parse_options!
109
- end
110
-
111
- def api_url
112
- return "#{@faspex5_api_base_url}/api/v5"
113
- end
114
-
115
- def auth_api_url
116
- return "#{@faspex5_api_base_url}/auth"
126
+ @pub_link_context = nil
117
127
  end
118
128
 
119
129
  def set_api
@@ -122,60 +132,63 @@ module Aspera
122
132
  auth_type = self.class.public_link?(@faspex5_api_base_url) ? :public_link : options.get_option(:auth, mandatory: true)
123
133
  case auth_type
124
134
  when :public_link
135
+ # resolve any redirect
136
+ @faspex5_api_base_url = Rest.new(base_url: @faspex5_api_base_url, redirect_max: 3).read('')[:http].uri.to_s
125
137
  encoded_context = Rest.decode_query(URI.parse(@faspex5_api_base_url).query)['context']
126
138
  raise 'Bad faspex5 public link, missing context in query' if encoded_context.nil?
139
+ # public link information (allowed usage)
127
140
  @pub_link_context = JSON.parse(Base64.decode64(encoded_context))
128
141
  Log.log.trace1{Log.dump(:@pub_link_context, @pub_link_context)}
129
142
  # ok, we have the additional parameters, get the base url
130
143
  @faspex5_api_base_url = @faspex5_api_base_url.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
131
- @api_v5 = Rest.new({
132
- base_url: api_url,
144
+ @api_v5 = Rest.new(
145
+ base_url: "#{@faspex5_api_base_url}/#{PATH_API_V5}",
133
146
  headers: {'Passcode' => @pub_link_context['passcode']}
134
- })
147
+ )
135
148
  when :boot
136
149
  # the password here is the token copied directly from browser in developer mode
137
- @api_v5 = Rest.new({
138
- base_url: api_url,
150
+ @api_v5 = Rest.new(
151
+ base_url: "#{@faspex5_api_base_url}/#{PATH_API_V5}",
139
152
  headers: {'Authorization' => options.get_option(:password, mandatory: true)}
140
- })
153
+ )
141
154
  when :web
142
155
  # opens a browser and ask user to auth using web
143
- @api_v5 = Rest.new({
144
- base_url: api_url,
156
+ @api_v5 = Rest.new(
157
+ base_url: "#{@faspex5_api_base_url}/#{PATH_API_V5}",
145
158
  auth: {
146
159
  type: :oauth2,
147
- base_url: auth_api_url,
160
+ base_url: "#{@faspex5_api_base_url}/#{PATH_AUTH}",
148
161
  grant_method: :web,
149
162
  client_id: options.get_option(:client_id, mandatory: true),
150
- web: {redirect_uri: options.get_option(:redirect_uri, mandatory: true)}
151
- }})
163
+ redirect_uri: options.get_option(:redirect_uri, mandatory: true)
164
+ })
152
165
  when :jwt
153
166
  app_client_id = options.get_option(:client_id, mandatory: true)
154
- @api_v5 = Rest.new({
155
- base_url: api_url,
167
+ @api_v5 = Rest.new(
168
+ base_url: "#{@faspex5_api_base_url}/#{PATH_API_V5}",
156
169
  auth: {
157
- type: :oauth2,
158
- base_url: auth_api_url,
159
- grant_method: :jwt,
160
- client_id: app_client_id,
161
- jwt: {
162
- payload: {
163
- iss: app_client_id, # issuer
164
- aud: app_client_id, # audience (this field is not clear...)
165
- sub: "user:#{options.get_option(:username, mandatory: true)}" # subject is a user
166
- },
167
- private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, mandatory: true), options.get_option(:passphrase)),
168
- headers: {typ: 'JWT'}
169
- }
170
- }})
171
- else raise 'Unexpected case for option: auth'
170
+ type: :oauth2,
171
+ grant_method: :jwt,
172
+ base_url: "#{@faspex5_api_base_url}/#{PATH_AUTH}",
173
+ client_id: app_client_id,
174
+ payload: {
175
+ iss: app_client_id, # issuer
176
+ aud: app_client_id, # audience (this field is not clear...)
177
+ sub: "user:#{options.get_option(:username, mandatory: true)}" # subject is a user
178
+ },
179
+ private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, mandatory: true), options.get_option(:passphrase)),
180
+ headers: {typ: 'JWT'}
181
+ })
182
+ else Aspera.error_unexpected_value(auth_type)
172
183
  end
184
+ # in case user wants to use HTTPGW tell transfer agent how to get address
185
+ transfer.httpgw_url_cb = lambda { @api_v5.read('account')[:data]['gateway_url'] }
173
186
  end
174
187
 
175
188
  # if recipient is just an email, then convert to expected API hash : name and type
176
189
  def normalize_recipients(parameters)
177
190
  return unless parameters.key?('recipients')
178
- raise 'Field recipients must be an Array' unless parameters['recipients'].is_a?(Array)
191
+ Aspera.assert_type(parameters['recipients'], Array){'recipients'}
179
192
  recipient_types = RECIPIENT_TYPES
180
193
  if parameters.key?('recipient_types')
181
194
  recipient_types = parameters['recipient_types']
@@ -183,7 +196,7 @@ module Aspera
183
196
  recipient_types = [recipient_types] unless recipient_types.is_a?(Array)
184
197
  end
185
198
  parameters['recipients'].map! do |recipient_data|
186
- # if just a string, assume it is the name
199
+ # if just a string, make a general lookup and build expected name/type hash
187
200
  if recipient_data.is_a?(String)
188
201
  matched = @api_v5.lookup_by_name('contacts', recipient_data, {context: 'packages', type: Rest.array_params(recipient_types)})
189
202
  recipient_data = {
@@ -236,27 +249,25 @@ module Aspera
236
249
  spinner.spin
237
250
  sleep(0.5)
238
251
  end
239
- raise 'internal error'
252
+ Aspera.error_unreachable_line
240
253
  end
241
254
 
242
- # get a (full or partial) list of all entities of a given type
255
+ # Get a (full or partial) list of all entities of a given type
243
256
  # @param type [String] the type of entity to list (just a name)
244
257
  # @param query [Hash,nil] additional query parameters
245
- # @param path [String] optional prefix to add to the path (nil or empty string: no prefix)
258
+ # @param real_path [String] real path if it's n ot just the type
246
259
  # @param item_list_key [String] key in the result to get the list of items
247
- def list_entities(type:, path: nil, query: nil, item_list_key: nil)
248
- query = {} if query.nil?
260
+ def list_entities(type:, real_path: nil, query: {}, item_list_key: nil)
249
261
  type = type.to_s if type.is_a?(Symbol)
262
+ Aspera.assert_type(type, String)
250
263
  item_list_key = type if item_list_key.nil?
251
- raise "internal error: Invalid type #{type.class}" unless type.is_a?(String)
252
- full_path = type
253
- full_path = "#{path}/#{full_path}" unless path.nil? || path.empty?
264
+ full_path = real_path.nil? ? type : real_path
254
265
  result = []
255
266
  offset = 0
256
267
  max_items = query.delete(MAX_ITEMS)
257
268
  remain_pages = query.delete(MAX_PAGES)
258
269
  # merge default parameters, by default 100 per page
259
- query = {'limit'=> 100}.merge(query)
270
+ query = {'limit'=> PER_PAGE_DEFAULT}.merge(query)
260
271
  loop do
261
272
  query['offset'] = offset
262
273
  page_result = @api_v5.read(full_path, query)[:data]
@@ -275,33 +286,36 @@ module Aspera
275
286
  end
276
287
 
277
288
  # lookup an entity id from its name
278
- def lookup_entity_by_field(type:, value:, field: 'name', query: :default, path: nil, item_list_key: nil)
279
- query = {'q'=> value} if query.eql?(:default)
280
- found = list_entities(type: type, path: path, query: query, item_list_key: item_list_key).select{|i|i[field].eql?(value)}
289
+ def lookup_entity_by_field(type:, value:, field: 'name', query: :default, real_path: nil, item_list_key: nil)
290
+ if query.eql?(:default)
291
+ Aspera.assert(field.eql?('name')){'Default query is on name only'}
292
+ query = {'q'=> value}
293
+ end
294
+ found = list_entities(type: type, real_path: real_path, query: query, item_list_key: item_list_key).select{|i|i[field].eql?(value)}
281
295
  case found.length
282
296
  when 0 then raise "No #{type} with #{field} = #{value}"
283
297
  when 1 then return found.first
284
- else raise "Found #{found.length} #{path} with #{field} = #{value}"
298
+ else raise "Found #{found.length} #{real_path} with #{field} = #{value}"
285
299
  end
286
300
  end
287
301
 
288
302
  # list all packages with optional filter
289
- def list_packages_with_filter
303
+ def list_packages_with_filter(query: {})
290
304
  filter = options.get_next_argument('filter', mandatory: false, type: Proc, default: ->(_x){true})
291
305
  # translate box name to API prefix (with ending slash)
292
306
  box = options.get_option(:box)
293
- api_path =
307
+ real_path =
294
308
  case box
295
- when ExtendedValue::ALL then '' # only admin can list all packages globally
296
- when *API_LIST_MAILBOX_TYPES then box
309
+ when ExtendedValue::ALL then 'packages' # only admin can list all packages globally
310
+ when *API_LIST_MAILBOX_TYPES then "#{box}/packages"
297
311
  else
298
312
  group_type = options.get_option(:group_type)
299
- "#{group_type}/#{lookup_entity_by_field(type: group_type, value: box)['id']}"
313
+ "#{group_type}/#{lookup_entity_by_field(type: group_type, value: box)['id']}/packages"
300
314
  end
301
315
  return list_entities(
302
316
  type: 'packages',
303
- query: query_read_delete(default: {}),
304
- path: api_path).select(&filter)
317
+ query: query_read_delete(default: query),
318
+ real_path: real_path).select(&filter)
305
319
  end
306
320
 
307
321
  def package_receive(package_ids)
@@ -310,30 +324,37 @@ module Aspera
310
324
  if options.get_option(:once_only, mandatory: true)
311
325
  # read ids from persistency
312
326
  skip_ids_persistency = PersistencyActionOnce.new(
313
- manager: @agents[:persistency],
327
+ manager: persistency,
314
328
  data: [],
315
329
  id: IdGenerator.from_list([
316
330
  'faspex_recv',
317
331
  options.get_option(:url, mandatory: true),
318
- options.get_option(:username, mandatory: true)]))
332
+ options.get_option(:username, mandatory: true),
333
+ options.get_option(:box, mandatory: true)
334
+ ]))
319
335
  end
336
+ packages = []
320
337
  case package_ids
321
- when PACKAGE_ALL_INIT
322
- raise 'Only with option once_only' unless skip_ids_persistency
338
+ when ExtendedValue::INIT
339
+ Aspera.assert(skip_ids_persistency){'Only with option once_only'}
323
340
  skip_ids_persistency.data.clear.concat(list_packages_with_filter.map{|p|p['id']})
324
341
  skip_ids_persistency.save
325
342
  return Main.result_status("Initialized skip for #{skip_ids_persistency.data.count} package(s)")
326
343
  when ExtendedValue::ALL
327
344
  # TODO: if packages have same name, they will overwrite ?
328
- package_ids = list_packages_with_filter.map{|p|p['id']}
329
- Log.log.debug{Log.dump(:package_ids, package_ids)}
330
- Log.log.debug{Log.dump(:skip_ids, skip_ids_persistency.data)}
331
- package_ids.reject!{|i|skip_ids_persistency.data.include?(i)} if skip_ids_persistency
332
- Log.log.debug{Log.dump(:package_ids, package_ids)}
345
+ packages = list_packages_with_filter(query: {'status' => 'completed'})
346
+ Log.log.trace1{Log.dump(:package_ids, packages.map{|p|p['id']})}
347
+ Log.log.trace1{Log.dump(:skip_ids, skip_ids_persistency.data)}
348
+ packages.reject!{|p|skip_ids_persistency.data.include?(p['id'])} if skip_ids_persistency
349
+ Log.log.trace1{Log.dump(:package_ids, packages.map{|p|p['id']})}
350
+ else
351
+ # a single id was provided, or a list of ids
352
+ package_ids = [package_ids] unless package_ids.is_a?(Array)
353
+ Aspera.assert_type(package_ids, Array){'Expecting a single package id or a list of ids'}
354
+ Aspera.assert(package_ids.all?(String)){'Package id shall be String'}
355
+ # packages = package_ids.map{|pkg_id|@api_v5.read("packages/#{pkg_id}")[:data]}
356
+ packages = package_ids.map{|pkg_id|{'id'=>pkg_id}}
333
357
  end
334
- # a single id was provided
335
- # TODO: check package_ids is a list of strings
336
- package_ids = [package_ids] if package_ids.is_a?(String)
337
358
  result_transfer = []
338
359
  param_file_list = {}
339
360
  begin
@@ -352,7 +373,8 @@ module Aspera
352
373
  else # shared inbox / workgroup
353
374
  download_params[:recipient_workgroup_id] = lookup_entity_by_field(type: options.get_option(:group_type), value: box)['id']
354
375
  end
355
- package_ids.each do |pkg_id|
376
+ packages.each do |package|
377
+ pkg_id = package['id']
356
378
  formatter.display_status("Receiving package #{pkg_id}")
357
379
  # TODO: allow from sent as well ?
358
380
  transfer_spec = @api_v5.call(
@@ -375,6 +397,45 @@ module Aspera
375
397
  return Main.result_transfer_multiple(result_transfer)
376
398
  end
377
399
 
400
+ # browse a folder
401
+ # @param browse_endpoint [String] the endpoint to browse
402
+ def browse_folder(browse_endpoint)
403
+ path = options.get_next_argument('folder path', mandatory: false, default: '/')
404
+ query = query_read_delete(default: {})
405
+ query['filters'] = {} unless query.key?('filters')
406
+ filters = query.delete('filters')
407
+ filters['basenames'] = [] unless filters.key?('basenames')
408
+ Aspera.assert_type(filters, Hash){'filters'}
409
+ max_items = query.delete('max')
410
+ recursive = query.delete('recursive')
411
+ all_items = []
412
+ folders_to_process = [path]
413
+ until folders_to_process.empty?
414
+ path = folders_to_process.shift
415
+ query.delete('iteration_token')
416
+ loop do
417
+ response = @api_v5.call(
418
+ operation: 'POST',
419
+ subpath: browse_endpoint,
420
+ headers: {'Accept' => 'application/json', 'Content-Type' => 'application/json'},
421
+ url_params: query,
422
+ json_params: {'path' => path, 'filters' => filters})
423
+ all_items.concat(response[:data]['items'])
424
+ if recursive
425
+ folders_to_process.concat(response[:data]['items'].select{|i|i['type'].eql?('directory')}.map{|i|i['path']})
426
+ end
427
+ if !max_items.nil? && (all_items.count >= max_items)
428
+ all_items = all_items.slice(0, max_items) if all_items.count > max_items
429
+ break
430
+ end
431
+ iteration_token = response[:http][HEADER_ITERATION_TOKEN]
432
+ break if iteration_token.nil? || iteration_token.empty?
433
+ query['iteration_token'] = iteration_token
434
+ end
435
+ end
436
+ return {type: :object_list, data: all_items}
437
+ end
438
+
378
439
  def package_action
379
440
  command = options.get_next_command(%i[show browse status delete receive send list])
380
441
  package_id =
@@ -385,35 +446,34 @@ module Aspera
385
446
  when :show
386
447
  return {type: :single_object, data: @api_v5.read("packages/#{package_id}")[:data]}
387
448
  when :browse
388
- path = options.get_next_argument('path', expected: :single, mandatory: false) || '/'
389
- # TODO: support multi-page listing ?
390
- params = {
391
- # recipient_user_id: 25,
392
- # offset: 0,
393
- # limit: 25
394
- }
395
- result = @api_v5.call({
396
- operation: 'POST',
397
- subpath: "packages/#{package_id}/files/received",
398
- headers: {'Accept' => 'application/json'},
399
- url_params: params,
400
- json_params: {'path' => path, 'filters' => {'basenames'=>[]}}})[:data]
401
- formatter.display_item_count(result['item_count'], result['total_count'])
402
- return {type: :object_list, data: result['items']}
449
+ location = case options.get_option(:box)
450
+ when 'inbox' then 'received'
451
+ when 'outbox' then 'sent'
452
+ else raise 'Browse only available for inbox and outbox'
453
+ end
454
+ return browse_folder("packages/#{package_id}/files/#{location}")
403
455
  when :status
404
456
  status = wait_package_status(package_id, status_list: nil)
405
457
  return {type: :single_object, data: status}
406
458
  when :delete
407
459
  ids = package_id
408
460
  ids = [ids] unless ids.is_a?(Array)
409
- raise 'Package identifier must be a single id or an Array' unless ids.is_a?(Array) && ids.all?(String)
461
+ Aspera.assert_type(ids, Array){'Package identifier'}
462
+ Aspera.assert(ids.all?(String)){'Package id shall be String'}
410
463
  # API returns 204, empty on success
411
- @api_v5.call({operation: 'DELETE', subpath: 'packages', headers: {'Accept' => 'application/json'}, json_params: {ids: ids}})
464
+ @api_v5.call(operation: 'DELETE', subpath: 'packages', headers: {'Accept' => 'application/json'}, json_params: {ids: ids})
412
465
  return Main.result_status('Package(s) deleted')
413
466
  when :receive
414
467
  return package_receive(package_id)
415
468
  when :send
416
469
  parameters = value_create_modify(command: command)
470
+ # autofill recipient for public url
471
+ if @pub_link_context&.key?('recipient_type') && !parameters.key?('recipients')
472
+ parameters['recipients'] = [{
473
+ name: @pub_link_context['name'],
474
+ recipient_type: @pub_link_context['recipient_type']
475
+ }]
476
+ end
417
477
  normalize_recipients(parameters)
418
478
  package = @api_v5.create('packages', parameters)[:data]
419
479
  shared_folder = options.get_option(:shared_folder)
@@ -453,7 +513,7 @@ module Aspera
453
513
  end # case package
454
514
  end
455
515
 
456
- ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing].freeze
516
+ ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing invitations].freeze
457
517
 
458
518
  def execute_action
459
519
  command = options.get_next_command(ACTIONS)
@@ -473,7 +533,9 @@ module Aspera
473
533
  end
474
534
  return nagios.result
475
535
  when :user
476
- case options.get_next_command(%i[profile])
536
+ case options.get_next_command(%i[account profile])
537
+ when :account
538
+ return { type: :single_object, data: @api_v5.read('account')[:data] }
477
539
  when :profile
478
540
  case options.get_next_command(%i[show modify])
479
541
  when :show
@@ -499,21 +561,9 @@ module Aspera
499
561
  raise "multiple matches for #{field} = #{value}" if matches.length > 1
500
562
  matches.first['id']
501
563
  end
502
- path = options.get_next_argument('folder path', mandatory: false) || '/'
503
564
  node = all_shared_folders.find{|i|i['id'].eql?(shared_folder_id)}
504
565
  raise "No such shared folder id #{shared_folder_id}" if node.nil?
505
- result = @api_v5.call({
506
- operation: 'POST',
507
- subpath: "nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse",
508
- headers: {'Accept' => 'application/json', 'Content-Type' => 'application/json'},
509
- json_params: {'path': path, 'filters': {'basenames': []}},
510
- url_params: {offset: 0, limit: 100}
511
- })[:data]
512
- if result.key?('items')
513
- return {type: :object_list, data: result['items']}
514
- else
515
- return {type: :single_object, data: result['self']}
516
- end
566
+ return browse_folder("nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse")
517
567
  end
518
568
  when :admin
519
569
  case options.get_next_command(%i[resource smtp].freeze)
@@ -523,11 +573,14 @@ module Aspera
523
573
  id_as_arg = false
524
574
  display_fields = nil
525
575
  adm_api = @api_v5
576
+ special_query = :default
526
577
  available_commands = [].concat(Plugin::ALL_OPS)
527
578
  case res_type
528
579
  when :metadata_profiles
529
580
  res_path = 'configuration/metadata_profiles'
530
581
  list_key = 'profiles'
582
+ when :alternate_addresses
583
+ res_path = 'configuration/alternate_addresses'
531
584
  when :email_notifications
532
585
  list_key = false
533
586
  id_as_arg = 'type'
@@ -535,14 +588,31 @@ module Aspera
535
588
  display_fields = Formatter.all_but('user_profile_data_attributes')
536
589
  when :oauth_clients
537
590
  display_fields = Formatter.all_but('public_key')
538
- adm_api = Rest.new(@api_v5.params.merge({base_url: auth_api_url}))
591
+ adm_api = Rest.new(**@api_v5.params.merge(base_url: "#{@faspex5_api_base_url}/#{PATH_AUTH}"))
539
592
  when :shared_inboxes, :workgroups
540
593
  available_commands.push(:members, :saml_groups, :invite_external_collaborator)
594
+ special_query = {'all': true}
595
+ when :nodes
596
+ available_commands.push(:shared_folders, :browse)
541
597
  end
542
598
  res_command = options.get_next_command(available_commands)
543
599
  case res_command
544
600
  when *Plugin::ALL_OPS
545
- return entity_command(res_command, adm_api, res_path, item_list_key: list_key, display_fields: display_fields, id_as_arg: id_as_arg)
601
+ return entity_command(res_command, adm_api, res_path, item_list_key: list_key, display_fields: display_fields, id_as_arg: id_as_arg) do |field, value|
602
+ lookup_entity_by_field(
603
+ type: res_type, real_path: res_path, field: field, value: value, query: special_query)['id']
604
+ end
605
+ when :shared_folders
606
+ node_id = instance_identifier do |field, value|
607
+ lookup_entity_by_field(type: res_type.to_s, field: field, value: value)['id']
608
+ end
609
+ sh_path = "#{res_path}/#{node_id}/shared_folders"
610
+ return entity_action(adm_api, sh_path, item_list_key: 'shared_folders') do |field, value|
611
+ lookup_entity_by_field(
612
+ type: 'shared_folders', real_path: sh_path, field: field, value: value)['id']
613
+ end
614
+ when :browse
615
+ return browse_folder("#{res_path}/#{instance_identifier}/browse")
546
616
  when :invite_external_collaborator
547
617
  shared_inbox_id = instance_identifier { |field, value| lookup_entity_by_field(type: res_type.to_s, field: field, value: value)['id']}
548
618
  creation_payload = value_create_modify(command: res_command, type: [Hash, String])
@@ -550,7 +620,9 @@ module Aspera
550
620
  res_path = "#{res_type}/#{shared_inbox_id}/external_collaborator"
551
621
  result = adm_api.create(res_path, creation_payload)[:data]
552
622
  formatter.display_status(result['message'])
553
- result = lookup_entity_by_field(type: 'members', path: "#{res_type}/#{shared_inbox_id}", value: creation_payload['email_address'], query: {})
623
+ result = lookup_entity_by_field(
624
+ type: 'members', real_path: "#{res_type}/#{shared_inbox_id}/members", value: creation_payload['email_address'],
625
+ query: {})
554
626
  return {type: :single_object, data: result}
555
627
  when :members, :saml_groups
556
628
  res_id = instance_identifier { |field, value| lookup_entity_by_field(type: res_type.to_s, field: field, value: value)['id']}
@@ -605,34 +677,41 @@ module Aspera
605
677
  return { type: :single_object, data: result }
606
678
  end
607
679
  end
680
+ when :invitations
681
+ invitation_endpoint = 'invitations'
682
+ invitation_command = options.get_next_command(%i[resend].concat(Plugin::ALL_OPS))
683
+ case invitation_command
684
+ when :create
685
+ return do_bulk_operation(command: invitation_command, descr: 'data') do |params|
686
+ invitation_endpoint = params.key?('recipient_name') ? 'public_invitations' : 'invitations'
687
+ @api_v5.create(invitation_endpoint, params)[:data]
688
+ end
689
+ when :resend
690
+ @api_v5.create("#{invitation_endpoint}/#{instance_identifier}/resend")
691
+ return Main.result_status('Invitation resent')
692
+ else
693
+ return entity_command(
694
+ invitation_command, @api_v5, invitation_endpoint, item_list_key: invitation_endpoint,
695
+ display_fields: %w[id public recipient_type recipient_name email_address])
696
+ end
608
697
  when :gateway
609
698
  require 'aspera/faspex_gw'
610
- url = value_create_modify(command: command, type: String)
699
+ url = value_create_modify(command: command, description: 'listening url (e.g. https://localhost:12345)', type: String)
611
700
  uri = URI.parse(url)
612
701
  server = WebServerSimple.new(uri)
613
702
  server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
614
- # on ctrl-c, tell server main loop to exit
615
- trap('INT') { server.shutdown }
616
- formatter.display_status("Gateway for Faspex 4-style API listening on #{url}")
617
- Log.log.info("Listening on #{url}")
618
- # this is blocking until server exits
619
703
  server.start
620
704
  return Main.result_status('Gateway terminated')
621
705
  when :postprocessing
622
706
  require 'aspera/faspex_postproc' # cspell:disable-line
623
707
  parameters = value_create_modify(command: command)
624
708
  parameters = parameters.symbolize_keys
625
- raise 'Missing key: url' unless parameters.key?(:url)
709
+ Aspera.assert(parameters.key?(:url)){'Missing key: url'}
626
710
  uri = URI.parse(parameters[:url])
627
711
  parameters[:processing] ||= {}
628
712
  parameters[:processing][:root] = uri.path
629
713
  server = WebServerSimple.new(uri, certificate: parameters[:certificate])
630
714
  server.mount(uri.path, Faspex4PostProcServlet, parameters[:processing])
631
- # on ctrl-c, tell server main loop to exit
632
- trap('INT') { server.shutdown }
633
- formatter.display_status("Web-hook for Faspex 4-style post processing listening on #{uri.port}")
634
- Log.log.info("Listening on #{uri.port}")
635
- # this is blocking until server exits
636
715
  server.start
637
716
  return Main.result_status('Gateway terminated')
638
717
  end # case command