aspera-cli 4.23.0 → 4.24.1

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 (110) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +37 -1
  4. data/CONTRIBUTING.md +86 -29
  5. data/README.md +2109 -1300
  6. data/bin/ascli +2 -1
  7. data/bin/asession +4 -4
  8. data/lib/aspera/agent/base.rb +4 -0
  9. data/lib/aspera/agent/connect.rb +20 -18
  10. data/lib/aspera/agent/desktop.rb +14 -11
  11. data/lib/aspera/agent/direct.rb +39 -31
  12. data/lib/aspera/agent/httpgw.rb +2 -2
  13. data/lib/aspera/agent/node.rb +9 -11
  14. data/lib/aspera/agent/transferd.rb +18 -11
  15. data/lib/aspera/api/aoc.rb +44 -31
  16. data/lib/aspera/api/cos_node.rb +7 -5
  17. data/lib/aspera/api/httpgw.rb +15 -18
  18. data/lib/aspera/api/node.rb +104 -22
  19. data/lib/aspera/ascmd.rb +22 -16
  20. data/lib/aspera/ascp/installation.rb +37 -40
  21. data/lib/aspera/ascp/management.rb +5 -4
  22. data/lib/aspera/assert.rb +54 -23
  23. data/lib/aspera/cli/basic_auth_plugin.rb +8 -7
  24. data/lib/aspera/cli/error.rb +1 -1
  25. data/lib/aspera/cli/extended_value.rb +28 -29
  26. data/lib/aspera/cli/formatter.rb +191 -168
  27. data/lib/aspera/cli/hints.rb +29 -3
  28. data/lib/aspera/cli/main.rb +138 -107
  29. data/lib/aspera/cli/manager.rb +50 -30
  30. data/lib/aspera/cli/plugin.rb +148 -77
  31. data/lib/aspera/cli/plugin_factory.rb +2 -2
  32. data/lib/aspera/cli/plugins/aoc.rb +189 -70
  33. data/lib/aspera/cli/plugins/ats.rb +15 -13
  34. data/lib/aspera/cli/plugins/config.rb +100 -214
  35. data/lib/aspera/cli/plugins/console.rb +49 -18
  36. data/lib/aspera/cli/plugins/cos.rb +4 -4
  37. data/lib/aspera/cli/plugins/faspex.rb +45 -51
  38. data/lib/aspera/cli/plugins/faspex5.rb +164 -165
  39. data/lib/aspera/cli/plugins/faspio.rb +6 -5
  40. data/lib/aspera/cli/plugins/httpgw.rb +2 -2
  41. data/lib/aspera/cli/plugins/node.rb +144 -162
  42. data/lib/aspera/cli/plugins/orchestrator.rb +10 -14
  43. data/lib/aspera/cli/plugins/preview.rb +26 -29
  44. data/lib/aspera/cli/plugins/server.rb +28 -28
  45. data/lib/aspera/cli/plugins/shares.rb +40 -28
  46. data/lib/aspera/cli/sync_actions.rb +101 -80
  47. data/lib/aspera/cli/transfer_agent.rb +51 -50
  48. data/lib/aspera/cli/transfer_progress.rb +29 -20
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +157 -0
  51. data/lib/aspera/colors.rb +13 -8
  52. data/lib/aspera/command_line_builder.rb +28 -22
  53. data/lib/aspera/command_line_converter.rb +31 -0
  54. data/lib/aspera/environment.rb +145 -101
  55. data/lib/aspera/faspex_gw.rb +1 -1
  56. data/lib/aspera/faspex_postproc.rb +3 -2
  57. data/lib/aspera/hash_ext.rb +1 -1
  58. data/lib/aspera/id_generator.rb +10 -10
  59. data/lib/aspera/keychain/base.rb +18 -0
  60. data/lib/aspera/keychain/encrypted_hash.rb +6 -12
  61. data/lib/aspera/keychain/factory.rb +9 -3
  62. data/lib/aspera/keychain/hashicorp_vault.rb +9 -6
  63. data/lib/aspera/keychain/macos_security.rb +13 -13
  64. data/lib/aspera/log.rb +91 -19
  65. data/lib/aspera/nagios.rb +5 -6
  66. data/lib/aspera/node_simulator.rb +12 -7
  67. data/lib/aspera/oauth/base.rb +5 -3
  68. data/lib/aspera/oauth/factory.rb +24 -18
  69. data/lib/aspera/oauth/jwt.rb +13 -1
  70. data/lib/aspera/oauth/url_json.rb +3 -3
  71. data/lib/aspera/oauth/web.rb +5 -3
  72. data/lib/aspera/persistency_folder.rb +2 -2
  73. data/lib/aspera/preview/file_types.rb +4 -3
  74. data/lib/aspera/preview/generator.rb +25 -12
  75. data/lib/aspera/preview/terminal.rb +10 -7
  76. data/lib/aspera/preview/utils.rb +11 -9
  77. data/lib/aspera/products/connect.rb +1 -1
  78. data/lib/aspera/products/desktop.rb +1 -1
  79. data/lib/aspera/products/other.rb +2 -2
  80. data/lib/aspera/products/transferd.rb +8 -6
  81. data/lib/aspera/proxy_auto_config.rb +1 -1
  82. data/lib/aspera/rest.rb +29 -22
  83. data/lib/aspera/rest_call_error.rb +1 -1
  84. data/lib/aspera/resumer.rb +1 -1
  85. data/lib/aspera/secret_hider.rb +46 -40
  86. data/lib/aspera/ssh.rb +13 -3
  87. data/lib/aspera/sync/args.schema.yaml +102 -0
  88. data/lib/aspera/sync/conf.schema.yaml +701 -0
  89. data/lib/aspera/sync/database.rb +83 -0
  90. data/lib/aspera/sync/operations.rb +296 -0
  91. data/lib/aspera/temp_file_manager.rb +3 -2
  92. data/lib/aspera/transfer/error.rb +1 -1
  93. data/lib/aspera/transfer/error_info.rb +1 -2
  94. data/lib/aspera/transfer/faux_file.rb +11 -10
  95. data/lib/aspera/transfer/parameters.rb +6 -5
  96. data/lib/aspera/transfer/spec.rb +15 -1
  97. data/lib/aspera/transfer/spec.schema.yaml +316 -293
  98. data/lib/aspera/transfer/spec_doc.rb +34 -16
  99. data/lib/aspera/transfer/uri.rb +5 -5
  100. data/lib/aspera/uri_reader.rb +14 -10
  101. data/lib/aspera/web_auth.rb +2 -2
  102. data/lib/aspera/web_server_simple.rb +2 -2
  103. data.tar.gz.sig +0 -0
  104. metadata +15 -13
  105. metadata.gz.sig +0 -0
  106. data/lib/aspera/transfer/async_conf.schema.yaml +0 -716
  107. data/lib/aspera/transfer/convert.rb +0 -29
  108. data/lib/aspera/transfer/sync.rb +0 -232
  109. data/lib/aspera/transfer/sync_instance.schema.yaml +0 -20
  110. data/lib/aspera/transfer/sync_session.schema.yaml +0 -86
