aspera-cli 4.14.0 → 4.16.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 (104) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/BUGS.md +29 -3
  4. data/CHANGELOG.md +300 -185
  5. data/CONTRIBUTING.md +74 -23
  6. data/README.md +2346 -1619
  7. data/bin/ascli +16 -25
  8. data/bin/asession +15 -15
  9. data/examples/dascli +2 -2
  10. data/examples/proxy.pac +1 -1
  11. data/lib/aspera/aoc.rb +216 -150
  12. data/lib/aspera/ascmd.rb +25 -18
  13. data/lib/aspera/assert.rb +45 -0
  14. data/lib/aspera/cli/basic_auth_plugin.rb +9 -6
  15. data/lib/aspera/cli/error.rb +17 -0
  16. data/lib/aspera/cli/extended_value.rb +51 -16
  17. data/lib/aspera/cli/formatter.rb +276 -174
  18. data/lib/aspera/cli/hints.rb +81 -0
  19. data/lib/aspera/cli/main.rb +114 -147
  20. data/lib/aspera/cli/manager.rb +181 -136
  21. data/lib/aspera/cli/plugin.rb +82 -64
  22. data/lib/aspera/cli/plugins/alee.rb +0 -1
  23. data/lib/aspera/cli/plugins/aoc.rb +327 -331
  24. data/lib/aspera/cli/plugins/ats.rb +12 -8
  25. data/lib/aspera/cli/plugins/bss.rb +2 -2
  26. data/lib/aspera/cli/plugins/config.rb +575 -439
  27. data/lib/aspera/cli/plugins/console.rb +40 -0
  28. data/lib/aspera/cli/plugins/cos.rb +4 -5
  29. data/lib/aspera/cli/plugins/faspex.rb +111 -92
  30. data/lib/aspera/cli/plugins/faspex5.rb +245 -182
  31. data/lib/aspera/cli/plugins/node.rb +239 -160
  32. data/lib/aspera/cli/plugins/orchestrator.rb +56 -19
  33. data/lib/aspera/cli/plugins/preview.rb +54 -38
  34. data/lib/aspera/cli/plugins/server.rb +63 -20
  35. data/lib/aspera/cli/plugins/shares.rb +64 -38
  36. data/lib/aspera/cli/sync_actions.rb +68 -0
  37. data/lib/aspera/cli/transfer_agent.rb +64 -67
  38. data/lib/aspera/cli/transfer_progress.rb +73 -0
  39. data/lib/aspera/cli/version.rb +1 -1
  40. data/lib/aspera/colors.rb +3 -1
  41. data/lib/aspera/command_line_builder.rb +27 -22
  42. data/lib/aspera/cos_node.rb +6 -4
  43. data/lib/aspera/coverage.rb +22 -0
  44. data/lib/aspera/data_repository.rb +33 -2
  45. data/lib/aspera/environment.rb +21 -8
  46. data/lib/aspera/fasp/agent_alpha.rb +116 -0
  47. data/lib/aspera/fasp/agent_base.rb +40 -76
  48. data/lib/aspera/fasp/agent_connect.rb +21 -22
  49. data/lib/aspera/fasp/agent_direct.rb +169 -179
  50. data/lib/aspera/fasp/agent_httpgw.rb +200 -195
  51. data/lib/aspera/fasp/agent_node.rb +43 -35
  52. data/lib/aspera/fasp/agent_trsdk.rb +124 -41
  53. data/lib/aspera/fasp/error_info.rb +2 -2
  54. data/lib/aspera/fasp/faux_file.rb +52 -0
  55. data/lib/aspera/fasp/installation.rb +89 -191
  56. data/lib/aspera/fasp/management.rb +249 -0
  57. data/lib/aspera/fasp/parameters.rb +86 -47
  58. data/lib/aspera/fasp/parameters.yaml +75 -8
  59. data/lib/aspera/fasp/products.rb +162 -0
  60. data/lib/aspera/fasp/resume_policy.rb +7 -5
  61. data/lib/aspera/fasp/sync.rb +273 -0
  62. data/lib/aspera/fasp/transfer_spec.rb +10 -8
  63. data/lib/aspera/fasp/uri.rb +6 -6
  64. data/lib/aspera/faspex_gw.rb +11 -8
  65. data/lib/aspera/faspex_postproc.rb +8 -7
  66. data/lib/aspera/hash_ext.rb +2 -2
  67. data/lib/aspera/id_generator.rb +3 -1
  68. data/lib/aspera/json_rpc.rb +51 -0
  69. data/lib/aspera/keychain/encrypted_hash.rb +46 -11
  70. data/lib/aspera/keychain/macos_security.rb +15 -13
  71. data/lib/aspera/line_logger.rb +23 -0
  72. data/lib/aspera/log.rb +61 -19
  73. data/lib/aspera/nagios.rb +7 -2
  74. data/lib/aspera/node.rb +105 -21
  75. data/lib/aspera/node_simulator.rb +214 -0
  76. data/lib/aspera/oauth.rb +57 -36
  77. data/lib/aspera/open_application.rb +4 -4
  78. data/lib/aspera/persistency_action_once.rb +13 -14
  79. data/lib/aspera/persistency_folder.rb +5 -4
  80. data/lib/aspera/preview/file_types.rb +56 -268
  81. data/lib/aspera/preview/generator.rb +28 -39
  82. data/lib/aspera/preview/options.rb +2 -0
  83. data/lib/aspera/preview/terminal.rb +36 -16
  84. data/lib/aspera/preview/utils.rb +23 -29
  85. data/lib/aspera/proxy_auto_config.rb +6 -3
  86. data/lib/aspera/rest.rb +127 -80
  87. data/lib/aspera/rest_call_error.rb +1 -1
  88. data/lib/aspera/rest_error_analyzer.rb +16 -14
  89. data/lib/aspera/rest_errors_aspera.rb +39 -34
  90. data/lib/aspera/secret_hider.rb +18 -17
  91. data/lib/aspera/ssh.rb +10 -5
  92. data/lib/aspera/temp_file_manager.rb +11 -4
  93. data/lib/aspera/web_auth.rb +10 -7
  94. data/lib/aspera/web_server_simple.rb +11 -5
  95. data.tar.gz.sig +0 -0
  96. metadata +108 -39
  97. metadata.gz.sig +0 -0
  98. data/lib/aspera/cli/listener/line_dump.rb +0 -19
  99. data/lib/aspera/cli/listener/logger.rb +0 -22
  100. data/lib/aspera/cli/listener/progress.rb +0 -50
  101. data/lib/aspera/cli/listener/progress_multi.rb +0 -84
  102. data/lib/aspera/cli/plugins/sync.rb +0 -44
  103. data/lib/aspera/fasp/listener.rb +0 -13
  104. data/lib/aspera/sync.rb +0 -213
