aspera-cli 4.15.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 (80) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/BUGS.md +29 -3
  4. data/CHANGELOG.md +292 -228
  5. data/CONTRIBUTING.md +69 -18
  6. data/README.md +1102 -952
  7. data/bin/ascli +13 -31
  8. data/bin/asession +3 -1
  9. data/examples/dascli +2 -2
  10. data/lib/aspera/aoc.rb +28 -33
  11. data/lib/aspera/ascmd.rb +3 -6
  12. data/lib/aspera/assert.rb +45 -0
  13. data/lib/aspera/cli/extended_value.rb +5 -5
  14. data/lib/aspera/cli/formatter.rb +26 -13
  15. data/lib/aspera/cli/hints.rb +4 -3
  16. data/lib/aspera/cli/main.rb +16 -3
  17. data/lib/aspera/cli/manager.rb +45 -36
  18. data/lib/aspera/cli/plugin.rb +20 -13
  19. data/lib/aspera/cli/plugins/aoc.rb +103 -73
  20. data/lib/aspera/cli/plugins/ats.rb +4 -3
  21. data/lib/aspera/cli/plugins/config.rb +114 -119
  22. data/lib/aspera/cli/plugins/cos.rb +2 -2
  23. data/lib/aspera/cli/plugins/faspex.rb +23 -19
  24. data/lib/aspera/cli/plugins/faspex5.rb +75 -43
  25. data/lib/aspera/cli/plugins/node.rb +28 -15
  26. data/lib/aspera/cli/plugins/orchestrator.rb +4 -2
  27. data/lib/aspera/cli/plugins/preview.rb +9 -7
  28. data/lib/aspera/cli/plugins/server.rb +6 -3
  29. data/lib/aspera/cli/plugins/shares.rb +30 -26
  30. data/lib/aspera/cli/sync_actions.rb +9 -9
  31. data/lib/aspera/cli/transfer_agent.rb +21 -14
  32. data/lib/aspera/cli/transfer_progress.rb +2 -3
  33. data/lib/aspera/cli/version.rb +1 -1
  34. data/lib/aspera/command_line_builder.rb +13 -11
  35. data/lib/aspera/cos_node.rb +3 -2
  36. data/lib/aspera/coverage.rb +22 -0
  37. data/lib/aspera/data_repository.rb +33 -2
  38. data/lib/aspera/environment.rb +4 -2
  39. data/lib/aspera/fasp/{agent_aspera.rb → agent_alpha.rb} +29 -39
  40. data/lib/aspera/fasp/agent_base.rb +17 -7
  41. data/lib/aspera/fasp/agent_direct.rb +88 -84
  42. data/lib/aspera/fasp/agent_httpgw.rb +4 -3
  43. data/lib/aspera/fasp/agent_node.rb +3 -2
  44. data/lib/aspera/fasp/agent_trsdk.rb +79 -37
  45. data/lib/aspera/fasp/installation.rb +51 -12
  46. data/lib/aspera/fasp/management.rb +11 -6
  47. data/lib/aspera/fasp/parameters.rb +53 -47
  48. data/lib/aspera/fasp/resume_policy.rb +7 -5
  49. data/lib/aspera/fasp/sync.rb +273 -0
  50. data/lib/aspera/fasp/transfer_spec.rb +10 -8
  51. data/lib/aspera/fasp/uri.rb +2 -2
  52. data/lib/aspera/faspex_gw.rb +11 -8
  53. data/lib/aspera/faspex_postproc.rb +6 -5
  54. data/lib/aspera/id_generator.rb +3 -1
  55. data/lib/aspera/json_rpc.rb +10 -8
  56. data/lib/aspera/keychain/encrypted_hash.rb +46 -11
  57. data/lib/aspera/keychain/macos_security.rb +15 -13
  58. data/lib/aspera/log.rb +4 -3
  59. data/lib/aspera/nagios.rb +7 -2
  60. data/lib/aspera/node.rb +17 -16
  61. data/lib/aspera/node_simulator.rb +214 -0
  62. data/lib/aspera/oauth.rb +22 -19
  63. data/lib/aspera/persistency_action_once.rb +13 -14
  64. data/lib/aspera/persistency_folder.rb +3 -2
  65. data/lib/aspera/preview/file_types.rb +53 -267
  66. data/lib/aspera/preview/generator.rb +7 -5
  67. data/lib/aspera/preview/terminal.rb +14 -5
  68. data/lib/aspera/preview/utils.rb +8 -7
  69. data/lib/aspera/proxy_auto_config.rb +6 -3
  70. data/lib/aspera/rest.rb +29 -13
  71. data/lib/aspera/rest_error_analyzer.rb +1 -0
  72. data/lib/aspera/rest_errors_aspera.rb +2 -0
  73. data/lib/aspera/secret_hider.rb +5 -2
  74. data/lib/aspera/ssh.rb +10 -8
  75. data/lib/aspera/temp_file_manager.rb +1 -1
  76. data/lib/aspera/web_server_simple.rb +2 -1
  77. data.tar.gz.sig +0 -0
  78. metadata +96 -45
  79. metadata.gz.sig +0 -0
  80. data/lib/aspera/sync.rb +0 -219