@@ -61,7 +61,7 @@ module Aspera
61
61
  # class static methods
62
62
  class << self
63
63
  # strings /Applications/Aspera\ Drive.app/Contents/MacOS/AsperaDrive|grep -E '.{100}==$'|base64 --decode
64
- def get_client_info(client_name=nil)
64
+ def get_client_info(client_name = nil)
65
65
  client_key = client_name.nil? ? GLOBAL_CLIENT_APPS.first : client_name.to_sym
66
66
  return client_key, DataRepository.instance.item(client_key)
67
67
  end
@@ -82,23 +82,25 @@ module Aspera
82
82
  end
83
83
 
84
84
  def saas_url?(url)
85
- url.include?(SAAS_DOMAIN_PROD)
85
+ URI.parse(url).host&.end_with?(".#{SAAS_DOMAIN_PROD}")
86
+ rescue URI::InvalidURIError
87
+ false
86
88
  end
87
89
 
88
90
  # @param url [String] URL of AoC public link
89
91
  # @return [Hash] information about public link, or nil if not a public link
90
92
  def link_info(url)
91
93
  final_uri = Rest.new(base_url: url, redirect_max: MAX_AOC_URL_REDIRECT).call(operation: 'GET')[:http].uri
92
- Log.log.trace1{Log.dump(:final_uri, final_uri)}
94
+ Log.dump(:final_uri, final_uri, level: :trace1)
93
95
  org_domain = split_org_domain(final_uri)
94
96
  if (m = final_uri.path.match(%r{/oauth2/([^/]+)/login$}))
95
97
  org_domain[:organization] = m[1] if org_domain[:organization].nil?
96
98
  else
97
99
  Log.log.debug{"path=#{final_uri.path} does not end with /login"}
98
100
  end
99
- raise 'AoC shall redirect to login page with a query' if final_uri.query.nil?
101
+ raise Error, 'AoC shall redirect to login page with a query' if final_uri.query.nil?
100
102
  query = Rest.query_to_h(final_uri.query)
101
- Log.log.trace1{Log.dump(:query, query)}
103
+ Log.dump(:query, query, level: :trace1)
102
104
  # is that a public link ?
103
105
  if query.key?('token')
104
106
  Log.log.warn{"Unknown pub link path: #{final_uri.path}"} unless PUBLIC_LINK_PATHS.include?(final_uri.path)
@@ -109,7 +111,7 @@ module Aspera
109
111
  token: query['token']
110
112
  }
111
113
  end
112
- if query['state']
114
+ if query.key?('state')
113
115
  # can be a private link
114
116
  state_uri = URI.parse(query['state'])
115
117
  if state_uri.query && query['redirect_uri']
@@ -132,7 +134,7 @@ module Aspera
132
134
  end
133
135
  end
134
136
  end
135
- Log.log.debug{Log.dump(:org_domain, org_domain)}
137
+ Log.dump(:org_domain, org_domain)
136
138
  return {
137
139
  instance_domain: org_domain[:domain],
138
140
  organization: org_domain[:organization]
@@ -166,7 +168,7 @@ module Aspera
166
168
  }
