aspera-cli 4.17.0 → 4.18.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +2 -4
- data/CHANGELOG.md +23 -0
- data/CONTRIBUTING.md +15 -1
- data/README.md +620 -378
- data/bin/ascli +5 -0
- data/bin/asession +2 -2
- data/lib/aspera/agent/alpha.rb +6 -4
- data/lib/aspera/agent/base.rb +9 -6
- data/lib/aspera/agent/connect.rb +4 -4
- data/lib/aspera/agent/direct.rb +56 -37
- data/lib/aspera/agent/httpgw.rb +23 -324
- data/lib/aspera/agent/node.rb +19 -20
- data/lib/aspera/agent/trsdk.rb +19 -20
- data/lib/aspera/api/aoc.rb +17 -14
- data/lib/aspera/api/cos_node.rb +4 -4
- data/lib/aspera/api/httpgw.rb +339 -0
- data/lib/aspera/api/node.rb +34 -21
- data/lib/aspera/ascmd.rb +4 -3
- data/lib/aspera/ascp/installation.rb +15 -7
- data/lib/aspera/ascp/management.rb +2 -2
- data/lib/aspera/cli/basic_auth_plugin.rb +5 -9
- data/lib/aspera/cli/extended_value.rb +12 -6
- data/lib/aspera/cli/formatter.rb +155 -65
- data/lib/aspera/cli/hints.rb +18 -0
- data/lib/aspera/cli/main.rb +22 -29
- data/lib/aspera/cli/manager.rb +53 -36
- data/lib/aspera/cli/plugin.rb +26 -17
- data/lib/aspera/cli/plugin_factory.rb +31 -20
- data/lib/aspera/cli/plugins/alee.rb +14 -2
- data/lib/aspera/cli/plugins/aoc.rb +141 -131
- data/lib/aspera/cli/plugins/ats.rb +1 -1
- data/lib/aspera/cli/plugins/config.rb +52 -46
- data/lib/aspera/cli/plugins/console.rb +8 -5
- data/lib/aspera/cli/plugins/faspex.rb +27 -19
- data/lib/aspera/cli/plugins/faspex5.rb +222 -149
- data/lib/aspera/cli/plugins/faspio.rb +85 -0
- data/lib/aspera/cli/plugins/httpgw.rb +55 -0
- data/lib/aspera/cli/plugins/node.rb +86 -29
- data/lib/aspera/cli/plugins/orchestrator.rb +31 -29
- data/lib/aspera/cli/plugins/preview.rb +6 -2
- data/lib/aspera/cli/plugins/server.rb +5 -5
- data/lib/aspera/cli/plugins/shares.rb +16 -14
- data/lib/aspera/cli/sync_actions.rb +6 -6
- data/lib/aspera/cli/transfer_agent.rb +5 -4
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/environment.rb +7 -6
- data/lib/aspera/faspex_gw.rb +5 -4
- data/lib/aspera/faspex_postproc.rb +2 -2
- data/lib/aspera/log.rb +6 -3
- data/lib/aspera/node_simulator.rb +2 -2
- data/lib/aspera/oauth/base.rb +31 -19
- data/lib/aspera/oauth/factory.rb +12 -13
- data/lib/aspera/oauth/generic.rb +1 -0
- data/lib/aspera/oauth/jwt.rb +18 -15
- data/lib/aspera/oauth/url_json.rb +8 -6
- data/lib/aspera/open_application.rb +5 -7
- data/lib/aspera/persistency_folder.rb +2 -2
- data/lib/aspera/preview/generator.rb +3 -3
- data/lib/aspera/preview/options.rb +3 -3
- data/lib/aspera/preview/terminal.rb +4 -4
- data/lib/aspera/preview/utils.rb +3 -3
- data/lib/aspera/proxy_auto_config.rb +5 -1
- data/lib/aspera/rest.rb +60 -74
- data/lib/aspera/rest_call_error.rb +1 -1
- data/lib/aspera/rest_error_analyzer.rb +2 -2
- data/lib/aspera/rest_errors_aspera.rb +1 -1
- data/lib/aspera/resumer.rb +1 -1
- data/lib/aspera/secret_hider.rb +2 -4
- data/lib/aspera/ssh.rb +1 -1
- data/lib/aspera/transfer/parameters.rb +39 -36
- data/lib/aspera/transfer/spec.rb +2 -0
- data/lib/aspera/transfer/sync.rb +2 -1
- data/lib/aspera/transfer/uri.rb +1 -1
- data/lib/aspera/uri_reader.rb +5 -4
- data/lib/aspera/web_auth.rb +1 -1
- data/lib/aspera/web_server_simple.rb +4 -3
- data.tar.gz.sig +0 -0
- metadata +5 -3
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/plugins/bss.rb +0 -71
@@ -0,0 +1,339 @@
|
|
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
|
+
# add scheme if missing
|
314
|
+
url = "https://#{url}" unless url.match?(%r{^[a-z]{1,6}://})
|
315
|
+
raise 'GW URL shall be with scheme https' unless url.start_with?('https://')
|
316
|
+
# remove trailing slash and version if any
|
317
|
+
url = url.gsub(%r{/+$}, '').gsub(%r{/#{API_V1}$}o, '')
|
318
|
+
url = File.join(url, DEFAULT_BASE_PATH) unless url.end_with?(DEFAULT_BASE_PATH)
|
319
|
+
@gw_root_url = url
|
320
|
+
super(base_url: "#{@gw_root_url}/#{API_V1}", **opts)
|
321
|
+
@upload_version = api_version
|
322
|
+
@upload_chunk_size = upload_chunk_size
|
323
|
+
@synchronous = synchronous
|
324
|
+
@notify_cb = notify_cb
|
325
|
+
# get API info
|
326
|
+
@api_info = read('info')[:data].freeze
|
327
|
+
Log.log.debug{Log.dump(:api_info, @api_info)}
|
328
|
+
# web socket endpoint: by default use v2 (newer gateways), without base64 encoding
|
329
|
+
# is the latest supported? else revert to old api
|
330
|
+
if !@upload_version.eql?(API_V1)
|
331
|
+
if !@api_info['endpoints'].any?{|i|i.include?(@upload_version)}
|
332
|
+
Log.log.warn{"API version #{@upload_version} not supported, reverting to #{API_V1}"}
|
333
|
+
@upload_version = API_V1
|
334
|
+
end
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
data/lib/aspera/api/node.rb
CHANGED
@@ -14,21 +14,23 @@ module Aspera
|
|
14
14
|
module Api
|
15
15
|
# Provides additional functions using node API with gen4 extensions (access keys)
|
16
16
|
class Node < Aspera::Rest
|
17
|
-
# permissions
|
17
|
+
# node api permissions
|
18
18
|
ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
|
19
|
+
HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
|
20
|
+
SCOPE_SEPARATOR = ':'
|
21
|
+
SCOPE_USER = 'user:all'
|
22
|
+
SCOPE_ADMIN = 'admin:all'
|
23
|
+
SCOPE_NODE_PREFIX = 'node.'
|
19
24
|
# prefix for ruby code for filter (deprecated)
|
20
25
|
MATCH_EXEC_PREFIX = 'exec:'
|
21
26
|
MATCH_TYPES = [String, Proc, Regexp, NilClass].freeze
|
22
|
-
HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
|
23
27
|
PATH_SEPARATOR = '/'
|
24
|
-
TS_FIELDS_TO_COPY = %w[remote_host remote_user ssh_port fasp_port wss_enabled wss_port].freeze
|
25
|
-
SCOPE_USER = 'user:all'
|
26
|
-
SCOPE_ADMIN = 'admin:all'
|
27
|
-
SCOPE_PREFIX = 'node.'
|
28
|
-
SCOPE_SEPARATOR = ':'
|
29
28
|
SIGNATURE_DELIMITER = '==SIGNATURE=='
|
30
29
|
BEARER_TOKEN_VALIDITY_DEFAULT = 86400
|
31
30
|
BEARER_TOKEN_SCOPE_DEFAULT = SCOPE_USER
|
31
|
+
private_constant :MATCH_EXEC_PREFIX, :MATCH_TYPES,
|
32
|
+
:SIGNATURE_DELIMITER, :BEARER_TOKEN_VALIDITY_DEFAULT, :BEARER_TOKEN_SCOPE_DEFAULT,
|
33
|
+
:SCOPE_SEPARATOR, :SCOPE_NODE_PREFIX
|
32
34
|
|
33
35
|
# register node special token decoder
|
34
36
|
OAuth::Factory.instance.register_decoder(lambda{|token|Node.decode_bearer_token(token)})
|
@@ -62,14 +64,14 @@ module Aspera
|
|
62
64
|
|
63
65
|
# node API scopes
|
64
66
|
def token_scope(access_key, scope)
|
65
|
-
return [
|
67
|
+
return [SCOPE_NODE_PREFIX, access_key, SCOPE_SEPARATOR, scope].join('')
|
66
68
|
end
|
67
69
|
|
68
70
|
def decode_scope(scope)
|
69
71
|
items = scope.split(SCOPE_SEPARATOR, 2)
|
70
72
|
Aspera.assert(items.length.eql?(2)){"invalid scope: #{scope}"}
|
71
|
-
Aspera.assert(items[0].start_with?(
|
72
|
-
return {access_key: items[0][
|
73
|
+
Aspera.assert(items[0].start_with?(SCOPE_NODE_PREFIX)){"invalid scope: #{scope}"}
|
74
|
+
return {access_key: items[0][SCOPE_NODE_PREFIX.length..-1], scope: items[1]}
|
73
75
|
end
|
74
76
|
|
75
77
|
# Create an Aspera Node bearer token
|
@@ -134,6 +136,7 @@ module Aspera
|
|
134
136
|
@app_info = app_info
|
135
137
|
# this is added to transfer spec, for instance to add tags (COS)
|
136
138
|
@add_tspec = add_tspec
|
139
|
+
@std_t_spec_cache = nil
|
137
140
|
if !@app_info.nil?
|
138
141
|
REQUIRED_APP_INFO_FIELDS.each do |field|
|
139
142
|
Aspera.assert(@app_info.key?(field)){"app_info lacks field #{field}"}
|
@@ -205,7 +208,7 @@ module Aspera
|
|
205
208
|
end
|
206
209
|
end
|
207
210
|
end
|
208
|
-
end
|
211
|
+
end
|
209
212
|
|
210
213
|
# Navigate the path from given file id
|
211
214
|
# @param top_file_id [String] id initial file id
|
@@ -271,7 +274,23 @@ module Aspera
|
|
271
274
|
end
|
272
275
|
|
273
276
|
def refreshed_transfer_token
|
274
|
-
return
|
277
|
+
return oauth.token(refresh: true)
|
278
|
+
end
|
279
|
+
|
280
|
+
# @return part of transfer spec with transport parameters only
|
281
|
+
def transport_params
|
282
|
+
if @std_t_spec_cache.nil?
|
283
|
+
# retrieve values from API (and keep a copy/cache)
|
284
|
+
full_spec = create(
|
285
|
+
'files/download_setup',
|
286
|
+
{transfer_requests: [{transfer_request: {paths: [{source: '/'}]}}]}
|
287
|
+
)[:data]['transfer_specs'].first['transfer_spec']
|
288
|
+
# set available fields
|
289
|
+
@std_t_spec_cache = Transfer::Spec::TRANSPORT_FIELDS.each_with_object({}) do |i, h|
|
290
|
+
h[i] = full_spec[i] if full_spec.key?(i)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
return @std_t_spec_cache
|
275
294
|
end
|
276
295
|
|
277
296
|
# Create transfer spec for gen4
|
@@ -285,9 +304,9 @@ module Aspera
|
|
285
304
|
ak_token = Rest.basic_token(auth_params[:username], auth_params[:password])
|
286
305
|
when :oauth2
|
287
306
|
ak_name = params[:headers][HEADER_X_ASPERA_ACCESS_KEY]
|
288
|
-
# TODO: token_generation_lambda = lambda{|do_refresh|
|
307
|
+
# TODO: token_generation_lambda = lambda{|do_refresh|oauth.token(refresh: do_refresh)}
|
289
308
|
# get bearer token, possibly use cache
|
290
|
-
ak_token =
|
309
|
+
ak_token = oauth.token(refresh: false)
|
291
310
|
else Aspera.error_unexpected_value(auth_params[:type])
|
292
311
|
end
|
293
312
|
transfer_spec = {
|
@@ -327,13 +346,7 @@ module Aspera
|
|
327
346
|
transfer_spec[i] = settings[i] if settings.key?(i)
|
328
347
|
end if settings.is_a?(Hash)
|
329
348
|
else
|
330
|
-
|
331
|
-
@std_t_spec_cache ||= create(
|
332
|
-
'files/download_setup',
|
333
|
-
{transfer_requests: [{ transfer_request: {paths: [{'source' => '/'}] } }] }
|
334
|
-
)[:data]['transfer_specs'].first['transfer_spec']
|
335
|
-
# copy some parts
|
336
|
-
TS_FIELDS_TO_COPY.each {|i| transfer_spec[i] = @std_t_spec_cache[i] if @std_t_spec_cache.key?(i)}
|
349
|
+
transfer_spec.merge!(transport_params)
|
337
350
|
end
|
338
351
|
Log.log.warn{"Expected transfer user: #{Transfer::Spec::ACCESS_KEY_TRANSFER_USER}, but have #{transfer_spec['remote_user']}"} \
|
339
352
|
unless transfer_spec['remote_user'].eql?(Transfer::Spec::ACCESS_KEY_TRANSFER_USER)
|
data/lib/aspera/ascmd.rb
CHANGED
@@ -22,6 +22,7 @@ module Aspera
|
|
22
22
|
mv: 2,
|
23
23
|
rm: 1
|
24
24
|
}.freeze
|
25
|
+
private_constant :OPS_ARGS
|
25
26
|
# list of supported actions
|
26
27
|
OPERATIONS = OPS_ARGS.keys.freeze
|
27
28
|
|
@@ -94,7 +95,7 @@ module Aspera
|
|
94
95
|
raise Error.new(result[:errno], result[:errstr], action_sym, arguments) if
|
95
96
|
result.is_a?(Hash) && (result.keys.sort == TYPES_DESCR[:error][:fields].map{|i|i[:name]}.sort)
|
96
97
|
return result
|
97
|
-
end
|
98
|
+
end
|
98
99
|
|
99
100
|
# This exception is raised when +ascmd+ returns an error.
|
100
101
|
class Error < StandardError
|
@@ -103,7 +104,7 @@ module Aspera
|
|
103
104
|
|
104
105
|
def message; "ascmd: #{@errstr} (#{@errno})"; end
|
105
106
|
def extended_message; "ascmd: errno=#{@errno} errstr=\"#{@errstr}\" command=#{@command} arguments=#{@arguments&.join(',')}"; end
|
106
|
-
end
|
107
|
+
end
|
107
108
|
|
108
109
|
# description of result structures (see ascmdtypes.h). Base types are big endian
|
109
110
|
# key = name of type
|
@@ -211,7 +212,7 @@ module Aspera
|
|
211
212
|
end
|
212
213
|
end
|
213
214
|
else Aspera.error_unexpected_value(type_descr[:decode])
|
214
|
-
end
|
215
|
+
end
|
215
216
|
return result
|
216
217
|
end
|
217
218
|
end
|
@@ -173,8 +173,7 @@ module Aspera
|
|
173
173
|
return exe_version
|
174
174
|
end
|
175
175
|
|
176
|
-
def
|
177
|
-
data = file_paths
|
176
|
+
def ascp_add_pvcl(data)
|
178
177
|
# read PATHs from ascp directly, and pvcl modules as well
|
179
178
|
Open3.popen3(data['ascp'], '-DDL-') do |_stdin, _stdout, stderr, thread|
|
180
179
|
last_line = ''
|
@@ -202,15 +201,24 @@ module Aspera
|
|
202
201
|
raise last_line
|
203
202
|
end
|
204
203
|
end
|
205
|
-
|
204
|
+
end
|
205
|
+
|
206
|
+
# extract some stings from ascp binary
|
207
|
+
def ascp_add_openssl(data)
|
206
208
|
ascp_file = data['ascp']
|
207
|
-
File.binread(ascp_file).scan(/[\x20-\x7E]{
|
208
|
-
if (m =
|
209
|
+
File.binread(ascp_file).scan(/[\x20-\x7E]{10,}/) do |bin_string|
|
210
|
+
if (m = bin_string.match(/OPENSSLDIR.*"(.*)"/))
|
209
211
|
data['openssldir'] = m[1]
|
212
|
+
elsif (m = bin_string.match(/OpenSSL (\d[^ -]+)/))
|
213
|
+
data['openssl_version'] = m[1]
|
210
214
|
end
|
211
215
|
end if File.file?(ascp_file)
|
212
|
-
|
213
|
-
|
216
|
+
end
|
217
|
+
|
218
|
+
def ascp_info
|
219
|
+
data = file_paths
|
220
|
+
ascp_add_pvcl(data)
|
221
|
+
ascp_add_openssl(data)
|
214
222
|
return data
|
215
223
|
end
|
216
224
|
|
@@ -209,7 +209,7 @@ module Aspera
|
|
209
209
|
h[new_name] = value
|
210
210
|
end
|
211
211
|
end
|
212
|
-
end
|
212
|
+
end
|
213
213
|
|
214
214
|
def initialize
|
215
215
|
# current event being parsed line by line
|
@@ -236,7 +236,7 @@ module Aspera
|
|
236
236
|
return @last_event
|
237
237
|
else
|
238
238
|
raise "mgt port: unexpected line: [#{line}]"
|
239
|
-
end
|
239
|
+
end
|
240
240
|
return nil
|
241
241
|
end
|
242
242
|
end
|
@@ -8,10 +8,7 @@ module Aspera
|
|
8
8
|
# base class for applications supporting basic authentication
|
9
9
|
class BasicAuthPlugin < Cli::Plugin
|
10
10
|
class << self
|
11
|
-
|
12
|
-
def declare_options(options) # , force: false
|
13
|
-
#return if @@basic_options_declared && !force
|
14
|
-
#@@basic_options_declared = true # rubocop:disable Style/ClassVars
|
11
|
+
def declare_options(options)
|
15
12
|
options.declare(:url, 'URL of application, e.g. https://faspex.example.com/aspera/faspex')
|
16
13
|
options.declare(:username, "User's name to log in")
|
17
14
|
options.declare(:password, "User's password")
|
@@ -21,14 +18,13 @@ module Aspera
|
|
21
18
|
|
22
19
|
def initialize(basic_options: true, **env)
|
23
20
|
super(**env)
|
24
|
-
# , force: env[:all_manuals]
|
25
21
|
BasicAuthPlugin.declare_options(options) if basic_options
|
26
22
|
end
|
27
23
|
|
28
24
|
# returns a Rest object with basic auth
|
29
25
|
def basic_auth_params(subpath=nil)
|
30
26
|
api_url = options.get_option(:url, mandatory: true)
|
31
|
-
api_url = api_url
|
27
|
+
api_url = "#{api_url}/#{subpath}" unless subpath.nil?
|
32
28
|
return {
|
33
29
|
base_url: api_url,
|
34
30
|
auth: {
|
@@ -41,6 +37,6 @@ module Aspera
|
|
41
37
|
def basic_auth_api(subpath=nil)
|
42
38
|
return Rest.new(**basic_auth_params(subpath))
|
43
39
|
end
|
44
|
-
end
|
45
|
-
end
|
46
|
-
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -22,6 +22,10 @@ module Aspera
|
|
22
22
|
ALL = 'ALL'
|
23
23
|
DEF = 'DEF'
|
24
24
|
|
25
|
+
MARKER_START = '@'
|
26
|
+
MARKER_END = ':'
|
27
|
+
MARKER_IN_END = '@'
|
28
|
+
|
25
29
|
class << self
|
26
30
|
# decode comma separated table text
|
27
31
|
def decode_csvt(value)
|
@@ -61,10 +65,11 @@ module Aspera
|
|
61
65
|
list: lambda{|v|v[1..-1].split(v[0])},
|
62
66
|
none: lambda{|v|ExtendedValue.assert_no_value(v, :none); nil}, # rubocop:disable Style/Semicolon
|
63
67
|
path: lambda{|v|File.expand_path(v)},
|
64
|
-
re: lambda{|v|Regexp.new(v)},
|
68
|
+
re: lambda{|v|Regexp.new(v, Regexp::MULTILINE)},
|
65
69
|
ruby: lambda{|v|Environment.secure_eval(v, __FILE__, __LINE__)},
|
66
70
|
secret: lambda{|v|prompt = v.empty? ? 'secret' : v; $stdin.getpass("#{prompt}> ")}, # rubocop:disable Style/Semicolon
|
67
71
|
stdin: lambda{|v|ExtendedValue.assert_no_value(v, :stdin); $stdin.read}, # rubocop:disable Style/Semicolon
|
72
|
+
stdbin: lambda{|v|ExtendedValue.assert_no_value(v, :stdin); $stdin.binmode.read}, # rubocop:disable Style/Semicolon
|
68
73
|
yaml: lambda{|v|YAML.load(v)},
|
69
74
|
zlib: lambda{|v|Zlib::Inflate.inflate(v)},
|
70
75
|
extend: lambda{|v|ExtendedValue.instance.evaluate_all(v)}
|
@@ -84,14 +89,14 @@ module Aspera
|
|
84
89
|
|
85
90
|
# Regex to match an extended value
|
86
91
|
def ext_re
|
87
|
-
"
|
92
|
+
"#{MARKER_START}(#{modifiers.join('|')})#{MARKER_END}"
|
88
93
|
end
|
89
94
|
|
90
95
|
# parse an option value if it is a String using supported extended value modifiers
|
91
96
|
# other value types are returned as is
|
92
97
|
def evaluate(value)
|
93
98
|
return value unless value.is_a?(String)
|
94
|
-
regex = Regexp.new("^#{ext_re}(.*)$")
|
99
|
+
regex = Regexp.new("^#{ext_re}(.*)$", Regexp::MULTILINE)
|
95
100
|
# first determine decoders, in reversed order
|
96
101
|
handlers_reversed = []
|
97
102
|
while (m = value.match(regex))
|
@@ -101,18 +106,19 @@ module Aspera
|
|
101
106
|
# stop processing if handler is extend (it will be processed later)
|
102
107
|
break if handler.eql?(:extend)
|
103
108
|
end
|
109
|
+
Log.log.trace1{"evaluating: #{handlers_reversed}, value: #{value}"}
|
104
110
|
handlers_reversed.each do |handler|
|
105
111
|
value = @handlers[handler].call(value)
|
106
112
|
end
|
107
113
|
return value
|
108
|
-
end
|
114
|
+
end
|
109
115
|
|
110
116
|
# find inner extended values
|
111
117
|
def evaluate_all(value)
|
112
|
-
regex = Regexp.new("^(.*)#{ext_re}([
|
118
|
+
regex = Regexp.new("^(.*)#{ext_re}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)$", Regexp::MULTILINE)
|
113
119
|
while (m = value.match(regex))
|
114
120
|
sub_value = "@#{m[2]}:#{m[3]}"
|
115
|
-
Log.log.debug
|
121
|
+
Log.log.debug{"evaluating #{sub_value}"}
|
116
122
|
value = m[1] + evaluate(sub_value) + m[4]
|
117
123
|
end
|
118
124
|
return value
|