@@ -1,92 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # cspell:ignore snid fnid bidi ssync asyncs rund asnodeadmin mkfile mklink asperabrowser asperabrowserurl watchfolders watchfolderd entsrv
3
4
  require 'aspera/cli/basic_auth_plugin'
4
- require 'aspera/cli/plugins/sync'
5
+ require 'aspera/cli/sync_actions'
6
+ require 'aspera/fasp/transfer_spec'
5
7
  require 'aspera/nagios'
6
8
  require 'aspera/hash_ext'
7
9
  require 'aspera/id_generator'
8
10
  require 'aspera/node'
9
11
  require 'aspera/aoc'
10
- require 'aspera/sync'
11
- require 'aspera/fasp/transfer_spec'
12
+ require 'aspera/oauth'
13
+ require 'aspera/node_simulator'
14
+ require 'aspera/assert'
12
15
  require 'base64'
13
16
  require 'zlib'
14
17
 
15
18
  module Aspera
16
19
  module Cli
17
20
  module Plugins
18
- class SyncSpecGen3
19
- def initialize(api_node)
20
- @api_node = api_node
21
- end
22
-
23
- def transfer_spec(direction, local_path, remote_path)
24
- # empty transfer spec for authorization request
25
- direction_sym = direction.to_sym
26
- request_transfer_spec = {
27
- type: Aspera::Sync::DIRECTION_TO_REQUEST_TYPE[direction_sym],
28
- paths: {
29
- source: remote_path,
30
- destination: local_path
31
- }
32
- }
33
- # add fixed parameters if any (for COS)
34
- @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
35
- # prepare payload for single request
36
- setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
37
- # only one request, so only one answer
38
- transfer_spec = @api_node.create('files/sync_setup', setup_payload)[:data]['transfer_specs'].first['transfer_spec']
39
- # API returns null tag... but async does not like it
40
- transfer_spec.delete_if{ |_k, v| v.nil? }
41
- # delete this part, as the returned value contains only destination, and not sources
42
- # transfer_spec.delete('paths') if command.eql?(:upload)
43
- Log.dump(:ts, transfer_spec)
44
- return transfer_spec
45
- end
46
- end
47
-
48
- class SyncSpecGen4
49
- def initialize(api_node, top_file_id)
50
- @api_node = api_node
51
- @top_file_id = top_file_id
52
- end
53
-
54
- def transfer_spec(direction, local_path, remote_path)
55
- # remote is specified by option to_folder
56
- apifid = @api_node.resolve_api_fid(@top_file_id, remote_path)
57
- transfer_spec = apifid[:api].transfer_spec_gen4(apifid[:file_id], Fasp::TransferSpec::DIRECTION_SEND)
58
- Log.dump(:ts, transfer_spec)
59
- return transfer_spec
60
- end
61
- end
62
-
63
21
  class Node < Aspera::Cli::BasicAuthPlugin
22
+ include SyncActions
64
23
  class << self