167
169
  # analyze type of url
168
170
  url_info = AoC.link_info(url)
169
- Log.log.debug{Log.dump(:url_info, url_info)}
171
+ Log.dump(:url_info, url_info)
170
172
  @private_link = url_info[:private_link]
171
173
  auth_params[:grant_method] = if url_info.key?(:token)
172
174
  :url_json
@@ -212,7 +214,7 @@ module Aspera
212
214
  end
213
215
 
214
216
  def public_link
215
- return nil unless auth_params[:grant_method].eql?(:url_json)
217
+ return unless auth_params[:grant_method].eql?(:url_json)
216
218
  return @cache_url_token_info unless @cache_url_token_info.nil?
217
219
  # TODO: can there be several in list ?
218
220
  @cache_url_token_info = read('url_tokens').first
@@ -245,12 +247,12 @@ module Aspera
245
247
  end
246
248
 
247
249
  def workspace
248
- raise 'internal error: AoC workspace context is not set' if @workspace_info.nil?
250
+ Aspera.assert(!@workspace_info.nil?){'AoC workspace context is not set'}
249
251
  @workspace_info
250
252
  end
251
253
 
252
254
  def home
253
- raise 'internal error: AoC home context is not set' if @home_info.nil?
255
+ Aspera.assert(!@home_info.nil?){'AoC home context is not set'}
254
256
  @home_info
255
257
  end
256
258
 
@@ -294,8 +296,8 @@ module Aspera
294
296
  name: ws_info['name']
295
297
  }
296
298
  end
297
- Log.log.debug{Log.dump(:context, @workspace_info)}
298
- return nil unless application.eql?(:files)
299
+ Log.dump(:context, @workspace_info)
300
+ return unless application.eql?(:files)
299
301
  @home_info =
300
302
  if !public_link.nil?
301
303
  assert_public_link_types(['view_shared_file'])
@@ -322,7 +324,7 @@ module Aspera
322
324
  }
323
325
  end
324
326
  raise "Cannot get user's home node id, check your default workspace or specify one" if @home_info[:node_id].to_s.empty?
325
- Log.log.debug{Log.dump(:context, @home_info)}
327
+ Log.dump(:context, @home_info)
326
328
  end
327
329
 
328
330
  # @param node_id [String] identifier of node in AoC
@@ -334,9 +336,7 @@ module Aspera
334
336
  def node_api_from(node_id:, workspace_id: nil, workspace_name: nil, scope: Node::SCOPE_USER, package_info: nil)
335
337
  Aspera.assert_type(node_id, String)
336
338
  node_info = read("nodes/#{node_id}")
337
- if workspace_name.nil? && !workspace_id.nil?
338
- workspace_name = read("workspaces/#{workspace_id}")['name']
339
- end
339
+ workspace_name = read("workspaces/#{workspace_id}")['name'] if workspace_name.nil? && !workspace_id.nil?
340
340
  app_info = {
341
341
  api: self, # for callback
342
342
  app: package_info.nil? ? FILES_APP : PACKAGES_APP,
@@ -345,7 +345,7 @@ module Aspera
345
345
  workspace_name: workspace_name
346
346
  }
347
347
  if PACKAGES_APP.eql?(app_info[:app])
348
- raise 'package info required' if package_info.nil?
348
+ Aspera.assert(!package_info.nil?){'package info required'}
349
349
  app_info[:package_id] = package_info['id']
350
350
  app_info[:package_name] = package_info['name']
351
351
  end
@@ -383,7 +383,7 @@ module Aspera
383
383
  Aspera.assert(pkg_data.key?('metadata')){"package requires metadata: #{meta_schema}"}
384
384
  pkg_meta = pkg_data['metadata']
385
385
  Aspera.assert_type(pkg_meta, Array){'metadata'}
386
- Log.log.debug{Log.dump(:metadata, pkg_meta)}
386
+ Log.dump(:metadata, pkg_meta)
387
387
  pkg_meta.each do |field|
388
388
  Aspera.assert_type(field, Hash){'metadata field'}
389
389
  Aspera.assert(field.key?('name')){'metadata field must have name'}
@@ -443,7 +443,7 @@ module Aspera
443
443
  end
444
444
  # replace with resolved elements
445
445
  package_data[recipient_list_field] = resolved_list
446
- return nil
446
+ return
447
447
  end
448
448
 
449
449
  # CLI allows simplified format for metadata: transform if necessary for API
@@ -461,7 +461,7 @@ module Aspera
461
461
  pkg_data['metadata'] = api_meta
462
462
  else Aspera.error_unexpected_value(pkg_meta.class)
463
463
  end
464
- return nil
464
+ return
465
465
  end
466
466
 
467
467
  # create a package
@@ -480,18 +480,24 @@ module Aspera
480
480
 
481
481
  validate_metadata(package_data) if validate_meta
482
482
 