@@ -8,8 +8,8 @@ require 'aspera/fasp/resume_policy'
8
8
  require 'aspera/fasp/transfer_spec'
9
9
  require 'aspera/fasp/management'
10
10
  require 'aspera/log'
11
+ require 'aspera/assert'
11
12
  require 'socket'
12
- require 'timeout'
13
13
  require 'securerandom'
14
14
  require 'shellwords'
15
15
  require 'English'
@@ -20,8 +20,8 @@ module Aspera
20
20
  class AgentDirect < Aspera::Fasp::AgentBase
21
21
  # options for initialize (same as values in option transfer_info)
22
22
  DEFAULT_OPTIONS = {
23
- spawn_timeout_sec: 3,
24
- spawn_delay_sec: 2,
23
+ spawn_timeout_sec: 2,
24
+ spawn_delay_sec: 2, # optional delay to start between sessions
25
25
  wss: true, # true: if both SSH and wss in ts: prefer wss
26
26
  multi_incr_udp: true,
27
27
  resume: {},
@@ -30,17 +30,20 @@ module Aspera
30
30
  quiet: true, # by default no native ascp progress bar
31
31
  trusted_certs: [] # list of files with trusted certificates (stores)
32
32
  }.freeze
33
+ LISTEN_ADDRESS = '127.0.0.1'
34
+ LISTEN_AVAILABLE_PORT = 0 # 0 means any available port
33
35
  # spellchecker: enable
34
- private_constant :DEFAULT_OPTIONS
36
+ private_constant :DEFAULT_OPTIONS, :LISTEN_ADDRESS
35
37
 
36
- # start ascp transfer (non blocking), single or multi-session
37
- # job information added to @jobs
38
+ # method of Aspera::Fasp::AgentBase
39
+ # start ascp transfer(s) (non blocking), single or multi-session
40
+ # session information added to @sessions
38
41
  # @param transfer_spec [Hash] aspera transfer specification
42
+ # @param token_regenerator [Object] object with method refreshed_transfer_token
39
43
  def start_transfer(transfer_spec, token_regenerator: nil)
40
- the_job_id = SecureRandom.uuid
41
44
  # clone transfer spec because we modify it (first level keys)
42
45
  transfer_spec = transfer_spec.clone
43
- # if there is aspera tags
46
+ # if there are aspera tags
44
47
  if transfer_spec['tags'].is_a?(Hash) && transfer_spec['tags'][Fasp::TransferSpec::TAG_RESERVED].is_a?(Hash)
45
48
  # TODO: what is this for ? only on local ascp ?
46
49
  # NOTE: important: transfer id must be unique: generate random id
@@ -48,11 +51,10 @@ module Aspera
48
51
  # all sessions in a multi-session transfer must have the same xfer_id (see admin manual)
49
52
  transfer_spec['tags'][Fasp::TransferSpec::TAG_RESERVED]['xfer_id'] ||= SecureRandom.uuid
50
53
  Log.log.debug{"xfer id=#{transfer_spec['xfer_id']}"}
51
- # TODO: useful ? node only ?
54
+ # TODO: useful ? node only ? seems to be a timeout for retry in node
52
55
  transfer_spec['tags'][Fasp::TransferSpec::TAG_RESERVED]['xfer_retry'] ||= 3600
53
56
  end
54
57
  Log.log.debug{Log.dump('ts', transfer_spec)}
55
-
56
58
  # Compute this before using transfer spec because it potentially modifies the transfer spec
57
59
  # (even if the var is not used in single session)
58
60
  multi_session_info = nil
@@ -77,30 +79,24 @@ module Aspera
77
79
  end
78
80
  end
79
81
 
