aspera-cli 4.17.0 → 4.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +3 -4
  3. data/CHANGELOG.md +33 -0
  4. data/CONTRIBUTING.md +15 -1
  5. data/README.md +711 -432
  6. data/bin/ascli +5 -0
  7. data/bin/asession +2 -2
  8. data/examples/build_package.sh +28 -0
  9. data/lib/aspera/agent/alpha.rb +10 -8
  10. data/lib/aspera/agent/base.rb +9 -6
  11. data/lib/aspera/agent/connect.rb +7 -8
  12. data/lib/aspera/agent/direct.rb +56 -37
  13. data/lib/aspera/agent/httpgw.rb +23 -324
  14. data/lib/aspera/agent/node.rb +19 -20
  15. data/lib/aspera/agent/trsdk.rb +19 -20
  16. data/lib/aspera/api/aoc.rb +17 -14
  17. data/lib/aspera/api/cos_node.rb +4 -4
  18. data/lib/aspera/api/httpgw.rb +342 -0
  19. data/lib/aspera/api/node.rb +135 -89
  20. data/lib/aspera/ascmd.rb +4 -3
  21. data/lib/aspera/ascp/installation.rb +15 -7
  22. data/lib/aspera/ascp/management.rb +2 -2
  23. data/lib/aspera/ascp/products.rb +1 -1
  24. data/lib/aspera/cli/basic_auth_plugin.rb +5 -9
  25. data/lib/aspera/cli/extended_value.rb +35 -16
  26. data/lib/aspera/cli/formatter.rb +161 -70
  27. data/lib/aspera/cli/hints.rb +18 -0
  28. data/lib/aspera/cli/main.rb +32 -39
  29. data/lib/aspera/cli/manager.rb +151 -119
  30. data/lib/aspera/cli/plugin.rb +27 -21
  31. data/lib/aspera/cli/plugin_factory.rb +31 -20
  32. data/lib/aspera/cli/plugins/alee.rb +14 -2
  33. data/lib/aspera/cli/plugins/aoc.rb +152 -141
  34. data/lib/aspera/cli/plugins/ats.rb +1 -1
  35. data/lib/aspera/cli/plugins/config.rb +72 -65
  36. data/lib/aspera/cli/plugins/console.rb +8 -5
  37. data/lib/aspera/cli/plugins/faspex.rb +32 -23
  38. data/lib/aspera/cli/plugins/faspex5.rb +232 -156
  39. data/lib/aspera/cli/plugins/faspio.rb +85 -0
  40. data/lib/aspera/cli/plugins/httpgw.rb +55 -0
  41. data/lib/aspera/cli/plugins/node.rb +129 -64
  42. data/lib/aspera/cli/plugins/orchestrator.rb +33 -30
  43. data/lib/aspera/cli/plugins/preview.rb +7 -3
  44. data/lib/aspera/cli/plugins/server.rb +6 -6
  45. data/lib/aspera/cli/plugins/shares.rb +16 -14
  46. data/lib/aspera/cli/special_values.rb +13 -0
  47. data/lib/aspera/cli/sync_actions.rb +10 -10
  48. data/lib/aspera/cli/transfer_agent.rb +7 -6
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/environment.rb +70 -9
  51. data/lib/aspera/faspex_gw.rb +5 -4
  52. data/lib/aspera/faspex_postproc.rb +2 -2
  53. data/lib/aspera/log.rb +6 -3
  54. data/lib/aspera/node_simulator.rb +2 -2
  55. data/lib/aspera/oauth/base.rb +31 -19
  56. data/lib/aspera/oauth/factory.rb +12 -13
  57. data/lib/aspera/oauth/generic.rb +1 -0
  58. data/lib/aspera/oauth/jwt.rb +18 -15
  59. data/lib/aspera/oauth/url_json.rb +8 -6
  60. data/lib/aspera/oauth/web.rb +2 -2
  61. data/lib/aspera/persistency_folder.rb +2 -2
  62. data/lib/aspera/preview/generator.rb +3 -3
  63. data/lib/aspera/preview/options.rb +3 -3
  64. data/lib/aspera/preview/terminal.rb +4 -4
  65. data/lib/aspera/preview/utils.rb +3 -3
  66. data/lib/aspera/proxy_auto_config.rb +5 -1
  67. data/lib/aspera/rest.rb +105 -88
  68. data/lib/aspera/rest_call_error.rb +1 -1
  69. data/lib/aspera/rest_error_analyzer.rb +2 -2
  70. data/lib/aspera/rest_errors_aspera.rb +1 -1
  71. data/lib/aspera/resumer.rb +1 -1
  72. data/lib/aspera/secret_hider.rb +2 -4
  73. data/lib/aspera/ssh.rb +1 -1
  74. data/lib/aspera/transfer/parameters.rb +39 -36
  75. data/lib/aspera/transfer/spec.rb +2 -0
  76. data/lib/aspera/transfer/sync.rb +2 -1
  77. data/lib/aspera/transfer/uri.rb +1 -1
  78. data/lib/aspera/uri_reader.rb +5 -4
  79. data/lib/aspera/web_auth.rb +1 -1
  80. data/lib/aspera/web_server_simple.rb +4 -3
  81. data.tar.gz.sig +0 -0
  82. metadata +7 -4
  83. metadata.gz.sig +0 -0
  84. data/lib/aspera/cli/plugins/bss.rb +0 -71
  85. data/lib/aspera/open_application.rb +0 -71
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/log'
4
+ require 'aspera/rest'
5
+ require 'aspera/transfer/faux_file'
6
+ require 'aspera/assert'
7
+ require 'securerandom'
8
+ require 'websocket'
9
+ require 'base64'
10
+ require 'json'
11
+
12
+ module Aspera
13
+ module Api
14
+ # Start a transfer using Aspera HTTP Gateway, using web socket secure for uploads
15
+ # ref: https://api.ibm.com/explorer/catalog/aspera/product/ibm-aspera/api/http-gateway-api/doc/guides-toc
16
+ # https://developer.ibm.com/apis/catalog?search=%22aspera%20http%22
17
+ # HTTP GW Upload protocol:
18
+ # # type Contents Ack Counter
19
+ # v1
20
+ # 0 JSON.transfer_spec Transfer Spec "end upload" sent_general
21
+ # 1.. JSON.slice_upload File base64 chunks "end upload" sent_general
22
+ # v2
23
+ # 0 JSON.transfer_spec Transfer Spec "end upload" sent_general
24
+ # 1 JSON.slice_upload File start "end_slice_upload" sent_v2_delimiter
25
+ # 2.. Binary File binary chunks "end upload" sent_general
26
+ # last JSON.slice_upload File end "end_slice_upload" sent_v2_delimiter
27
+ class Httpgw < Aspera::Rest
28
+ DEFAULT_BASE_PATH = '/aspera/http-gwy'
29
+ INFO_ENDPOINT = 'info'
30
+ MSG_SEND_TRANSFER_SPEC = 'transfer_spec'
31
+ MSG_SEND_SLICE_UPLOAD = 'slice_upload'
32
+ MSG_RECV_DATA_RECEIVED_SIGNAL = 'end upload'
33
+ MSG_RECV_SLICE_UPLOAD_SIGNAL = 'end_slice_upload'
34
+ # upload API versions
35
+ API_V1 = 'v1'
36
+ API_V2 = 'v2'
37
+ THR_RECV = 'recv'
38
+ LOG_WS_SEND = 'ws: send: '.red
39
+ LOG_WS_RECV = "ws: #{THR_RECV}: ".green
40
+ private_constant :MSG_RECV_DATA_RECEIVED_SIGNAL, :MSG_RECV_SLICE_UPLOAD_SIGNAL
41
+ # send message on http gw web socket
42
+ def ws_snd_json(msg_type, payload)
43
+ if msg_type.eql?(MSG_SEND_SLICE_UPLOAD) && @upload_version.eql?(API_V2)
44
+ @shared_info[:count][:sent_v2_delimiter] += 1
45
+ else
46
+ @shared_info[:count][:sent_general] += 1
47
+ end
48
+ Log.log.debug do
49
+ log_data = payload.dup
50
+ log_data[:data] = "[data #{log_data[:data].length} bytes]" if log_data.key?(:data)
51
+ "#{LOG_WS_SEND}json: #{msg_type}: #{JSON.generate(log_data)}"
52
+ end
53
+ ws_send(ws_type: :text, data: JSON.generate({msg_type => payload}))
54
+ end
55
+
56
+ # send data on http gw web socket
57
+ def ws_send(ws_type:, data:)
58
+ Log.log.debug{"#{LOG_WS_SEND}sending: #{ws_type} (#{data&.length || 0} bytes)"}
59
+ @shared_info[:count][:sent_general] += 1 if ws_type.eql?(:binary)
60
+ frame_generator = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: ws_type, version: @ws_handshake.version)
61
+ @ws_io.write(frame_generator.to_s)
62
+ if @synchronous
63
+ @shared_info[:mutex].synchronize do
64
+ # if read thread exited, there will be no more updates
65
+ # we allow for 1 of difference else it stays blocked
66
+ while @ws_read_thread.alive? &&
67
+ @shared_info[:read_exception].nil? &&
68
+ (((@shared_info[:count][:sent_general] - @shared_info[:count][:received_general]) > 1) ||
69
+ ((@shared_info[:count][:received_v2_delimiter] - @shared_info[:count][:sent_v2_delimiter]) > 1))
70
+ if !@shared_info[:cond_var].wait(@shared_info[:mutex], 2.0)
71
+ Log.log.debug{"#{LOG_WS_SEND}#{'timeout'.blue}: #{@shared_info[:count]}"}
72
+ end
73
+ end
74
+ end
75
+ end
76
+ raise @shared_info[:read_exception] unless @shared_info[:read_exception].nil?
77
+ Log.log.debug{"#{LOG_WS_SEND}counts: #{@shared_info[:count]}"}
78
+ end
79
+
80
+ # message processing for read thread
81
+ def process_received_message(message)
82
+ Log.log.debug{"#{LOG_WS_RECV}message: [#{message}] (#{message.class})"}
83
+ if message.eql?(MSG_RECV_DATA_RECEIVED_SIGNAL)
84
+ @shared_info[:mutex].synchronize do
85
+ @shared_info[:count][:received_general] += 1
86
+ @shared_info[:cond_var].signal
87
+ end
88
+ elsif message.eql?(MSG_RECV_SLICE_UPLOAD_SIGNAL)
89
+ @shared_info[:mutex].synchronize do
90
+ @shared_info[:count][:received_v2_delimiter] += 1
91
+ @shared_info[:cond_var].signal
92
+ end
93
+ else
94
+ message.chomp!
95
+ error_message =
96
+ if message.start_with?('"') && message.end_with?('"')
97
+ # remove double quotes : 1..-2
98
+ JSON.parse(Base64.strict_decode64(message.chomp[1..-2]))['message']
99
+ elsif message.start_with?('{') && message.end_with?('}')
100
+ JSON.parse(message)['message']
101
+ else
102
+ "unknown message from gateway: [#{message}]"
103
+ end
104
+ raise error_message
105
+ end
106
+ end
107
+
108
+ # main function of read thread
109
+ def process_read_thread
110
+ Log.log.debug{"#{LOG_WS_RECV}read thread started"}
111
+ frame_parser = ::WebSocket::Frame::Incoming::Client.new(version: @ws_handshake.version)
112
+ until @ws_io.eof?
113
+ begin # rubocop:disable Style/RedundantBegin
114
+ # ready byte by byte until frame is ready
115
+ # blocking read
116
+ byte = @ws_io.read(1)
117
+ Log.log.trace1{"#{LOG_WS_RECV}read: #{byte} (#{byte.class}) eof=#{@ws_io.eof?}"}
118
+ frame_parser << byte
119
+ frame_ok = frame_parser.next
120
+ next if frame_ok.nil?
121
+ process_received_message(frame_ok.data.to_s)
122
+ Log.log.debug{"#{LOG_WS_RECV}counts: #{@shared_info[:count]}"}
123
+ rescue => e
124
+ Log.log.debug{"#{LOG_WS_RECV}Exception: #{e}"}
125
+ @shared_info[:mutex].synchronize do
126
+ @shared_info[:read_exception] = e
127
+ @shared_info[:cond_var].signal
128
+ end
129
+ break
130
+ end
131
+ end
132
+ Log.log.debug do
133
+ "#{LOG_WS_RECV}exception: #{@shared_info[:read_exception]},cls=#{@shared_info[:read_exception].class})"
134
+ end unless @shared_info[:read_exception].nil?
135
+ Log.log.debug{"#{LOG_WS_RECV}read thread stopped (ws eof=#{@ws_io.eof?})"}
136
+ end
137
+
138
+ def upload(transfer_spec)
139
+ # identify this session uniquely
140
+ session_id = SecureRandom.uuid
141
+ @notify_cb&.call(session_id: nil, type: :pre_start, info: 'starting')
142
+ # total size of all files
143
+ total_bytes_to_transfer = 0
144
+ # we need to keep track of actual file path because transfer spec is modified to be sent in web socket
145
+ files_to_read = []
146
+ # get source root or nil
147
+ source_root = transfer_spec.key?('source_root') && !transfer_spec['source_root'].empty? ? transfer_spec['source_root'] : nil
148
+ # source root is ignored by GW, used only here
149
+ transfer_spec.delete('source_root')
150
+ # compute total size of files to upload (for progress)
151
+ # modify transfer spec to be suitable for GW
152
+ transfer_spec['paths'].each do |item|
153
+ # save actual file location to be able read contents later
154
+ file_to_add = Transfer::FauxFile.open(item['source'])
155
+ if file_to_add
156
+ item['source'] = file_to_add.path
157
+ item['file_size'] = file_to_add.size
158
+ else
159
+ file_to_add = item['source']
160
+ # add source root if needed
161
+ file_to_add = File.join(source_root, file_to_add) unless source_root.nil?
162
+ # GW expects a simple file name in 'source' but if user wants to change the name, we take it
163
+ item['source'] = File.basename(item['destination'].nil? ? item['source'] : item['destination'])
164
+ item['file_size'] = File.size(file_to_add)
165
+ end
166
+ # save so that we can actually read the file later
167
+ files_to_read.push(file_to_add)
168
+ total_bytes_to_transfer += item['file_size']
169
+ end
170
+ # TODO: check that this is available in endpoints: @api_info['endpoints']
171
+ upload_url = File.join(@gw_root_url, @upload_version, 'upload')
172
+ @notify_cb&.call(session_id: nil, type: :pre_start, info: 'connecting wss')
173
+ # open web socket to end point (equivalent to Net::HTTP.start)
174
+ http_session = Rest.start_http_session(upload_url)
175
+ # get the underlying socket i/o
176
+ @ws_io = Rest.io_http_session(http_session)
177
+ @ws_handshake = ::WebSocket::Handshake::Client.new(url: upload_url, headers: {})
178
+ @ws_io.write(@ws_handshake.to_s)
179
+ sleep(0.1)
180
+ @ws_handshake << @ws_io.readuntil("\r\n\r\n")
181
+ Aspera.assert(@ws_handshake.finished?){'Error in websocket handshake'}
182
+ Log.log.debug{"#{LOG_WS_SEND}handshake success"}
183
+ # start read thread after handshake
184
+ @ws_read_thread = Thread.new {process_read_thread}
185
+ @notify_cb&.call(session_id: session_id, type: :session_start)
186
+ @notify_cb&.call(session_id: session_id, type: :session_size, info: total_bytes_to_transfer)
187
+ sleep(1)
188
+ # data shared between main thread and read thread
189
+ @shared_info = {
190
+ read_exception: nil, # error message if any in callback
191
+ count: {
192
+ sent_general: 0,
193
+ received_general: 0,
194
+ sent_v2_delimiter: 0,
195
+ received_v2_delimiter: 0
196
+ },
197
+ mutex: Mutex.new,
198
+ cond_var: ConditionVariable.new
199
+ }
200
+ # notify progress bar
201
+ @notify_cb&.call(type: :session_size, session_id: session_id, info: total_bytes_to_transfer)
202
+ # first step send transfer spec
203
+ Log.log.debug{Log.dump(:ws_spec, transfer_spec)}
204
+ ws_snd_json(MSG_SEND_TRANSFER_SPEC, transfer_spec)
205
+ # current file index
206
+ file_index = 0
207
+ # aggregate size sent
208
+ session_sent_bytes = 0
209
+ # process each file
210
+ transfer_spec['paths'].each do |item|
211
+ slice_info = {
212
+ name: nil,
213
+ # TODO: get mime type?
214
+ type: 'application/octet-stream',
215
+ size: item['file_size'],
216
+ slice: 0, # current slice index
217
+ # index of last slice (i.e number of slices - 1)
218
+ last_slice: (item['file_size'] - 1) / @upload_chunk_size,
219
+ fileIndex: file_index
220
+ }
221
+ file = files_to_read[file_index]
222
+ if file.is_a?(Transfer::FauxFile)
223
+ slice_info[:name] = file.path
224
+ else
225
+ file = File.open(file)
226
+ slice_info[:name] = File.basename(item[item['destination'].nil? ? 'source' : 'destination'])
227
+ end
228
+ begin
229
+ until file.eof?
230
+ slice_bin_data = file.read(@upload_chunk_size)
231
+ # interrupt main thread if read thread failed
232
+ raise @shared_info[:read_exception] unless @shared_info[:read_exception].nil?
233
+ begin
234
+ if @upload_version.eql?(API_V1)
235
+ slice_info[:data] = Base64.strict_encode64(slice_bin_data)
236
+ ws_snd_json(MSG_SEND_SLICE_UPLOAD, slice_info)
237
+ else
238
+ # send once, before data, at beginning
239
+ ws_snd_json(MSG_SEND_SLICE_UPLOAD, slice_info) if slice_info[:slice].eql?(0)
240
+ ws_send(ws_type: :binary, data: slice_bin_data)
241
+ Log.log.debug{"#{LOG_WS_SEND}buffer: file: #{file_index}, slice: #{slice_info[:slice]}/#{slice_info[:last_slice]}"}
242
+ # send once, after data, at end
243
+ ws_snd_json(MSG_SEND_SLICE_UPLOAD, slice_info) if slice_info[:slice].eql?(slice_info[:last_slice])
244
+ end
245
+ rescue Errno::EPIPE => e
246
+ raise @shared_info[:read_exception] unless @shared_info[:read_exception].nil?
247
+ raise e
248
+ rescue Net::ReadTimeout => e
249
+ Log.log.warn{'A timeout condition using HTTPGW may signal a permission problem on destination. Check ascp logs on httpgw.'}
250
+ raise e
251
+ end
252
+ session_sent_bytes += slice_bin_data.length
253
+ @notify_cb&.call(type: :transfer, session_id: session_id, info: session_sent_bytes)
254
+ slice_info[:slice] += 1
255
+ end
256
+ ensure
257
+ file.close
258
+ end
259
+ file_index += 1
260
+ end
261
+ # throttling may have skipped last one
262
+ @notify_cb&.call(type: :transfer, session_id: session_id, info: session_sent_bytes)
263
+ @notify_cb&.call(type: :end, session_id: session_id)
264
+ ws_send(ws_type: :close, data: nil)
265
+ Log.log.debug("Finished upload, waiting for end of #{THR_RECV} thread.")
266
+ @ws_read_thread.join
267
+ Log.log.debug{'Read thread joined'}
268
+ # session no more used
269
+ @ws_io = nil
270
+ http_session&.finish
271
+ end
272
+
273
+ def download(transfer_spec)
274
+ transfer_spec['zip_required'] ||= false
275
+ transfer_spec['source_root'] ||= '/'
276
+ # is normally provided by application, like package name
277
+ if !transfer_spec.key?('download_name')
278
+ # by default it is the name of first file
279
+ download_name = File.basename(transfer_spec['paths'].first['source'], '.*')
280
+ # ands add indication of number of files if there is more than one
281
+ if transfer_spec['paths'].length > 1
282
+ download_name += " #{transfer_spec['paths'].length} Files"
283
+ end
284
+ transfer_spec['download_name'] = download_name
285
+ end
286
+ creation = create('download', {'transfer_spec' => transfer_spec})[:data]
287
+ transfer_uuid = creation['url'].split('/').last
288
+ file_name =
289
+ if transfer_spec['zip_required'] || transfer_spec['paths'].length > 1
290
+ # it is a zip file if zip is required or there is more than 1 file
291
+ transfer_spec['download_name'] + '.zip'
292
+ else
293
+ # it is a plain file if we don't require zip and there is only one file
294
+ File.basename(transfer_spec['paths'].first['source'])
295
+ end
296
+ file_path = File.join(transfer_spec['destination_root'], file_name)
297
+ call(operation: 'GET', subpath: "download/#{transfer_uuid}", save_to_file: file_path)
298
+ end
299
+
300
+ def info
301
+ return @api_info
302
+ end
303
+
304
+ # @param url [String] URL of the HTTP Gateway, without version
305
+ def initialize(
306
+ url:,
307
+ api_version: API_V2,
308
+ upload_chunk_size: 64_000,
309
+ synchronous: false,
310
+ notify_cb: nil,
311
+ **opts
312
+ )
313
+ Log.log.debug{Log.dump(:gw_url, url)}
314
+ # add scheme if missing
315
+ url = "https://#{url}" unless url.match?(%r{^[a-z]{1,6}://})
316
+ raise 'GW URL shall be with scheme https' unless url.start_with?('https://')
317
+ # remove trailing slash and version (o=only once) if present
318
+ # TODO: issue warning ?
319
+ url = url.gsub(%r{/+$}, '').gsub(%r{/#{API_V1}$}o, '')
320
+ # assume GW is always under specific path (TODO: remove this ?)
321
+ url = File.join(url, DEFAULT_BASE_PATH) unless url.end_with?(DEFAULT_BASE_PATH)
322
+ @gw_root_url = url
323
+ super(base_url: "#{@gw_root_url}/#{API_V1}", **opts)
324
+ @upload_version = api_version
325
+ @upload_chunk_size = upload_chunk_size
326
+ @synchronous = synchronous
327
+ @notify_cb = notify_cb
328
+ # get API info
329
+ @api_info = read('info')[:data].freeze
330
+ Log.log.debug{Log.dump(:api_info, @api_info)}
331
+ # web socket endpoint: by default use v2 (newer gateways), without base64 encoding
332
+ # is the latest supported? else revert to old api
333
+ if !@upload_version.eql?(API_V1)
334
+ if !@api_info['endpoints'].any?{|i|i.include?(@upload_version)}
335
+ Log.log.warn{"API version #{@upload_version} not supported, reverting to #{API_V1}"}
336
+ @upload_version = API_V1
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end