483
+ # tell AoC what to expect in package: 1 transfer (can also be done after transfer)
484
+ # TODO: if multi session was used we should probably tell
485
+ # also, currently no "multi-source" , i.e. only from client-side files, unless "node" agent is used
486
+ # `single_source` is required to allow web UI to ask for CSEAR password on download, see API doc
487
+ package_data.merge!({
488
+ 'single_source' => true,
489
+ 'sent' => true,
490
+ 'transfers_expected' => 1
491
+ })
492
+
483
493
  # create a new package container
484
494
  created_package = create('packages', package_data)
485
495
 
486
496
  package_node_api = node_api_from(
487
497
  node_id: created_package['node_id'],
488
498
  workspace_id: created_package['workspace_id'],
489
- package_info: created_package)
490
-
491
- # tell AoC what to expect in package: 1 transfer (can also be done after transfer)
492
- # TODO: if multi session was used we should probably tell
493
- # also, currently no "multi-source" , i.e. only from client-side files, unless "node" agent is used
494
- update("packages/#{created_package['id']}", {'sent' => true, 'transfers_expected' => 1})
499
+ package_info: created_package
500
+ )
495
501
 
496
502
  return {
497
503
  spec: package_node_api.transfer_spec_gen4(created_package['contents_file_id'], Transfer::Spec::DIRECTION_SEND),
@@ -510,8 +516,10 @@ module Aspera
510
516
  transfer_spec.deep_merge!({
511
517
  'tags' => {
512
518
  Transfer::Spec::TAG_RESERVED => {
519
+ 'app' => app_info[:app],
513
520
  'usage_id' => "aspera.files.workspace.#{app_info[:workspace_id]}", # activity tracking
514
521
  'files' => {
522
+ 'node_id' => app_info[:node_info]['id'],
515
523
  'files_transfer_action' => "#{transfer_type}_#{app_info[:app].gsub(/s$/, '')}",
516
524
  'workspace_name' => app_info[:workspace_name], # activity tracking
517
525
  'workspace_id' => app_info[:workspace_id]
@@ -530,8 +538,15 @@ module Aspera
530
538
  case app_info[:app]
531
539
  when FILES_APP
532
540
  file_id = transfer_spec['tags'][Transfer::Spec::TAG_RESERVED]['node']['file_id']
533
- transfer_spec.deep_merge!({'tags' => {Transfer::Spec::TAG_RESERVED => {'files' => {'parentCwd' => "#{app_info[:node_info]['id']}:#{file_id}"}}}}) \
534
- unless transfer_spec.key?('remote_access_key')
541
+ transfer_spec.deep_merge!({
542
+ 'tags' => {
543
+ Transfer::Spec::TAG_RESERVED => {
544
+ 'files' => {
545
+ 'parentCwd' => "#{app_info[:node_info]['id']}:#{file_id}"
546
+ }
547
+ }
548
+ }
549
+ }) unless transfer_spec.key?('remote_access_key')
535
550
  when PACKAGES_APP
536
551
  transfer_spec.deep_merge!({
537
552
  'tags' => {
@@ -545,8 +560,6 @@ module Aspera
545
560
  }
546
561
  })
547
562
  end
548
- transfer_spec['tags'][Transfer::Spec::TAG_RESERVED]['files']['node_id'] = app_info[:node_info]['id']
549
- transfer_spec['tags'][Transfer::Spec::TAG_RESERVED]['app'] = app_info[:app]
550
563
  end
551
564
 
552
565
  # Callback from Plugins::Node
@@ -14,13 +14,13 @@ module Aspera
14
14
  def parameters_from_svc_credentials(service_credentials, bucket_region)
15
15
  # check necessary contents
16
16
  Aspera.assert_type(service_credentials, Hash){'service_credentials'}
17
- Aspera::Log.dump('service_credentials', service_credentials)
17
+ Log.dump(:service_credentials, service_credentials)
18
18
  %w[apikey resource_instance_id endpoints].each do |field|
19
19
  Aspera.assert(service_credentials.key?(field)){"service_credentials must have a field: #{field}"}
20
20
  end
21
21
  # read endpoints from service provided in service credentials
22
22
  endpoints = Aspera::Rest.new(base_url: service_credentials['endpoints']).read('')
23
- Aspera::Log.dump('endpoints', endpoints)
23
+ Log.dump(:endpoints, endpoints)
24
24
  endpoint = endpoints.dig('service-endpoints', 'regional', bucket_region, 'public', bucket_region)
25
25
  raise "no such region: #{bucket_region}" if endpoint.nil?
26
26
  return {
@@ -48,7 +48,8 @@ module Aspera
48
48
  grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
49
49
  response_type: 'cloud_iam',
50
50
  apikey: @api_key
51
- })
51
+ }
52
+ )
52
53
  # read FASP connection information for bucket
53
54
  xml_result_text = s3_api.call(
54
55
  operation: 'GET',
@@ -57,7 +58,7 @@ module Aspera
57
58
  query: {'faspConnectionInfo' => nil}
58
59
  )[:http].body
59
60
  ats_info = XmlSimple.xml_in(xml_result_text, {'ForceArray' => false})
60
- Aspera::Log.dump('ats_info', ats_info)
61
+ Log.dump(:ats_info, ats_info)
61
62
  @storage_credentials = {
62
63
  'type' => 'token',
63
64
  'token' => {TOKEN_FIELD => nil}
@@ -67,7 +68,8 @@ module Aspera
67
68
  auth: {
68
69
  type: :basic,
69
70
  username: ats_info['AccessKey']['Id'],
70
- password: ats_info['AccessKey']['Secret']},
71
+ password: ats_info['AccessKey']['Secret']
72
+ },
71
73
  add_tspec: {'tags'=>{Transfer::Spec::TAG_RESERVED=>{'node'=>{'storage_credentials'=>@storage_credentials}}}}
72
74
  )
73
75
  # update storage_credentials AND Rest params
@@ -67,9 +67,7 @@ module Aspera
67
67
  @shared_info[:read_exception].nil? &&
68
68
  (((@shared_info[:count][:sent_general] - @shared_info[:count][:received_general]) > 1) ||
69
69
  ((@shared_info[:count][:received_v2_delimiter] - @shared_info[:count][:sent_v2_delimiter]) > 1))
70
- if !@shared_info[:cond_var].wait(@shared_info[:mutex], 2.0)
71
- Log.log.trace1{"#{LOG_WS_SEND}#{'timeout'.blue}: #{@shared_info[:count]}"}
72
- end
70
+ Log.log.trace1{"#{LOG_WS_SEND}#{'timeout'.blue}: #{@shared_info[:count]}"} if !@shared_info[:cond_var].wait(@shared_info[:mutex], 2.0)
73
71
  end
74
72
  end
75
73
  end
@@ -110,7 +108,7 @@ module Aspera
110
108
  Log.log.debug{"#{LOG_WS_RECV}read thread started"}
111
109
  frame_parser = ::WebSocket::Frame::Incoming::Client.new(version: @ws_handshake.version)
112
110
  until @ws_io.eof?
113
- begin # rubocop:disable Style/RedundantBegin
111
+ begin
114
112
  # ready byte by byte until frame is ready
115
113
  # blocking read
116
114
  byte = @ws_io.read(1)
@@ -138,16 +136,16 @@ module Aspera
138
136
  def upload(transfer_spec)
139
137
  # identify this session uniquely
140
138
  session_id = SecureRandom.uuid
141
- @notify_cb&.call(:pre_start, session_id: nil, info: 'starting')
139
+ @notify_cb&.call(:sessions_init, info: 'starting')
142
140
  # process files to send, modify `paths` in transfer_spec
143
141
  files_to_send = process_upload_list(transfer_spec)
144
142
  # total size of all files is last element
145
143
  total_bytes_to_transfer = files_to_send.pop
146
- Log.log.trace1{Log.dump(:modified_tspec, transfer_spec)}
147
- Log.log.trace1{Log.dump(:files_to_send, files_to_send)}
144
+ Log.dump(:modified_tspec, transfer_spec, level: :trace1)
145
+ Log.dump(:files_to_send, files_to_send, level: :trace1)
148
146
  # TODO: check that this is available in endpoints: @api_info['endpoints']
149
147
  upload_url = File.join(@gw_root_url, @upload_version, 'upload')
150
- @notify_cb&.call(:pre_start, session_id: nil, info: 'connecting wss')
148
+ @notify_cb&.call(:sessions_init, info: 'connecting wss')
151
149
  # open web socket to end point (equivalent to Net::HTTP.start)
152
150
  http_session = Rest.start_http_session(upload_url)
153
151
  # get the underlying socket i/o
@@ -233,7 +231,8 @@ module Aspera
233
231
  end
234
232
  # throttling may have skipped last one
235
233
  @notify_cb&.call(:transfer, session_id: session_id, info: session_sent_bytes)
236
- @notify_cb&.call(:end, session_id: session_id)
234
+ @notify_cb&.call(:session_end, session_id: session_id)
235
+ @notify_cb&.call(:end)
237
236
  ws_send(ws_type: :close, data: nil)
238
237
  Log.log.debug("Finished upload, waiting for end of #{THR_RECV} thread.")
239
238
  @ws_read_thread.join
@@ -254,9 +253,7 @@ module Aspera
254
253
  # by default it is the name of first file
255
254
  download_name = File.basename(default_file_name, '.*')
256
255
  # add indication of number of files if there is more than one
257
- if transfer_spec['paths'].length > 1
258
- download_name += " #{transfer_spec['paths'].length} Files"
259
- end
256
+ download_name += " #{transfer_spec['paths'].length} Files" if transfer_spec['paths'].length > 1
260
257
  transfer_spec['download_name'] = download_name
261
258
  end
262
259
  # start transfer session on httpgw
@@ -265,7 +262,7 @@ module Aspera
265
262
  file_name =
266
263
  if transfer_spec['zip_required'] || transfer_spec['paths'].length > 1
267
264
  # it is a zip file if zip is required or there is more than 1 file
268
- transfer_spec['download_name'] + '.zip'
265
+ "#{transfer_spec['download_name']}.zip"
269
266
  else
270
267
  # it is a plain file if we don't require zip and there is only one file
271
268
  File.basename(default_file_name)
@@ -292,13 +289,13 @@ module Aspera
292
289
  notify_cb: nil,
293
290
  **opts
294
291
  )
295
- Log.log.debug{Log.dump(:gw_url, url)}
292
+ Log.dump(:gw_url, url)
296
293
  # add scheme if missing
297
294
  url = "https://#{url}" unless url.match?(%r{^[a-z]{1,6}://})
298
- raise 'GW URL shall be with scheme https' unless url.start_with?('https://')
295
+ raise Error, 'GW URL shall be with scheme https' unless url.start_with?('https://')
299
296
  # remove trailing slash and version (o=only once) if present
300
297
  # TODO: issue warning ?
301
- url = url.gsub(%r{/+$}, '').gsub(%r{/#{API_V1}$}o, '')
298
+ url = url.chomp('/').gsub(%r{/#{API_V1}$}o, '')
302
299
  # assume GW is always under specific path (TODO: remove this ?)
303
300
  url = File.join(url, DEFAULT_BASE_PATH) unless url.end_with?(DEFAULT_BASE_PATH)
304
301
  @gw_root_url = url
@@ -309,7 +306,7 @@ module Aspera
309
306
  @notify_cb = notify_cb
310
307
  # get API info
311
308
  @api_info = read('info').freeze
312
- Log.log.debug{Log.dump(:api_info, @api_info)}
309
+ Log.dump(:api_info, @api_info)
313
310
  # web socket endpoint: by default use v2 (newer gateways), without base64 encoding
314
311
  # is the latest supported? else revert to old api
315
312
  if !@upload_version.eql?(API_V1)
@@ -332,7 +329,7 @@ module Aspera
332
329
  # @return [Array] info on files to send
333
330
  def process_upload_list(transfer_spec)
334
331
  total_bytes_to_transfer = 0
335
- source_prefix = transfer_spec.key?('source_root') && !transfer_spec['source_root'].empty? ? transfer_spec['source_root'] + '/' : ''
332
+ source_prefix = transfer_spec.key?('source_root') && !transfer_spec['source_root'].empty? ? "#{transfer_spec['source_root']}/" : ''
336
333
  files_to_send = []
337
334
  transfer_spec['paths'].each do |one_path|
338
335
  source_path = source_prefix + one_path['source']
@@ -9,6 +9,9 @@ require 'aspera/assert'
9
9
  require 'aspera/environment'
10
10
  require 'zlib'
11
11
  require 'base64'
12
+ require 'openssl'
13
+ require 'pathname'
14
+ require 'net/ssh/buffer'
12
15
 
13
16
  module Aspera
14
17
  module Api
@@ -35,6 +38,7 @@ module Aspera
35
38
  HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
36
39
  SCOPE_USER = 'user:all'
37
40
  SCOPE_ADMIN = 'admin:all'
41
+ # / in cloud
38
42
  PATH_SEPARATOR = '/'
39
43
 
40
44
  # register node special token decoder
@@ -49,6 +53,35 @@ module Aspera
49
53
  attr_accessor :use_standard_ports
50
54
  # set to false to bypass cache in redis
51
55
  attr_accessor :use_node_cache
56
+ attr_reader :use_dynamic_key
57
+
58
+ # set private key to be used
59
+ # @param pem_content [String] PEM encoded private key
60
+ def use_dynamic_key=(pem_content)
61
+ Aspera.assert_type(pem_content, String)
62
+ @dynamic_key = OpenSSL::PKey.read(pem_content)
63
+ end
64
+
65
+ # Adds fields `public_keys` in provided Hash, if dynamic key is set.
66
+ # @param h [Hash] Hash to add public key to
67
+ def add_public_key(h)
68
+ if @dynamic_key
69
+ ssh_key = Net::SSH::Buffer.from(:key, @dynamic_key)
70
+ # get pub key in OpenSSH public key format (authorized_keys)
71
+ h['public_keys'] = [
72
+ ssh_key.read_string,
73
+ Base64.strict_encode64(ssh_key.to_s)
74
+ ].join(' ')
75
+ end
76
+ return h
77
+ end
78
+
79
+ # Adds fields `ssh_private_key` in provided Hash, if dynamic key is set.
80
+ # @param h [Hash] Hash to add private key to
81
+ def add_private_key(h)
82
+ h['ssh_private_key'] = @dynamic_key.to_pem if @dynamic_key
83
+ return h
84
+ end
52
85
 
53
86
  # For access keys: provide expression to match entry in folder
54
87
  # @param match_expression one of supported types
@@ -60,7 +93,7 @@ module Aspera
60
93
  when String
61
94
  return ->(f){File.fnmatch(match_expression, f['name'], File::FNM_DOTMATCH)}
62
95
  when NilClass then return ->(_){true}
63
- else Aspera.error_unexpected_value(match_expression.class.name, exception_class: Cli::BadArgument)
96
+ else Aspera.error_unexpected_value(match_expression.class.name, type: Cli::BadArgument)
64
97
  end
65
98
  end
66
99
 
@@ -68,6 +101,13 @@ module Aspera
68
101
  return file_matcher(options.get_next_argument('filter', validation: MATCH_TYPES, mandatory: false))
69
102
  end
70
103
 
104
+ # @return [Array] containing folder + inside folder/file
105
+ def split_folder(path)
106
+ folder = path.split(PATH_SEPARATOR)
107
+ inside = folder.pop
108
+ [folder.join(PATH_SEPARATOR), inside]
109
+ end
110
+
71
111
  # node API scopes
72
112
  def token_scope(access_key, scope)
73
113
  return [SCOPE_NODE_PREFIX, access_key, SCOPE_SEPARATOR, scope].join('')
@@ -115,7 +155,7 @@ module Aspera
115
155
  def bearer_headers(bearer_auth, access_key: nil)
116
156
  # if username is not provided, use the access key from the token
117
157
  if access_key.nil?
118
- access_key = Node.decode_scope(Node.decode_bearer_token(OAuth::Factory.bearer_extract(bearer_auth))['scope'])[:access_key]
158
+ access_key = Node.decode_scope(Node.decode_bearer_token(OAuth::Factory.bearer_token(bearer_auth))['scope'])[:access_key]
119
159
  Aspera.assert(!access_key.nil?)
120
160
  end
121
161
  return {
@@ -135,6 +175,7 @@ module Aspera
135
175
  def initialize(app_info: nil, add_tspec: nil, **rest_args)
136
176
  # init Rest
137
177
  super(**rest_args)
178
+ @dynamic_key = nil
138
179
  @app_info = app_info
139
180
  # this is added to transfer spec, for instance to add tags (COS)
140
181
  @add_tspec = add_tspec
@@ -150,14 +191,15 @@ module Aspera
150
191
  end
151
192
 
152
193
  # Call node API, possibly adding cache control header, as globally specified
153
- def read_with_cache(subpath, query=nil)
194
+ def read_with_cache(subpath, query = nil)
154
195
  headers = {'Accept' => Rest::MIME_JSON}
155
196
  headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless self.class.use_node_cache
156
197
  return call(
157
198
  operation: 'GET',
158
199
  subpath: subpath,
159
200
  headers: headers,
160
- query: query)[:data]
201
+ query: query
202
+ )[:data]
161
203
  end
162
204
 
163
205
  # update transfer spec with special additional tags
@@ -173,10 +215,11 @@ module Aspera
173
215
  return @app_info[:api].node_api_from(
174
216
  node_id: node_id,
175
217
  workspace_id: @app_info[:workspace_id],
176
- workspace_name: @app_info[:workspace_name])
218
+ workspace_name: @app_info[:workspace_name]
219
+ )
177
220
  end
178
221
  Log.log.warn{"Cannot resolve link with node id #{node_id}, no resolver"}
179
- return nil
222
+ return
180
223
  end
181
224
 
182
225
  # Check if a link entry in folder has target information
@@ -201,12 +244,12 @@ module Aspera
201
244
  # @param state [Object] state object sent to processing method
202
245
  # @param top_file_id [String] file id to start at (default = access key root file id)
203
246
  # @param top_file_path [String] path of top folder (default = /)
204
- def process_folder_tree(method_sym:, state:, top_file_id:, top_file_path: '/', query: nil)
247
+ def process_folder_tree(method_sym:, state:, top_file_id:, top_file_path: '/')
205
248
  Aspera.assert(!top_file_path.nil?){'top_file_path not set'}
206
249
  Log.log.debug{"process_folder_tree: node=#{@app_info ? @app_info[:node_info]['id'] : 'nil'}, file id=#{top_file_id}, path=#{top_file_path}"}
207
250
  # start at top folder
208
251
  folders_to_explore = [{id: top_file_id, path: top_file_path}]
209
- Log.log.debug{Log.dump(:folders_to_explore, folders_to_explore)}
252
+ Log.dump(:folders_to_explore, folders_to_explore)
210
253
  until folders_to_explore.empty?
211
254
  # consume first in job list
212
255
  current_item = folders_to_explore.shift
@@ -220,12 +263,10 @@ module Aspera
220
263
  Log.log.warn{"#{current_item[:path]}: #{e.class} #{e.message}"}
221
264
  []
222
265
  end
223
- Log.log.debug{Log.dump(:folder_contents, folder_contents)}
266
+ Log.dump(:folder_contents, folder_contents)
224
267
  folder_contents.each do |entry|
225
268
  if entry.key?('error')
226
- if entry['error'].is_a?(Hash) && entry['error'].key?('user_message')
227
- Log.log.error(entry['error']['user_message'])
228
- end
269
+ Log.log.error(entry['error']['user_message']) if entry['error'].is_a?(Hash) && entry['error'].key?('user_message')
229
270
  next
230
271
  end
231
272
  current_path = File.join(current_item[:path], entry['name'])
@@ -242,7 +283,8 @@ module Aspera
242
283
  method_sym: method_sym,
243
284
  state: state,
244
285
  top_file_id: entry['target_id'],
245
- top_file_path: current_path)
286
+ top_file_path: current_path
287
+ )
246
288
  end
247
289
  end
248
290
  end
@@ -255,7 +297,7 @@ module Aspera
255
297
  # @param path [String] file or folder path (end with "/" is like setting process_last_link)
256
298
  # @param process_last_link [Boolean] if true, follow the last link
257
299
  # @return [Hash] {.api,.file_id}
258
- def resolve_api_fid(top_file_id, path, process_last_link=false)
300
+ def resolve_api_fid(top_file_id, path, process_last_link = false)
259
301
  Aspera.assert_type(top_file_id, String)
260
302
  Aspera.assert_type(path, String)
261
303
  process_last_link ||= path.end_with?(PATH_SEPARATOR)
@@ -268,6 +310,50 @@ module Aspera
268
310
  return resolve_state[:result]
269
311
  end
270
312
 
313
+ # Given a list of paths, finds a common root and list of sub-paths
314
+ # @param top_file_id [String] Root file id
315
+ # @param paths [Array(Hash)] List of paths
316
+ # @return [Array] size=2: apfid, paths (Array(Hash))
317
+ def resolve_api_fid_paths(top_file_id, paths)
318
+ Aspera.assert_type(paths, Array)
319
+ Aspera.assert(paths.size.positive?)
320
+ split_sources = paths.map{ |p| Pathname(p['source']).each_filename.to_a}
321
+ root = []
322
+ split_sources.map(&:size).min.times do |i|
323
+ parts = split_sources.map{ |s| s[i]}
324
+ break unless parts.uniq.size == 1
325
+ root << parts.first
326
+ end
327
+ source_folder = File.join(root)
328
+ source_paths = paths.each_with_index.map do |p, i|
329
+ m = {'source' => File.join(split_sources[i][root.size..])}
330
+ m['destination'] = p['destination'] if p.key?('destination')
331
+ m
332
+ end
333
+ apifid = resolve_api_fid(top_file_id, source_folder, true)
334
+ # If a single item
335
+ if source_paths.size.eql?(1)
336
+ # Get precise info in this element
337
+ file_info = apifid[:api].read("files/#{apifid[:file_id]}")
338
+ source_paths =
339
+ case file_info['type']
340
+ when 'file'
341
+ # if the single source is a file, we need to split into folder path and filename
342
+ src_dir_elements = source_folder.split(Api::Node::PATH_SEPARATOR)
343
+ filename = src_dir_elements.pop
344
+ apifid = resolve_api_fid(top_file_id, src_dir_elements.join(Api::Node::PATH_SEPARATOR), true)
345
+ # filename is the last one, source folder is what remains
346
+ [{'source' => filename}]
347
+ when 'link', 'folder'
348
+ # single source is 'folder' or 'link'
349
+ # TODO: add this ? , 'destination'=>file_info['name']
350
+ [{'source' => '.'}]
351
+ else Aspera.error_unexpected_value(file_info['type']){'source type'}
352
+ end
353
+ end
354
+ [apifid, source_paths]
355
+ end
356
+
271
357
  def find_files(top_file_id, test_lambda)
272
358
  Log.log.debug{"find_files: file id=#{top_file_id}"}
273
359
  find_state = {found: [], test_lambda: test_lambda}
@@ -305,7 +391,7 @@ module Aspera
305
391
  # @param file_id destination or source folder (id)
306
392
  # @param direction one of Transfer::Spec::DIRECTION_SEND, Transfer::Spec::DIRECTION_RECEIVE
307
393
  # @param ts_merge additional transfer spec to merge
308
- def transfer_spec_gen4(file_id, direction, ts_merge=nil)
394
+ def transfer_spec_gen4(file_id, direction, ts_merge = nil)
309
395
  ak_name = nil
310
396
  ak_token = nil
311
397
  case auth_params[:type]
@@ -347,9 +433,7 @@ module Aspera
347
433
  # by default: same address as node API
348
434
  transfer_spec['remote_host'] = URI.parse(base_url).host
349
435
  # AoC allows specification of other url
350
- if !@app_info.nil? && !@app_info[:node_info]['transfer_url'].nil? && !@app_info[:node_info]['transfer_url'].empty?
351
- transfer_spec['remote_host'] = @app_info[:node_info]['transfer_url']
352
- end
436
+ transfer_spec['remote_host'] = @app_info[:node_info]['transfer_url'] if !@app_info.nil? && !@app_info[:node_info]['transfer_url'].nil? && !@app_info[:node_info]['transfer_url'].empty?
353
437
  info = read('info')
354
438
  # get the transfer user from info on access key
355
439
  transfer_spec['remote_user'] = info['transfer_user'] if info['transfer_user']
@@ -395,10 +479,8 @@ module Aspera
395
479
  if state[:process_last_link]
396
480
  # we found it
397
481
  other_node = nil
398
- if entry_has_link_information(entry)
399
- other_node = node_id_to_node(entry['target_node_id'])
400
- end
401
- raise 'Cannot resolve link' if other_node.nil?
482
+ other_node = node_id_to_node(entry['target_node_id']) if entry_has_link_information(entry)
483
+ raise Error, 'Cannot resolve link' if other_node.nil?
402
484
  state[:result] = {api: other_node, file_id: entry['target_id']}
403
485
  else
404
486
  # we found it but we do not process the link