80
- # compute known arguments and environment variables
81
- env_args = Parameters.new(transfer_spec, @options).ascp_args
82
-
83
- # transfer job can be multi session
84
- xfer_job = {
85
- id: the_job_id,
86
- sessions: [] # all sessions as below
87
- }
88
-
89
82
  # generic session information
90
83
  session = {
84
+ id: nil, # SessionId from INIT message in mgt port
85
+ job_id: SecureRandom.uuid, # job id (regroup sessions)
86
+ ts: transfer_spec, # transfer spec
91
87
  thread: nil, # Thread object monitoring management port, not nil when pushed to :sessions
92
88
  error: nil, # exception if failed
93
89
  io: nil, # management port server socket
94
- id: nil, # SessionId from INIT message in mgt port
95
90
  token_regenerator: token_regenerator, # regenerate bearer token with oauth
96
- env_args: env_args # env vars and args to ascp (from transfer spec)
91
+ # env vars and args to ascp (from transfer spec)
92
+ env_args: Parameters.new(transfer_spec, @options).ascp_args
97
93
  }
98
94
 
99
95
  if multi_session_info.nil?
100
96
  Log.log.debug('Starting single session thread')
101
97
  # single session for transfer : simple
102
98
  session[:thread] = Thread.new(session) {|s|transfer_thread_entry(s)}
103
- xfer_job[:sessions].push(session)
99
+ @sessions.push(session)
104
100
  else
105
101
  Log.log.debug('Starting multi session threads')
106
102
  1.upto(multi_session_info[:count]) do |i|
@@ -108,22 +104,19 @@ module Aspera
108
104
  sleep(@options[:spawn_delay_sec]) unless i.eql?(1)
109
105
  # do deep copy (each thread has its own copy because it is modified here below and in thread)
110
106
  this_session = session.clone
107
+ this_session[:ts] = this_session[:ts].clone
111
108
  this_session[:env_args] = this_session[:env_args].clone
112
109
  this_session[:env_args][:args] = this_session[:env_args][:args].clone
110
+ # set multi session part
113
111
  this_session[:env_args][:args].unshift("-C#{i}:#{multi_session_info[:count]}")
114
112
  # option: increment (default as per ascp manual) or not (cluster on other side ?)
115
113
  this_session[:env_args][:args].unshift('-O', (multi_session_info[:udp_base] + i - 1).to_s) if @options[:multi_incr_udp]
114
+ # finally start the thread
116
115
  this_session[:thread] = Thread.new(this_session) {|s|transfer_thread_entry(s)}
117
- xfer_job[:sessions].push(this_session)
116
+ @sessions.push(this_session)
118
117
  end
119
118
  end
120
- Log.log.debug('started session thread(s)')
121
-
122
- # add job to list of jobs
123
- @jobs[the_job_id] = xfer_job
124
- Log.log.debug{"jobs: #{@jobs.keys.count}"}
125
-
126
- return the_job_id
119
+ return session[:job_id]
127
120
  end # start_transfer
128
121
 
129
122
  # wait for completion of all jobs started
@@ -132,16 +125,14 @@ module Aspera
132
125
  Log.log.debug('wait_for_transfers_completion')
133
126
  # set to non-nil to exit loop
134
127
  result = []
135
- @jobs.each do |_id, job|
136
- job[:sessions].each do |session|
137
- Log.log.debug{"join #{session[:thread]}"}
138
- session[:thread].join
139
- result.push(session[:error] || :success)
140
- end
128
+ @sessions.each do |session|
129
+ Log.log.debug{"join #{session[:thread]}"}
130
+ session[:thread].join
131
+ result.push(session[:error] || :success)
141
132
  end
142
133
  Log.log.debug('all transfers joined')
143
134
  # since all are finished and we return the result, clear statuses
144
- @jobs.clear
135
+ @sessions.clear
145
136
  return result
146
137
  end
147
138
 
@@ -198,27 +189,26 @@ module Aspera
198
189
  # start ascp with management port.
199
190
  # raises FaspError on error
200
191
  # if there is a thread info: set and broadcast session id
192
+ # runs in separate thread
201
193
  # @param env_args a hash containing :args :env :ascp_version
202
194
  # @param session this session information
203
195
  # could be private method
204
196
  def start_transfer_with_args_env(env_args, session)
