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