aspera-cli 4.15.0 → 4.16.0

Sign up to get free protection for your applications and to get access to all the features.
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}")