205
- raise 'env_args must be Hash' unless env_args.is_a?(Hash)
206
- raise 'session must be Hash' unless session.is_a?(Hash)
197
+ assert_type(env_args, Hash)
198
+ assert_type(session, Hash)
199
+ Log.log.debug{"env_args=#{env_args.inspect}"}
200
+ notify_progress(session_id: nil, type: :pre_start, info: 'starting')
207
201
  begin
208
- Log.log.debug{"env_args=#{env_args.inspect}"}
209
- # get location of ascp executable
210
- ascp_path = @mutex.synchronize do
211
- Fasp::Installation.instance.path(env_args[:ascp_version])
212
- end
213
- # (optional) check it exists
214
- raise Fasp::Error, "no such file: #{ascp_path}" unless File.exist?(ascp_path)
215
- notify_progress(session_id: nil, type: :pre_start, info: 'starting ascp')
202
+ # we use Socket directly, instead of TCPServer, s it gives access to lower level options
203
+ mgt_server_socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
216
204
  # open an available (0) local TCP port as ascp management
217
- mgt_sock = TCPServer.new('127.0.0.1', 0)
218
- # clone arguments as we eed to modify with mgt port
219
- ascp_arguments = env_args[:args].clone
220
- # add management port on the selected local port
221
- ascp_arguments.unshift('-M', mgt_sock.addr[1].to_s)
205
+ # Socket.pack_sockaddr_in(LISTEN_AVAILABLE_PORT, LISTEN_ADDRESS)
206
+ mgt_server_socket.bind(Addrinfo.tcp(LISTEN_ADDRESS, LISTEN_AVAILABLE_PORT))
207
+ # clone arguments and add mgt port
208
+ ascp_arguments = ['-M', mgt_server_socket.local_address.ip_port.to_s].concat(env_args[:args])
209
+ # mgt_server_socket.addr[1]
210
+ # get location of ascp executable
211
+ ascp_path = Fasp::Installation.instance.path(env_args[:ascp_version])
222
212
  # display ascp command line
223
213
  Log.log.debug do
224
214
  [
@@ -229,20 +219,23 @@ module Aspera
229
219
  ].flatten.join(' ')
230
220
  end
231
221
  # start ascp in separate process
232
- ascp_pid = Process.spawn(env_args[:env], [ascp_path, ascp_path], *ascp_arguments)
233
- # in parent, wait for connection to socket max 3 seconds
234
- Log.log.debug{"before accept for pid (#{ascp_pid})"}
235
- # init management socket
236
- ascp_mgt_io = nil
222
+ ascp_pid = Process.spawn(env_args[:env], [ascp_path, ascp_path], *ascp_arguments, close_others: true)
223
+ Log.log.debug{"spawned pid #{ascp_pid}"}
237
224
  notify_progress(session_id: nil, type: :pre_start, info: 'waiting for ascp')
238
- Timeout.timeout(@options[:spawn_timeout_sec]) do
239
- ascp_mgt_io = mgt_sock.accept
240
- # management messages include file names which may be utf8
241
- # by default socket is US-ASCII
242
- # TODO: use same value as Encoding.default_external
243
- ascp_mgt_io.set_encoding(Encoding::UTF_8)
244
- end
245
- Log.log.debug{"after accept (#{ascp_mgt_io})"}
225
+ mgt_server_socket.listen(1)
226
+ # TODO: timeout does not work when Process.spawn is used... until process exits, then it works
227
+ Log.log.debug{"before select, timeout: #{@options[:spawn_timeout_sec]}"}
228
+ readable, _, _ = IO.select([mgt_server_socket], nil, nil, @options[:spawn_timeout_sec])
229
+ Log.log.debug('after select, before accept')
230
+ assert(readable, exception_class: Fasp::Error){'timeout waiting mgt port connect (select not readable)'}
231
+ # There is a connection to accept
232
+ client_socket, _client_addrinfo = mgt_server_socket.accept
233
+ Log.log.debug('after accept')
234
+ ascp_mgt_io = client_socket.to_io
235
+ # management messages include file names which may be utf8
236
+ # by default socket is US-ASCII
237
+ # TODO: use same value as Encoding.default_external
238
+ ascp_mgt_io.set_encoding(Encoding::UTF_8)
246
239
  session[:io] = ascp_mgt_io
247
240
  processor = Management.new
248
241
  # read management port, until socket is closed (gets returns nil)
@@ -250,11 +243,12 @@ module Aspera
250
243
  event = processor.process_line(line.chomp)
251
244
  next unless event
252
245
  # event is ready