65
- def detect(base_url)
66
- api = Rest.new({ base_url: base_url})
67
- result = api.call({ operation: 'GET', subpath: 'ping'})
68
- if result[:http].body.eql?('')
69
- return { product: :node, version: 'unknown'}
24
+ @@node_options_declared = false # rubocop:disable Style/ClassVars
25
+ def application_name
26
+ 'HSTS Node API'
27
+ end
28
+
29
+ def detect(address_or_url)
30
+ urls = if address_or_url.match?(%r{^[a-z]{1,6}://})
31
+ [address_or_url]
32
+ else
33
+ [
34
+ "https://#{address_or_url}",
35
+ "https://#{address_or_url}:9092",
36
+ "http://#{address_or_url}:9091"
37
+ ]
38
+ end
39
+
40
+ urls.each do |base_url|
41
+ next unless base_url.match?('https?://')
42
+ api = Rest.new(base_url: base_url)
43
+ test_endpoint = 'ping'
44
+ result = api.call(operation: 'GET', subpath: test_endpoint)
45
+ next unless result[:http].body.eql?('')
46
+ url_length = -2 - test_endpoint.length
47
+ return {
48
+ url: result[:http].uri.to_s[0..url_length]
49
+ }
50
+ rescue StandardError => e
51
+ Log.log.debug{"detect error: #{e}"}
70
52
  end
71
53
  return nil
72
54
  end
73
55
 
74
- def register_node_options(env)
75
- env[:options].declare(:validator, 'Identifier of validator (optional for central)')
76
- env[:options].declare(:asperabrowserurl, 'URL for simple aspera web ui', default: 'https://asperabrowser.mybluemix.net')
77
- env[:options].declare(:sync_name, 'Sync name')
78
- env[:options].declare(:default_ports, 'Use standard FASP ports or get from node api (gen4)', values: :bool, default: :yes)
79
- env[:options].parse_options!
80
- Aspera::Node.use_standard_ports = env[:options].get_option(:default_ports)
56
+ def wizard(object:, private_key_path: nil, pub_key_pem: nil)
57
+ options = object.options
58
+ return {
59
+ preset_value: {
60
+ url: options.get_option(:url, mandatory: true),
61
+ username: options.get_option(:username, mandatory: true),
62
+ password: options.get_option(:password, mandatory: true)
63
+ },
64
+ test_args: 'info'
65
+ }
66
+ end
67
+
68
+ def declare_options(options, force: false)
69
+ return if @@node_options_declared && !force
70
+ @@node_options_declared = true # rubocop:disable Style/ClassVars
71
+ options.declare(:validator, 'Identifier of validator (optional for central)')
72
+ options.declare(:asperabrowserurl, 'URL for simple aspera web ui', default: 'https://asperabrowser.mybluemix.net')
73
+ options.declare(:sync_name, 'Sync name')
74
+ options.declare(
75
+ :default_ports, 'Use standard FASP ports or get from node api (gen4)', values: :bool, default: :yes,
76
+ handler: {o: Aspera::Node, m: :use_standard_ports})
77
+ options.declare(:root_id, 'File id of top folder if using bearer tokens')
78
+ SyncActions.declare_options(options)
79
+ options.parse_options!
81
80
  end
82
81
  end
83
82
 
84
83
  # spellchecker: disable
85
84
  # SOAP API call to test central API
86
- CENTRAL_SOAP_API_TEST = '<?xml version="1.0" encoding="UTF-8"?>'\
87
- '<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:typ="urn:Aspera:XML:FASPSessionNET:2009/11:Types">'\
88
- '<soapenv:Header></soapenv:Header>'\
89
- '<soapenv:Body><typ:GetSessionInfoRequest><SessionFilter><SessionStatus>running</SessionStatus></SessionFilter></typ:GetSessionInfoRequest></soapenv:Body>'\
85
+ CENTRAL_SOAP_API_TEST = '<?xml version="1.0" encoding="UTF-8"?>' \
86
+ '<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:typ="urn:Aspera:XML:FASPSessionNET:2009/11:Types">' \
87
+ '<soapenv:Header></soapenv:Header>' \
88
+ '<soapenv:Body><typ:GetSessionInfoRequest><SessionFilter><SessionStatus>running</SessionStatus></SessionFilter></typ:GetSessionInfoRequest></soapenv:Body>' \
90
89
  '</soapenv:Envelope>'
91
90
  # spellchecker: enable
92
91
 
@@ -94,17 +93,17 @@ module Aspera
94
93
  SEARCH_REMOVE_FIELDS = %w[basename permissions].freeze
95
94
 
96
95
  # actions in execute_command_gen3
97
- COMMANDS_GEN3 = %i[search space mkdir mklink mkfile rename delete browse upload download sync]
96
+ COMMANDS_GEN3 = %i[search space mkdir mklink mkfile rename delete browse upload download http_node_download sync]
98
97
 
99
98
  BASE_ACTIONS = %i[api_details].concat(COMMANDS_GEN3).freeze
100
99
 
101
- SPECIAL_ACTIONS = %i[health events info license].freeze
100
+ SPECIAL_ACTIONS = %i[health events info slash license].freeze
102
101
 
103
102
  # actions available in v3 in gen4
104
- V3_IN_V4_ACTIONS = %i[access_key].concat(BASE_ACTIONS).concat(SPECIAL_ACTIONS).freeze
103
+ V3_IN_V4_ACTIONS = %i[access_keys].concat(BASE_ACTIONS).concat(SPECIAL_ACTIONS).freeze
105
104
 
106
105
  # actions used commonly when a node is involved
107
- COMMON_ACTIONS = %i[access_key].concat(BASE_ACTIONS).concat(SPECIAL_ACTIONS).freeze
106
+ COMMON_ACTIONS = %i[access_keys].concat(BASE_ACTIONS).concat(SPECIAL_ACTIONS).freeze
108
107
 
109
108
  private_constant(*%i[CENTRAL_SOAP_API_TEST SEARCH_REMOVE_FIELDS BASE_ACTIONS SPECIAL_ACTIONS V3_IN_V4_ACTIONS COMMON_ACTIONS])
110
109
 
@@ -114,26 +113,22 @@ module Aspera
114
113
  # commands for execute_command_gen4
115
114
  COMMANDS_GEN4 = %i[mkdir rename delete upload download sync http_node_download show modify permission thumbnail v3].concat(NODE4_READ_ACTIONS).freeze
116
115
 
117
- COMMANDS_COS = %i[upload download info access_key api_details transfer].freeze
116
+ COMMANDS_COS = %i[upload download info access_keys api_details transfer].freeze
118
117
  COMMANDS_SHARES = (BASE_ACTIONS - %i[search]).freeze
119
118
  COMMANDS_FASPEX = COMMON_ACTIONS
120
119
 
121
- def initialize(env)
120
+ def initialize(env, api: nil)
122
121
  super(env)
123
- self.class.register_node_options(env) unless env[:skip_node_options]
124
- return if env[:man_only]
122
+ Node.declare_options(options, force: env[:all_manuals])
125
123
  @api_node =
126
- if env.key?(:node_api)
124
+ if !api.nil? || env[:all_manuals]
127
125
  # this can be Aspera::Node or Aspera::Rest (shares)
128
- env[:node_api]
129
- elsif options.get_option(:password, mandatory: true).start_with?('Bearer ')
126
+ api
127
+ elsif Oauth.bearer?(options.get_option(:password, mandatory: true))
130
128
  # info is provided like node_info of aoc
131
129
  Aspera::Node.new(params: {
132
130
  base_url: options.get_option(:url, mandatory: true),
133
- headers: {
134
- Aspera::Node::HEADER_X_ASPERA_ACCESS_KEY => options.get_option(:username, mandatory: true),
135
- 'Authorization' => options.get_option(:password, mandatory: true)
136
- }
131
+ headers: Aspera::Node.bearer_headers(options.get_option(:password, mandatory: true))
137
132
  })
138
133
  else
139
134
  # this is normal case
@@ -147,26 +142,6 @@ module Aspera
147
142
  end
148
143
  end
149
144
 
150
- def c_textify_browse(table_data)
151
- return table_data.map do |i|
152
- i['permissions'] = i['permissions'].map { |x| x['name'] }.join(',')
153
- i
154
- end
155
- end
156
-
157
- # key/value is defined in main in hash_table
158
- def c_textify_bool_list_result(list, name_list)
159
- list.each_index do |i|
160
- next unless name_list.include?(list[i]['key'])
161
- list[i]['value'].each do |item|
162
- list.push({'key' => item['name'], 'value' => item['value']})
163
- end
164
- list.delete_at(i)
165
- # continue at same index because we delete current one
166
- redo
167
- end
168
- end
169
-
170
145
  # reduce the path from a result on given named column
171
146
  def c_result_remove_prefix_path(result, column, path_prefix)
172
147
  if !path_prefix.nil?
@@ -222,7 +197,7 @@ module Aspera
222
197
  when :search
223
198
  search_root = get_next_arg_add_prefix(prefix_path, 'search root')
224
199
  parameters = {'path' => search_root}
225
- other_options = value_or_query(allowed_types: Hash)
200
+ other_options = query_option
226
201
  parameters.merge!(other_options) unless other_options.nil?
227
202
  resp = @api_node.create('files/search', parameters)
228
203
  result = { type: :object_list, data: resp[:data]['items']}
@@ -268,17 +243,40 @@ module Aspera
268
243
  # if there is no items
269
244
  case send_result['self']['type']
270
245
  when 'directory', 'container' # directory: node, container: shares
271
- result = { data: send_result['items'], type: :object_list, textify: lambda { |table_data| c_textify_browse(table_data) } }
246
+ result = { data: send_result['items'], type: :object_list }
272
247
  formatter.display_item_count(send_result['item_count'], send_result['total_count'])
273
248
  else # 'file','symbolic_link'
274
249
  result = { data: send_result['self'], type: :single_object}
275
- # result={ data: [send_result['self']] , type: :object_list, textify: lambda { |table_data| c_textify_browse(table_data) } }
276
- # raise "unknown type: #{send_result['self']['type']}"
277
250
  end
278
251
  return c_result_remove_prefix_path(result, 'path', prefix_path)
279
252
  when :sync
280
- node_sync = SyncSpecGen3.new(@api_node)
281
- return Plugins::Sync.new(@agents, sync_spec: node_sync).execute_action
253
+ return execute_sync_action do |sync_direction, local_path, remote_path|
254
+ # Gen3 API
255
+ # empty transfer spec for authorization request
256
+ request_transfer_spec = {
257
+ type: case sync_direction
258
+ when :push then :sync_upload
259
+ when :pull then :sync_download
260
+ when :bidi then :sync
261
+ end,
262
+ paths: [{
263
+ source: remote_path,
264
+ destination: local_path
265
+ }]
266
+ }
267
+ # add fixed parameters if any (for COS)
268
+ @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
269
+ # prepare payload for single request
270
+ setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
271
+ # only one request, so only one answer
272
+ transfer_spec = @api_node.create('files/sync_setup', setup_payload)[:data]['transfer_specs'].first['transfer_spec']
273
+ # API returns null tag... but async does not like it
274
+ transfer_spec.delete_if{ |_k, v| v.nil? }
275
+ # delete this part, as the returned value contains only destination, and not sources
276
+ # transfer_spec.delete('paths') if command.eql?(:upload)
277
+ Log.log.debug{Log.dump(:ts, transfer_spec)}
278
+ transfer_spec
279
+ end
282
280
  when :upload, :download
283
281
  # empty transfer spec for authorization request
284
282
  request_transfer_spec = {}
@@ -297,8 +295,16 @@ module Aspera
297
295
  # delete this part, as the returned value contains only destination, and not sources
298
296
  transfer_spec.delete('paths') if command.eql?(:upload)
299
297
  return Main.result_transfer(transfer.start(transfer_spec))
298
+ when :http_node_download
299
+ remote_path = get_next_arg_add_prefix(prefix_path, 'remote path')
300
+ file_name = File.basename(remote_path)
301
+ @api_node.call(
302
+ operation: 'GET',
303
+ subpath: "files/#{URI.encode_www_form_component(remote_path)}/contents",
304
+ save_to_file: File.join(transfer.destination_folder(Fasp::TransferSpec::DIRECTION_RECEIVE), file_name))
305
+ return Main.result_status("downloaded: #{file_name}")
300
306
  end
301
- raise 'INTERNAL ERROR'
307
+ error_unreachable_line
302
308
  end
303
309
 
304
310
  # common API to node and Shares
@@ -307,20 +313,37 @@ module Aspera
307
313
  case command
308
314
  when *COMMANDS_GEN3
309
315
  execute_command_gen3(command, prefix_path)
310
- when :access_key
311
- ak_command = options.get_next_command([:do].concat(Plugin::ALL_OPS))
316
+ when :access_keys
317
+ ak_command = options.get_next_command(%i[do set_bearer_key].concat(Plugin::ALL_OPS))
312
318
  case ak_command
313
- when *Plugin::ALL_OPS then return entity_command(ak_command, @api_node, 'access_keys', id_default: 'self')
319
+ when *Plugin::ALL_OPS
320
+ return entity_command(ak_command, @api_node, 'access_keys') do |field, value|
321
+ raise 'only selector: %id:self' unless field.eql?('id') && value.eql?('self')
322
+ @api_node.read('access_keys/self')[:data]['id']
323
+ end
314
324
  when :do
315
- access_key = options.get_next_argument('access key id')
316
- ak_info = @api_node.read("access_keys/#{access_key}")[:data]
317
- # change API credentials if different access key
318
- if !access_key.eql?('self')
319
- @api_node.params[:auth][:username] = ak_info['id']
320
- @api_node.params[:auth][:password] = config.lookup_secret(url: @api_node.params[:base_url], username: ak_info['id'], mandatory: true)
325
+ access_key_id = options.get_next_argument('access key id')
326
+ root_file_id = options.get_option(:root_id)
327
+ if root_file_id.nil?
328
+ ak_info = @api_node.read("access_keys/#{access_key_id}")[:data]
329
+ # change API credentials if different access key
330
+ if !access_key_id.eql?('self')
331
+ @api_node.params[:auth][:username] = ak_info['id']
332
+ @api_node.params[:auth][:password] = config.lookup_secret(url: @api_node.params[:base_url], username: ak_info['id'], mandatory: true)
333
+ end
334
+ root_file_id = ak_info['root_file_id']
321
335
  end
322
336
  command_repo = options.get_next_command(COMMANDS_GEN4)
323
- return execute_command_gen4(command_repo, ak_info['root_file_id'])
337
+ return execute_command_gen4(command_repo, root_file_id)
338
+ when :set_bearer_key
339
+ access_key_id = options.get_next_argument('access key id')
340
+ access_key_id = @api_node.read('access_keys/self')[:data]['id'] if access_key_id.eql?('self')
341
+ bearer_key_pem = options.get_next_argument('public or private RSA key PEM value', type: String)
342
+ key = OpenSSL::PKey.read(bearer_key_pem)
343
+ key = key.public_key if key.private?
344
+ bearer_key_pem = key.to_pem
345
+ @api_node.update("access_keys/#{access_key_id}", {token_verification_key: bearer_key_pem})
346
+ return Main.result_status('public key updated')
324
347
  end
325
348
  when :health
326
349
  nagios = Nagios.new
@@ -345,10 +368,13 @@ module Aspera
345
368
  return nagios.result
346
369
  when :events
347
370
  events = @api_node.read('events', query_read_delete)[:data]
348
- return { type: :object_list, data: events}
371
+ return { type: :object_list, data: events, fields: ->(f){!f.start_with?('data')} }
349
372
  when :info
350
373
  nd_info = @api_node.read('info')[:data]
351
- return { type: :single_object, data: nd_info, textify: lambda { |table_data| c_textify_bool_list_result(table_data, %w[capabilities settings])}}
374
+ return { type: :single_object, data: nd_info}
375
+ when :slash
376
+ nd_info = @api_node.read('')[:data]
377
+ return { type: :single_object, data: nd_info}
352
378
  when :license
353
379
  # requires: asnodeadmin -mu <node user> --acl-add=internal --internal
354
380
  node_license = @api_node.read('license')[:data]
@@ -364,7 +390,7 @@ module Aspera
364
390
  # @return [Hash] api and main file id for given path or id
365
391
  # Allows to specify a file by its path or by its id on the node
366
392
  def apifid_from_next_arg(top_file_id)
367
- file_path = instance_identifier(description: 'path or id') do |attribute, value|
393
+ file_path = instance_identifier(description: 'path or %id:<id>') do |attribute, value|
368
394
  raise 'Only selection "id" is supported (file id)' unless attribute.eql?('id')
369
395
  # directly return result for method
370
396
  return {api: @api_node, file_id: value}
@@ -380,14 +406,14 @@ module Aspera
380
406
  command_legacy = options.get_next_command(V3_IN_V4_ACTIONS)
381
407
  # TODO: shall we support all methods here ? what if there is a link ?
382
408
  apifid = @api_node.resolve_api_fid(top_file_id, '')
383
- return Node.new(@agents.merge(skip_basic_auth_options: true, skip_node_options: true, node_api: apifid[:api])).execute_action(command_legacy)
409
+ return Node.new(@agents, api: apifid[:api]).execute_action(command_legacy)
384
410
  when :node_info, :bearer_token_node
385
411
  apifid = @api_node.resolve_api_fid(top_file_id, options.get_next_argument('path'))
386
412
  result = {
387
413
  url: apifid[:api].params[:base_url],
388
414
  root_id: apifid[:file_id]
389
415
  }
390
- raise 'No auth for node' if apifid[:api].params[:auth].nil?
416
+ assert_values(apifid[:api].params[:auth][:type], %i[basic oauth2])
391
417
  case apifid[:api].params[:auth][:type]
392
418
  when :basic
393
419
  result[:username] = apifid[:api].params[:auth][:username]
@@ -395,10 +421,11 @@ module Aspera
395
421
  when :oauth2
396
422
  result[:username] = apifid[:api].params[:headers][Aspera::Node::HEADER_X_ASPERA_ACCESS_KEY]
397
423
  result[:password] = apifid[:api].oauth_token
398
- else raise 'unknown'
424
+ else error_unreachable_line
399
425
  end
400
426
  return {type: :single_object, data: result} if command_repo.eql?(:node_info)
401
- raise 'not bearer token' unless result[:password].start_with?('Bearer ')
427
+ # check format of bearer token
428
+ Oauth.bearer_extract(result[:password])
402
429
  return Main.result_status(result[:password])
403
430
  when :browse
404
431
  apifid = @api_node.resolve_api_fid(top_file_id, options.get_next_argument('path'))
@@ -413,7 +440,7 @@ module Aspera
413
440
  return {type: :object_list, data: items, fields: %w[name type recursive_size size modified_time access_level]}
414
441
  when :find
415
442
  apifid = @api_node.resolve_api_fid(top_file_id, options.get_next_argument('path'))
416
- test_block = Aspera::Node.file_matcher(value_or_query(allowed_types: String))
443
+ test_block = Aspera::Node.file_matcher_from_argument(options)
417
444
  return {type: :object_list, data: @api_node.find_files(apifid[:file_id], test_block), fields: ['path']}
418
445
  when :mkdir
419
446
  containing_folder_path = options.get_next_argument('path').split(Aspera::Node::PATH_SEPARATOR)
@@ -428,15 +455,27 @@ module Aspera
428
455
  result = apifid[:api].update("files/#{apifid[:file_id]}", {name: newname})[:data]
429
456
  return Main.result_status("renamed to #{newname}")
430
457
  when :delete
431
- return do_bulk_operation(options.get_next_argument('path'), 'deleted', id_result: 'path') do |l_path|
432
- raise "expecting String (path), got #{l_path.class.name} (#{l_path})" unless l_path.is_a?(String)
458
+ return do_bulk_operation(command: command_repo, descr: 'path', values: String, id_result: 'path') do |l_path|
433
459
  apifid = @api_node.resolve_api_fid(top_file_id, l_path)
434
460
  result = apifid[:api].delete("files/#{apifid[:file_id]}")[:data]
435
461
  {'path' => l_path}
436
462
  end
437
463
  when :sync
438
- node_sync = SyncSpecGen4.new(@api_node, top_file_id)
439
- return Plugins::Sync.new(@agents, sync_spec: node_sync).execute_action
464
+ return execute_sync_action do |sync_direction, _local_path, remote_path|
465
+ # Gen4 API
466
+ # direction is push pull, bidi
467
+ assert_values(sync_direction, %i[push pull bidi])
468
+ ts_direction = case sync_direction
469
+ when :push, :bidi then Fasp::TransferSpec::DIRECTION_SEND
470
+ when :pull then Fasp::TransferSpec::DIRECTION_RECEIVE
471
+ else error_unreachable_line
472
+ end
473
+ # remote is specified by option to_folder
474
+ apifid = @api_node.resolve_api_fid(top_file_id, remote_path)
475
+ transfer_spec = apifid[:api].transfer_spec_gen4(apifid[:file_id], ts_direction)
476
+ Log.log.debug{Log.dump(:ts, transfer_spec)}
477
+ transfer_spec
478
+ end
440
479
  when :upload
441
480
  apifid = @api_node.resolve_api_fid(top_file_id, transfer.destination_folder(Fasp::TransferSpec::DIRECTION_SEND))
442
481
  return Main.result_transfer(transfer.start(apifid[:api].transfer_spec_gen4(apifid[:file_id], Fasp::TransferSpec::DIRECTION_SEND)))
@@ -475,7 +514,7 @@ module Aspera
475
514
  source_paths = [{'source' => source_folder.pop}]
476
515
  source_folder = source_folder.join(Aspera::Node::PATH_SEPARATOR)
477
516
  end
478
- raise CliBadArgument, 'one file at a time only in HTTP mode' if source_paths.length > 1
517
+ raise Cli::BadArgument, 'one file at a time only in HTTP mode' if source_paths.length > 1
479
518
  file_name = source_paths.first['source']
480
519
  apifid = @api_node.resolve_api_fid(top_file_id, File.join(source_folder, file_name))
481
520
  apifid[:api].call(
@@ -499,8 +538,7 @@ module Aspera
499
538
  subpath: "files/#{apifid[:file_id]}/preview",
500
539
  headers: {'Accept' => 'image/png'}
501
540
  )
502
- require 'aspera/preview/terminal'
503
- return Main.result_status(Preview::Terminal.build(result[:http].body, reserved_lines: 3))
541
+ return Main.result_picture_in_terminal(options, result[:http].body)
504
542
  when :permission
505
543
  apifid = apifid_from_next_arg(top_file_id)
506
544
  command_perm = options.get_next_command(%i[list create delete])
@@ -514,10 +552,8 @@ module Aspera
514
552
  items = apifid[:api].read('permissions', list_options)[:data]
515
553
  return {type: :object_list, data: items}
516
554
  when :delete
517
- perm_id = instance_identifier
518
- return do_bulk_operation(perm_id, 'deleted') do |one_id|
519
- # TODO: notify event ?
520
- apifid[:api].delete("permissions/#{perm_id}")
555
+ return do_bulk_operation(command: command_perm, descr: 'identifier', values: :identifier) do |one_id|
556
+ apifid[:api].delete("permissions/#{one_id}")
521
557
  # notify application of deletion
522
558
  the_app[:api].permissions_send_event(created_data: created_data, app_info: the_app, types: ['permission.deleted']) unless the_app.nil?
523
559
  {'id' => one_id}
@@ -535,11 +571,11 @@ module Aspera
535
571
  # notify application of creation
536
572
  the_app[:api].permissions_send_event(created_data: created_data, app_info: the_app) unless the_app.nil?
537
573
  return { type: :single_object, data: created_data}
538
- else raise "internal error:shall not reach here (#{command_perm})"
574
+ else error_unreachable_line
539
575
  end
540
- else raise "INTERNAL ERROR: no case for #{command_repo}"
576
+ else error_unreachable_line
541
577
  end # command_repo
542
- # raise 'INTERNAL ERROR: missing return'
578
+ error_unreachable_line
543
579
  end # execute_command_gen4
544
580
 
545
581
  # This is older API
@@ -549,7 +585,7 @@ module Aspera
549
585
  async_name = options.get_option(:sync_name)
550
586
  if async_name.nil?
551
587
  async_id = instance_identifier
552
- if async_id.eql?(VAL_ALL) && %i[show delete].include?(command)
588
+ if async_id.eql?(ExtendedValue::ALL) && %i[show delete].include?(command)
553
589
  async_ids = @api_node.read('async/list')[:data]['sync_ids']
554
590
  else
555
591
  Integer(async_id) # must be integer
@@ -572,7 +608,7 @@ module Aspera
572
608
  when :show
573
609
  resp = @api_node.create('async/summary', post_data)[:data]['sync_summaries']
574
610
  return Main.result_empty if resp.empty?
575
- return { type: :object_list, data: resp, fields: %w[snid name local_dir remote_dir] } if async_id.eql?(VAL_ALL)
611
+ return { type: :object_list, data: resp, fields: %w[snid name local_dir remote_dir] } if async_id.eql?(ExtendedValue::ALL)
576
612
  return { type: :single_object, data: resp.first }
577
613
  when :delete
578
614
  resp = @api_node.create('async/delete', post_data)[:data]
@@ -589,7 +625,7 @@ module Aspera
589
625
  # filename str
590
626
  # skip int
591
627
  # status int
592
- filter = value_or_query(allowed_types: Hash)
628
+ filter = query_option
593
629
  post_data.merge!(filter) unless filter.nil?
594
630
  resp = @api_node.create('async/files', post_data)[:data]
595
631
  data = resp['sync_files']
@@ -620,6 +656,20 @@ module Aspera
620
656
  end
621
657
  end
622
658
 
659
+ # @return [Integer] id of the sync
660
+ # @raise [Cli::BadArgument] if no such sync, or not by name
661
+ # @param [String] field name of the field to search
662
+ # @param [String] value value of the field to search
663
+ def ssync_lookup(field, value)
664
+ raise Cli::BadArgument, "Only search by name is supported (#{field})" unless field.eql?('name')
665
+ @api_node.read('asyncs')[:data]['ids'].each do |id|
666
+ sync_info = @api_node.read("asyncs/#{id}")[:data]['configuration']
667
+ # name is unique, so we can return
668
+ return id if sync_info[field].eql?(value)
669
+ end
670
+ raise Cli::BadArgument, "no such sync: #{field}=#{value}"
671
+ end
672
+
623
673
  ACTIONS = %i[
624
674
  async
625
675
  ssync
@@ -629,7 +679,9 @@ module Aspera
629
679
  watch_folder
630
680
  central
631
681
  asperabrowser
632
- basic_token].concat(COMMON_ACTIONS).freeze
682
+ basic_token
683
+ bearer_token
684
+ simulator].concat(COMMON_ACTIONS).freeze
633
685
 
634
686
  def execute_action(command=nil, prefix_path=nil)
635
687
  command ||= options.get_next_command(ACTIONS)
@@ -640,17 +692,15 @@ module Aspera
640
692
  # newer API
641
693
  sync_command = options.get_next_command(%i[start stop bandwidth counters files state summary].concat(Plugin::ALL_OPS) - %i[modify])
642
694
  case sync_command
643
- when *Plugin::ALL_OPS then return entity_command(sync_command, @api_node, 'asyncs', item_list_key: 'ids')
695
+ when *Plugin::ALL_OPS then return entity_command(sync_command, @api_node, 'asyncs', item_list_key: 'ids'){|field, value|ssync_lookup(field, value)}
644
696
  else
645
- asyncs_id = instance_identifier
697
+ asyncs_id = instance_identifier {|field, value|ssync_lookup(field, value)}
646
698
  parameters = nil
647
699
  if %i[start stop].include?(sync_command)
648
700
  @api_node.create("asyncs/#{asyncs_id}/#{sync_command}", parameters)
649
701
  return Main.result_status('Done')
650
702
  end
651
- if %i[bandwidth counters files].include?(sync_command)
652
- parameters = value_or_query(allowed_types: Hash, mandatory: false) || {}
653
- end
703
+ parameters = query_option(default: {}) if %i[bandwidth counters files].include?(sync_command)
654
704
  return { type: :single_object, data: @api_node.read("asyncs/#{asyncs_id}/#{sync_command}", parameters)[:data] }
655
705
  end
656
706
  when :stream
@@ -660,13 +710,13 @@ module Aspera
660
710
  resp = @api_node.read('ops/transfers', old_query_read_delete)
661
711
  return { type: :object_list, data: resp[:data], fields: %w[id status] } # TODO: useful?
662
712
  when :create
663
- resp = @api_node.create('streams', value_create_modify(command: command, type: Hash))
713
+ resp = @api_node.create('streams', value_create_modify(command: command))
664
714
  return { type: :single_object, data: resp[:data] }
665
715
  when :show
666
716
  resp = @api_node.read("ops/transfers/#{options.get_next_argument('transfer id')}")
667
717
  return { type: :other_struct, data: resp[:data] }
668
718
  when :modify
669
- resp = @api_node.update("streams/#{options.get_next_argument('transfer id')}", value_create_modify(command: command, type: Hash))
719
+ resp = @api_node.update("streams/#{options.get_next_argument('transfer id')}", value_create_modify(command: command))
670
720
  return { type: :other_struct, data: resp[:data] }
671
721
  when :cancel
672
722
  resp = @api_node.cancel("streams/#{options.get_next_argument('transfer id')}")
@@ -675,7 +725,7 @@ module Aspera
675
725
  raise 'error'
676
726
  end
677
727
  when :transfer
678
- command = options.get_next_command(%i[list cancel show modify bandwidth_average])
728
+ command = options.get_next_command(%i[list cancel show modify bandwidth_average sessions])
679
729
  res_class_path = 'ops/transfers'
680
730
  if %i[cancel show modify].include?(command)
681
731
  one_res_id = instance_identifier
@@ -683,15 +733,24 @@ module Aspera
683
733
  end
684
734
  case command
685
735
  when :list
686
- # could use ? subpath: 'transfers'
687
- query = query_read_delete
688
- raise 'Query must be a Hash' unless query.nil? || query.is_a?(Hash)
689
- transfers_data = @api_node.read(res_class_path, query)[:data]
736
+ transfers_data = @api_node.read(res_class_path, query_read_delete)[:data]
690
737
  return {
691
738
  type: :object_list,
692
739
  data: transfers_data,
693
740
  fields: %w[id status start_spec.direction start_spec.remote_user start_spec.remote_host start_spec.destination_path]
694
741
  }
742
+ when :sessions
743
+ transfers_data = @api_node.read(res_class_path, query_read_delete)[:data]
744
+ sessions = transfers_data.map{|t|t['sessions']}.flatten
745
+ sessions.each do |session|
746
+ session['start_time'] = Time.at(session['start_time_usec'] / 1_000_000.0).utc.iso8601(0)
747
+ session['end_time'] = Time.at(session['end_time_usec'] / 1_000_000.0).utc.iso8601(0)
748
+ end
749
+ return {
750
+ type: :object_list,
751
+ data: sessions,
752
+ fields: %w[id status start_time end_time target_rate_kbps]
753
+ }
695
754
  when :cancel
696
755
  resp = @api_node.cancel(one_res_path)
697
756
  return { type: :other_struct, data: resp[:data] }
@@ -702,7 +761,7 @@ module Aspera
702
761
  resp = @api_node.update(one_res_path, options.get_next_argument('update value', type: Hash))
703
762
  return { type: :other_struct, data: resp[:data] }
704
763
  when :bandwidth_average
705
- transfers_data = @api_node.read(res_class_path, query)[:data]
764
+ transfers_data = @api_node.read(res_class_path, query_read_delete)[:data]
706
765
  # collect all key dates
707
766
  bandwidth_period = {}
708
767
  dir_info = %i[avg_kbps sessions].freeze
@@ -779,7 +838,7 @@ module Aspera
779
838
  @api_node.params[:headers]['X-aspera-WF-version'] = '2017_10_23'
780
839
  case command
781
840
  when :create
782
- resp = @api_node.create(res_class_path, value_create_modify(command: command, type: Hash))
841
+ resp = @api_node.create(res_class_path, value_create_modify(command: command))
783
842
  return Main.result_status("#{resp[:data]['id']} created")
784
843
  when :list
785
844
  resp = @api_node.read(res_class_path, old_query_read_delete)
@@ -787,7 +846,7 @@ module Aspera
787
846
  when :show
788
847
  return { type: :single_object, data: @api_node.read(one_res_path)[:data]}
789
848
  when :modify
790
- @api_node.update(one_res_path, value_or_query(mandatory: true, allowed_types: Hash))
849
+ @api_node.update(one_res_path, query_option(mandatory: true))
791
850
  return Main.result_status("#{one_res_id} updated")
792
851
  when :delete
793
852
  @api_node.delete(one_res_path)
@@ -799,7 +858,7 @@ module Aspera
799
858
  command = options.get_next_command(%i[session file])
800
859
  validator_id = options.get_option(:validator)
801
860
  validation = {'validator_id' => validator_id} unless validator_id.nil?
802
- request_data = value_create_modify(default: {}, type: Hash)
861
+ request_data = query_option(default: {})
803
862
  case command
804
863
  when :session
805
864
  command = options.get_next_command([:list])
@@ -820,7 +879,7 @@ module Aspera
820
879
  request_data.deep_merge!({'validation' => validation}) unless validation.nil?
821
880
  resp = @api_node.create('services/rest/transfers/v1/files', request_data)[:data]
822
881
  resp = JSON.parse(resp) if resp.is_a?(String)
823
- Log.dump(:resp, resp)
882
+ Log.log.debug{Log.dump(:resp, resp)}
824
883
  return { type: :object_list, data: resp['file_transfer_info_result']['file_transfer_info'], fields: %w[session_uuid file_id status path]}
825
884
  when :modify
826
885
  request_data.deep_merge!(validation) unless validation.nil?
@@ -839,7 +898,27 @@ module Aspera
839
898
  OpenApplication.instance.uri(options.get_option(:asperabrowserurl) + '?goto=' + encoded_params)
840
899
  return Main.result_status('done')
841
900
  when :basic_token
842
- return Main.result_status(Rest.basic_creds(options.get_option(:username, mandatory: true), options.get_option(:password, mandatory: true)))
901
+ return Main.result_status(Rest.basic_token(options.get_option(:username, mandatory: true), options.get_option(:password, mandatory: true)))
902
+ when :bearer_token
903
+ private_key = OpenSSL::PKey::RSA.new(options.get_next_argument('private RSA key PEM value', type: String))
904
+ token_info = options.get_next_argument('user and group identification', type: Hash)
905
+ access_key = options.get_option(:username, mandatory: true)
906
+ return Main.result_status(Aspera::Node.bearer_token(payload: token_info, access_key: access_key, private_key: private_key))
907
+ when :simulator
908
+ require 'aspera/node_simulator'
909
+ parameters = value_create_modify(command: command)
910
+ parameters = parameters.symbolize_keys
911
+ raise 'Missing key: url' unless parameters.key?(:url)
912
+ uri = URI.parse(parameters[:url])
913
+ server = WebServerSimple.new(uri, certificate: parameters[:certificate])
914
+ server.mount(uri.path, NodeSimulatorServlet, parameters[:credentials], transfer)
915
+ # on ctrl-c, tell server main loop to exit
916
+ trap('INT') { server.shutdown }
917
+ formatter.display_status("Node Simulator listening on #{uri.port}")
918
+ Log.log.info{"Listening on #{uri.port}"}
919
+ # this is blocking until server exits
920
+ server.start
921
+ return Main.result_status('Simulator terminated')
843
922
  end # case command
844
923
  raise 'ERROR: shall not reach this line'
845
924
  end # execute_action