253
- Log.log.debug{Log.dump(:management_port, event)}
246
+ Log.log.trace1{Log.dump(:management_port, event)}
254
247
  # Log.log.trace1{"event: #{JSON.generate(Management.enhanced_event_format(event))}"}
255
248
  process_progress(event)
256
249
  Log.log.error((event['Description']).to_s) if event['Type'].eql?('FILEERROR') # cspell:disable-line
257
250
  end
251
+ Log.log.debug('management io closed')
258
252
  last_event = processor.last_event
259
253
  # check that last status was received before process exit
260
254
  if last_event.is_a?(Hash)
@@ -264,7 +258,7 @@ module Aspera
264
258
  session[:token_regenerator].respond_to?(:refreshed_transfer_token)
265
259
  # regenerate token here, expired, or error on it
266
260
  # Note: in multi-session, each session will have a different one.
267
- Log.log.warn('Regenerating bearer token')
261
+ Log.log.warn('Regenerating token for transfer')
268
262
  env_args[:env]['ASPERA_SCP_TOKEN'] = session[:token_regenerator].refreshed_transfer_token
269
263
  end
270
264
  raise Fasp::Error.new(last_event['Description'], last_event['Code'].to_i)
@@ -277,11 +271,10 @@ module Aspera
277
271
  rescue SystemCallError => e
278
272
  # Process.spawn
279
273
  raise Fasp::Error, e.message
280
- rescue Timeout::Error
281
- raise Fasp::Error, 'timeout waiting mgt port connect'
282
274
  rescue Interrupt
283
275
  raise Fasp::Error, 'transfer interrupted by user'
284
276
  ensure
277
+ mgt_server_socket.close
285
278
  # if ascp was successfully started
286
279
  unless ascp_pid.nil?
287
280
  # "wait" for process to avoid zombie
@@ -290,27 +283,37 @@ module Aspera
290
283
  ascp_pid = nil
291
284
  session.delete(:io)
292
285
  if !status.success?
293
- message = "ascp failed with code #{status.exitstatus}"
294
- # raise error only if there was not already an exception
286
+ message = "ascp failed: #{status}"
287
+ # raise error only if there was not already an exception (ERROR_INFO)
295
288
  raise Fasp::Error, message unless $ERROR_INFO
296
- # else just debug, as main exception is already here
297
- Log.log.debug(message)
289
+ # else display this message also, as main exception is already here
290
+ Log.log.error(message)
298
291
  end
299
292
  end
300
293
  end # begin-ensure
301
294
  end # start_transfer_with_args_env
302
295
 
303
- # send command of management port to ascp session
296
+ # @return [Array] list of sessions for a job
297
+ def sessions_by_job(job_id)
298
+ @sessions.select{|s| s[:job_id].eql?(job_id)}
299
+ end
300
+
301
+ # @return [Hash] session information
302
+ def session_by_id(id)
303
+ matches = @sessions.select{|s| s[:id].eql?(id)}
304
+ raise 'no such session' if matches.empty?
305
+ raise 'more than one session' if matches.length > 1
306
+ return matches.first
307
+ end
308
+
309
+ # send command of management port to ascp session (used in `asession)
304
310
  # @param job_id identified transfer process
305
311
  # @param session_index index of session (for multi session)
306
312
  # @param data command on mgt port, examples:
307
313
  # {'type'=>'START','source'=>_path_,'destination'=>_path_}
308
314
  # {'type'=>'DONE'}
309
- def send_command(job_id, session_index, data)
310
- job = @jobs[job_id]
311
- raise 'no such job' if job.nil?
312
- session = job[:sessions][session_index]
313
- raise 'no such session' if session.nil?
315
+ def send_command(job_id, data)
316
+ session = session_by_id(job_id)
314
317
  Log.log.debug{"command: #{data}"}
315
318
  # build command
316
319
  command = data
@@ -321,20 +324,21 @@ module Aspera
321
324
  .join("\n")
322
325
  session[:io].puts(command)
323
326
  end
327
+ attr_reader :sessions
324
328
 
325
329
  private
326
330
 
327
331
  # @param options : keys(symbol): see DEFAULT_OPTIONS
328
332
  def initialize(options={})
329
333
  super(options)
330
- # all transfer jobs, key = SecureRandom.uuid, protected by mutex, cond var on change
331
- @jobs = {}
332
- # mutex protects global data accessed by threads
333
- @mutex = Mutex.new
334
334
  # set default options and override if specified
335
335
  @options = AgentBase.options(default: DEFAULT_OPTIONS, options: options)
336
- @resume_policy = ResumePolicy.new(@options[:resume].symbolize_keys)
337
336
  Log.log.debug{Log.dump(:agent_options, @options)}
337
+ @resume_policy = ResumePolicy.new(@options[:resume].symbolize_keys)
338
+ # all transfer jobs, key = SecureRandom.uuid, protected by mutex, cond var on change
339
+ @sessions = []
340
+ # mutex protects global data accessed by threads
341
+ @mutex = Mutex.new
338
342
  end
339
343
 
340
344
  # transfer thread entry
@@ -4,6 +4,7 @@ require 'aspera/fasp/agent_base'
4
4
  require 'aspera/fasp/transfer_spec'
5
5
  require 'aspera/fasp/faux_file'
6
6
  require 'aspera/log'
7
+ require 'aspera/assert'
7
8
  require 'aspera/rest'
8
9
  require 'securerandom'
9
10
  require 'websocket'
@@ -185,7 +186,7 @@ module Aspera
185
186
  @ws_io.write(@ws_handshake.to_s)
186
187
  sleep(0.1)
187
188
  @ws_handshake << @ws_io.readuntil("\r\n\r\n")
188
- raise 'Error in websocket handshake' unless @ws_handshake.finished?
189
+ assert(@ws_handshake.finished?){'Error in websocket handshake'}
189
190
  Log.log.debug{"#{LOG_WS_SEND}handshake success"}
190
191
  # start read thread after handshake
191
192
  @ws_read_thread = Thread.new {process_read_thread}
@@ -311,8 +312,8 @@ module Aspera
311
312
  # HTTP download only supports file list
312
313
  def start_transfer(transfer_spec, token_regenerator: nil)
313
314
  raise 'GW URL must be set' if @gw_api.nil?
314
- raise 'paths: must be Array' unless transfer_spec['paths'].is_a?(Array)
315
- raise 'only token based transfer is supported in GW' unless transfer_spec['token'].is_a?(String)
315
+ assert_type(transfer_spec['paths'], Array){'paths'}
316
+ assert_type(transfer_spec['token'], String){'only token based transfer is supported in GW'}
316
317
  Log.log.debug{Log.dump(:user_spec, transfer_spec)}
317
318
  transfer_spec['authentication'] ||= 'token'
318
319
  case transfer_spec['direction']
@@ -5,6 +5,7 @@ require 'aspera/fasp/agent_base'
5
5
  require 'aspera/fasp/transfer_spec'
6
6
  require 'aspera/node'
7
7
  require 'aspera/log'
8
+ require 'aspera/assert'
8
9
  require 'aspera/oauth'
9
10
 
10
11
  module Aspera
@@ -22,7 +23,7 @@ module Aspera
22
23
  # attr_writer :options
23
24
 
24
25
  def initialize(opts)
25
- raise 'node specification must be Hash' unless opts.is_a?(Hash)
26
+ assert_type(opts, Hash){'node agent options'}
26
27
  super(opts)
27
28
  options = AgentBase.options(default: DEFAULT_OPTIONS, options: opts)
28
29
  # root id is required for access key
@@ -67,7 +68,7 @@ module Aspera
67
68
  case transfer_spec['direction']
68
69
  when Fasp::TransferSpec::DIRECTION_SEND then transfer_spec['source_root_id'] = @root_id
69
70
  when Fasp::TransferSpec::DIRECTION_RECEIVE then transfer_spec['destination_root_id'] = @root_id
70
- else raise "unexpected direction in ts: #{transfer_spec['direction']}"
71
+ else error_unexpected_value(transfer_spec['direction'])
71
72
  end
72
73
  end
73
74
  # manage special additional parameter
@@ -2,46 +2,84 @@
2
2
 
3
3
  require 'aspera/fasp/agent_base'
4
4
  require 'aspera/fasp/installation'
5
+ require 'aspera/temp_file_manager'
6
+ require 'aspera/log'
7
+ require 'aspera/assert'
5
8
  require 'json'
6
9
  require 'uri'
7
10
 
8
11
  module Aspera
9
12
  module Fasp
10
13
  class AgentTrsdk < Aspera::Fasp::AgentBase
14
+ # see https://github.com/grpc/grpc/blob/master/doc/naming.md
15
+ # https://grpc.io/docs/guides/custom-name-resolution/
16
+ LOCAL_SOCKET_ADDR = '127.0.0.1'
17
+ PORT_SEP = ':'
18
+ # port zero means select a random available high port
19
+ AUTO_LOCAL_TCP_PORT = "#{PORT_SEP}0"
11
20
  DEFAULT_OPTIONS = {
12
- url: 'grpc://127.0.0.1:0',
13
- external: false,
14
- keep: false
21
+ url: AUTO_LOCAL_TCP_PORT,
22
+ external: false, # expect that an external daemon is already running
23
+ keep: false # do not shutdown daemon on exit
15
24
  }.freeze
16
25
  private_constant :DEFAULT_OPTIONS
17
26
 
27
+ class << self
28
+ # Well, the port number is only in log file
29
+ def daemon_port_from_log(log_file)
30
+ result = nil
31
+ # if port is zero, a dynamic port was created, get it
32
+ File.open(log_file, 'r') do |file|
33
+ file.each_line do |line|
34
+ # Well, it's tricky to depend on log
35
+ if (m = line.match(/Info: API Server: Listening on ([^:]+):(\d+) /))
36
+ result = m[2].to_i
37
+ # no "break" , need to read last matching log line
38
+ end
39
+ end
40
+ end
41
+ raise 'Port not found in daemon logs' if result.nil?
42
+ Log.log.debug{"Got port #{result} from log"}
43
+ return result
44
+ end
45
+ end
46
+
18
47
  # options come from transfer_info
19
48
  def initialize(user_opts={})
20
49
  super(user_opts)
21
50
  @options = AgentBase.options(default: DEFAULT_OPTIONS, options: user_opts)
22
- daemon_uri = URI.parse(@options[:url])
23
- raise Fasp::Error, "invalid url #{@options[:url]}" unless daemon_uri.scheme.eql?('grpc')
51
+ is_local_auto_port = @options[:url].eql?(AUTO_LOCAL_TCP_PORT)
52
+ raise 'Cannot use options `keep` or `external` with port zero' if is_local_auto_port && (@options[:keep] || @options[:external])
24
53
  Log.log.debug{Log.dump(:agent_options, @options)}
25
- # load and create SDK stub
54
+ # load SDK stub class on demand, as it's an optional gem
26
55
  $LOAD_PATH.unshift(Installation.instance.sdk_ruby_folder)
27
56
  require 'transfer_services_pb'
28
- # it stays
57
+ # keep PID for optional shutdown
29
58
  @daemon_pid = nil
59
+ daemon_endpoint = @options[:url]
60
+ Log.log.debug{Log.dump(:daemon_endpoint, daemon_endpoint)}
61
+ # retry loop
30
62
  begin
31
- @transfer_client = Transfersdk::TransferService::Stub.new("#{daemon_uri.host}:#{daemon_uri.port}", :this_channel_is_insecure)
63
+ # no address: local bind
64
+ daemon_endpoint = "#{LOCAL_SOCKET_ADDR}#{daemon_endpoint}" if daemon_endpoint.match?(/^#{PORT_SEP}[0-9]+$/o)
65
+ # Create stub (without credentials)
66
+ @transfer_client = Transfersdk::TransferService::Stub.new(daemon_endpoint, :this_channel_is_insecure)
67
+ # Initiate actual connection
32
68
  get_info_response = @transfer_client.get_info(Transfersdk::InstanceInfoRequest.new)
33
- Log.log.debug{"daemon info: #{get_info_response}"}
34
- Log.log.warn{'attached to existing daemon'} unless @options[:external] || @options[:keep]
69
+ Log.log.debug{"Daemon info: #{get_info_response}"}
70
+ Log.log.warn{'Attached to existing daemon'} unless @daemon_pid || @options[:external] || @options[:keep]
35
71
  at_exit{shutdown}
36
- rescue GRPC::Unavailable
37
- raise if @options[:external]
38
- raise "daemon started with PID #{@daemon_pid}, but connection failed to #{daemon_uri}}" unless @daemon_pid.nil?
72
+ rescue GRPC::Unavailable => e
73
+ # if transferd is external: do not start it, or other error
74
+ raise if @options[:external] || !e.message.include?('failed to connect')
75
+ # we already tried to start a daemon, but it failed
76
+ assert(@daemon_pid.nil?){"Daemon started with PID #{@daemon_pid}, but connection failed to #{daemon_endpoint}}"}
39
77
  Log.log.warn('no daemon present, starting daemon...') if @options[:external]
40
78
  # location of daemon binary
41
- bin_folder = File.realpath(File.join(Installation.instance.sdk_ruby_folder, '..'))
42
- # config file and logs are created in same folder
43
- generated_config_file_path = File.join(bin_folder, 'sdk.conf')
44
- log_base = File.join(bin_folder, 'transferd')
79
+ sdk_folder = File.realpath(File.join(Installation.instance.sdk_ruby_folder, '..'))
80
+ # transferd only supports local ip and port
81
+ daemon_uri = URI.parse("ipv4://#{daemon_endpoint}")
82
+ assert(daemon_uri.scheme.eql?('ipv4')){"Invalid scheme daemon URI #{daemon_endpoint}"}
45
83
  # create a config file for daemon
46
84
  config = {
47
85
  address: daemon_uri.host,
@@ -49,35 +87,35 @@ module Aspera
49
87
  fasp_runtime: {
50
88
  use_embedded: false,
51
89
  user_defined: {
52
- bin: bin_folder,
53
- etc: bin_folder
90
+ bin: sdk_folder,
91
+ etc: sdk_folder
54
92
  }
55
93
  }
56
94
  }
57
- File.write(generated_config_file_path, config.to_json)
58
- @daemon_pid = Process.spawn(Installation.instance.path(:transferd), '--config', generated_config_file_path, out: "#{log_base}.out", err: "#{log_base}.err")
95
+ # config file and logs are created in same folder
96
+ transferd_base_tmp = TempFileManager.instance.new_file_path_global('transferd')
97
+ Log.log.debug{"transferd base tmp #{transferd_base_tmp}"}
98
+ conf_file = "#{transferd_base_tmp}.conf"
99
+ log_stdout = "#{transferd_base_tmp}.out"
100
+ log_stderr = "#{transferd_base_tmp}.err"
101
+ File.write(conf_file, config.to_json)
102
+ @daemon_pid = Process.spawn(Installation.instance.path(:transferd), '--config', conf_file, out: log_stdout, err: log_stderr)
59
103
  begin
60
- # wait for process to initialize
104
+ # wait for process to initialize, max 2 seconds
61
105
  Timeout.timeout(2.0) do
106
+ # this returns if process dies (within 2 seconds)
62
107
  _, status = Process.wait2(@daemon_pid)
63
- raise "transfer daemon exited with status #{status.exitstatus}. Check files: #{log_base}.out #{log_base}.err"
108
+ raise "Transfer daemon exited with status #{status.exitstatus}. Check files: #{log_stdout} and #{log_stderr}"
64
109
  end
65
110
  rescue Timeout::Error
66
111
  nil
67
112
  end
68
- Log.log.debug{"daemon started with pid #{@daemon_pid}"}
113
+ Log.log.debug{"Daemon started with pid #{@daemon_pid}"}
69
114
  Process.detach(@daemon_pid) if @options[:keep]
70
- if daemon_uri.port.eql?(0)
71
- # if port is zero, a dynamic port was created, get it
72
- File.open("#{log_base}.out", 'r') do |file|
73
- file.each_line do |line|
74
- if (m = line.match(/Info: API Server: Listening on ([^:]+):(\d+) /))
75
- daemon_uri.port = m[2].to_i
76
- # no "break" , need to keep last one
77
- end
78
- end
79
- end
80
- end
115
+ at_exit {shutdown}
116
+ # update port for next connection attempt (if auto high port was requested)
117
+ daemon_endpoint = "#{LOCAL_SOCKET_ADDR}#{PORT_SEP}#{self.class.daemon_port_from_log(log_stdout)}" if is_local_auto_port
118
+ # local daemon started, try again
81
119
  retry
82
120
  end
83
121
  end
@@ -133,8 +171,12 @@ module Aspera
133
171
  end
134
172
 
135
173
  def shutdown
136
- if !@options[:keep] && !@daemon_pid.nil?
137
- Log.log.debug("stopping daemon #{@daemon_pid}")
174
+ stop_daemon unless @options[:keep]
175
+ end
176
+
177
+ def stop_daemon
178
+ if !@daemon_pid.nil?
179
+ Log.log.debug("Stopping daemon #{@daemon_pid}")
138
180
  Process.kill('INT', @daemon_pid)
139
181
  _, status = Process.wait2(@daemon_pid)
140
182
  Log.log.debug("daemon stopped #{status}")