aspera-cli 4.27.3 → 4.27.5
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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +118 -0
- data/CONTRIBUTING.md +5 -2
- data/bin/ascli +1 -0
- data/docs/README.md +224 -42
- data/lib/aspera/agent/base.rb +7 -2
- data/lib/aspera/agent/connect.rb +0 -4
- data/lib/aspera/agent/desktop.rb +0 -4
- data/lib/aspera/agent/direct.rb +49 -21
- data/lib/aspera/agent/node.rb +6 -5
- data/lib/aspera/agent/transferd.rb +2 -2
- data/lib/aspera/api/faspex.rb +2 -0
- data/lib/aspera/api/httpgw.rb +1 -2
- data/lib/aspera/api/node.rb +3 -3
- data/lib/aspera/ascp/installation.rb +1 -1
- data/lib/aspera/cli/async_transfer_store.rb +10 -9
- data/lib/aspera/cli/bootstrapper.rb +3 -1
- data/lib/aspera/cli/command_registry.rb +69 -7
- data/lib/aspera/cli/command_spec.rb +1 -1
- data/lib/aspera/cli/extended_value.rb +4 -3
- data/lib/aspera/cli/formatter.rb +10 -8
- data/lib/aspera/cli/http.rb +8 -20
- data/lib/aspera/cli/option_types.rb +3 -1
- data/lib/aspera/cli/option_value.rb +16 -19
- data/lib/aspera/cli/options.schema.yaml +86 -10
- data/lib/aspera/cli/parser.rb +30 -18
- data/lib/aspera/cli/plugins/aoc.rb +133 -156
- data/lib/aspera/cli/plugins/ats.rb +1 -7
- data/lib/aspera/cli/plugins/base.rb +40 -34
- data/lib/aspera/cli/plugins/config.rb +24 -11
- data/lib/aspera/cli/plugins/console.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +30 -11
- data/lib/aspera/cli/plugins/faspio.rb +1 -1
- data/lib/aspera/cli/plugins/node.rb +26 -19
- data/lib/aspera/cli/plugins/orchestrator.rb +73 -44
- data/lib/aspera/cli/plugins/preview.rb +21 -19
- data/lib/aspera/cli/plugins/server.rb +3 -4
- data/lib/aspera/cli/plugins/shares.rb +21 -24
- data/lib/aspera/cli/preset_actions.rb +28 -18
- data/lib/aspera/cli/preset_manager.rb +34 -19
- data/lib/aspera/cli/prompt.rb +2 -1
- data/lib/aspera/cli/result.rb +33 -22
- data/lib/aspera/cli/runner.rb +1 -5
- data/lib/aspera/cli/sync_actions.rb +17 -16
- data/lib/aspera/cli/transfer_actions.rb +14 -3
- data/lib/aspera/cli/transfer_agent.rb +6 -4
- data/lib/aspera/cli/transfer_progress.rb +290 -55
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +1 -1
- data/lib/aspera/coverage.rb +0 -1
- data/lib/aspera/environment.rb +29 -5
- data/lib/aspera/keychain/encrypted_hash.rb +1 -1
- data/lib/aspera/keychain/factory.rb +2 -1
- data/lib/aspera/log.rb +25 -2
- data/lib/aspera/node_emulator.rb +759 -0
- data/lib/aspera/oauth/base.rb +2 -1
- data/lib/aspera/oauth/factory.rb +6 -3
- data/lib/aspera/oauth/json_credentials.rb +34 -0
- data/lib/aspera/oauth/jwt.rb +3 -4
- data/lib/aspera/oauth.rb +1 -0
- data/lib/aspera/persistency_folder.rb +1 -3
- data/lib/aspera/preview/generator.rb +4 -1
- data/lib/aspera/preview/options.schema.yaml +119 -0
- data/lib/aspera/rest/aspera_errors.rb +12 -0
- data/lib/aspera/rest/client.rb +28 -19
- data/lib/aspera/rest/list.rb +14 -8
- data/lib/aspera/rest/parameters.rb +2 -2
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
- data/lib/aspera/schema/reader.rb +12 -18
- data/lib/aspera/schema/registry.rb +6 -1
- data.tar.gz.sig +0 -0
- metadata +5 -3
- metadata.gz.sig +0 -0
- data/lib/aspera/node_simulator.rb +0 -345
- data/lib/aspera/preview/options.rb +0 -45
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# cspell:ignore precalc noxfer euid gettime
|
|
4
|
+
require 'aspera/assert'
|
|
5
|
+
require 'aspera/mime'
|
|
6
|
+
require 'aspera/ascp/installation'
|
|
7
|
+
require 'aspera/agent/transferd'
|
|
8
|
+
require 'aspera/transfer/spec'
|
|
9
|
+
require 'aspera/transfer/error'
|
|
10
|
+
require 'aspera/secret_hider'
|
|
11
|
+
require 'aspera/string_ext'
|
|
12
|
+
require 'aspera/log'
|
|
13
|
+
require 'webrick'
|
|
14
|
+
require 'openssl'
|
|
15
|
+
require 'etc'
|
|
16
|
+
require 'securerandom'
|
|
17
|
+
require 'json'
|
|
18
|
+
require 'time'
|
|
19
|
+
|
|
20
|
+
module Aspera
|
|
21
|
+
# Node API transfers executed by transferd.
|
|
22
|
+
# Only transfers started through the emulator are known (kept in memory).
|
|
23
|
+
class NodeEmulator
|
|
24
|
+
# transferd status → Node API transfer status
|
|
25
|
+
STATUS = {
|
|
26
|
+
UNKNOWN_STATUS: 'waiting',
|
|
27
|
+
QUEUED: 'waiting',
|
|
28
|
+
RUNNING: 'running',
|
|
29
|
+
COMPLETED: 'completed',
|
|
30
|
+
FAILED: 'failed',
|
|
31
|
+
ORPHANED: 'failed',
|
|
32
|
+
CANCELED: 'canceled',
|
|
33
|
+
PAUSED: 'paused'
|
|
34
|
+
}.freeze
|
|
35
|
+
# transferd statuses after which no event is received
|
|
36
|
+
TERMINAL = %i[COMPLETED FAILED CANCELED ORPHANED].freeze
|
|
37
|
+
# Node API statuses of active transfers and sessions
|
|
38
|
+
ACTIVE = %w[waiting running].freeze
|
|
39
|
+
# transferd session status (lowercase) → Node API, others are only lowercased
|
|
40
|
+
SESSION_STATUS = {'draft' => 'waiting'}.freeze
|
|
41
|
+
# transferd file status (lowercase) → Node API, others are only lowercased
|
|
42
|
+
FILE_STATUS = {'transferring' => 'running', 'finished' => 'completed'}.freeze
|
|
43
|
+
# transferd field → Node API field, when not the snake case of it (`nil`: ignored)
|
|
44
|
+
TRANSFER_OVERRIDES = {'averageRateKbps' => 'avg_rate_kbps', 'errorDescription' => 'error_desc'}.freeze
|
|
45
|
+
SESSION_OVERRIDES = {'id' => nil, 'sessionId' => 'id'}.freeze
|
|
46
|
+
FILE_OVERRIDES = {'fileId' => 'id', 'errorDescription' => 'error_desc', 'fileType' => 'type', 'fileChecksumType' => 'checksum_type'}.freeze
|
|
47
|
+
# Node API fields taken as is from transferd (after renaming)
|
|
48
|
+
TRANSFER_FIELDS = %w[bytes_transferred bytes_written bytes_lost avg_rate_kbps files_completed directories_completed elapsed_usec].freeze
|
|
49
|
+
SESSION_FIELDS = %w[
|
|
50
|
+
id client_node_id server_node_id client_ip_address server_ip_address start_time_usec end_time_usec elapsed_usec
|
|
51
|
+
bytes_transferred bytes_written bytes_lost files_completed directories_completed
|
|
52
|
+
target_rate_kbps min_rate_kbps calc_rate_kbps network_delay_usec error_code error_desc
|
|
53
|
+
].freeze
|
|
54
|
+
FILE_FIELDS = %w[id path start_time_usec elapsed_usec error_code error_desc size type checksum_type checksum start_byte bytes_written session_id].freeze
|
|
55
|
+
# Node API session `source_statistics` field → transferd session field
|
|
56
|
+
SOURCE_STATISTICS = {
|
|
57
|
+
'args_scan_attempted' => 'arg_scans_attempted',
|
|
58
|
+
'args_scan_completed' => 'arg_scans_completed',
|
|
59
|
+
'paths_scan_attempted' => 'source_paths_scan_attempted',
|
|
60
|
+
'paths_scan_failed' => 'source_paths_scan_failed',
|
|
61
|
+
'paths_scan_excluded' => 'source_paths_scan_excluded',
|
|
62
|
+
'files_scan_completed' => 'source_paths_scan_completed',
|
|
63
|
+
'dirs_scan_completed' => 'dir_scans_completed',
|
|
64
|
+
'dirs_xfer_attempted' => 'dir_creates_attempted',
|
|
65
|
+
'dirs_xfer_fail' => 'dir_creates_failed',
|
|
66
|
+
'files_xfer_attempted' => 'transfers_attempted',
|
|
67
|
+
'files_xfer_fail' => 'transfers_failed',
|
|
68
|
+
'files_xfer_noxfer' => 'transfers_skipped'
|
|
69
|
+
}.freeze
|
|
70
|
+
# Node API `precalc` field → transferd session field
|
|
71
|
+
PRECALC = {
|
|
72
|
+
'bytes_expected' => 'pre_transfer_bytes',
|
|
73
|
+
'files_expected' => 'pre_transfer_files',
|
|
74
|
+
'directories_expected' => 'pre_transfer_dirs',
|
|
75
|
+
'files_special' => 'pre_transfer_special'
|
|
76
|
+
}.freeze
|
|
77
|
+
# transferd fills fields `*TimeUsec` (except `elapsedUsec`) with milliseconds: values below are not microseconds
|
|
78
|
+
MAX_MSEC = 10**14
|
|
79
|
+
# Max wait for the first event of a new transfer
|
|
80
|
+
START_TIMEOUT_SEC = 10
|
|
81
|
+
# `File.ftype` → Node API item type, others are unchanged
|
|
82
|
+
FILE_TYPES = {'link' => 'symbolic_link'}.freeze
|
|
83
|
+
# Node API node information not provided by transferd
|
|
84
|
+
# The emulator has no access key, AEJ, watch folder, sync, file id API (gen4), `files/page` or file operations
|
|
85
|
+
INFO_STATIC = {
|
|
86
|
+
'aej_status' => 'disconnected',
|
|
87
|
+
'async_reporting' => 'no',
|
|
88
|
+
'transfer_activity_reporting' => 'no',
|
|
89
|
+
'acls' => [],
|
|
90
|
+
'access_key_configuration_capabilities' => {'transfer' => [], 'server' => []},
|
|
91
|
+
'capabilities' => [
|
|
92
|
+
{'name' => 'sync', 'value' => false},
|
|
93
|
+
{'name' => 'watchfolder', 'value' => false},
|
|
94
|
+
{'name' => 'symbolic_links', 'value' => false},
|
|
95
|
+
{'name' => 'move_file', 'value' => false},
|
|
96
|
+
{'name' => 'move_directory', 'value' => false},
|
|
97
|
+
{'name' => 'filelock', 'value' => false},
|
|
98
|
+
{'name' => 'ssh_fingerprint', 'value' => false},
|
|
99
|
+
{'name' => 'page', 'value' => false},
|
|
100
|
+
{'name' => 'auto_delete', 'value' => false}
|
|
101
|
+
],
|
|
102
|
+
'settings' => [
|
|
103
|
+
{'name' => 'content_protection_required', 'value' => false},
|
|
104
|
+
{'name' => 'content_protection_strong_pass_required', 'value' => false},
|
|
105
|
+
{'name' => 'filelock_restriction', 'value' => 'none'},
|
|
106
|
+
{'name' => 'ssh_fingerprint', 'value' => nil},
|
|
107
|
+
{'name' => 'wss_enabled', 'value' => false},
|
|
108
|
+
{'name' => 'wss_port', 'value' => 443}
|
|
109
|
+
]
|
|
110
|
+
}.freeze
|
|
111
|
+
private_constant :STATUS, :TERMINAL, :ACTIVE, :SESSION_STATUS, :FILE_STATUS,
|
|
112
|
+
:TRANSFER_OVERRIDES, :SESSION_OVERRIDES, :FILE_OVERRIDES, :TRANSFER_FIELDS, :SESSION_FIELDS, :FILE_FIELDS,
|
|
113
|
+
:SOURCE_STATISTICS, :PRECALC, :MAX_MSEC, :START_TIMEOUT_SEC, :FILE_TYPES, :INFO_STATIC
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# Convert a transferd transfer status to a Node API status
|
|
117
|
+
# @param status [Symbol] transferd `TransferStatus`
|
|
118
|
+
# @return [String] Node API transfer status
|
|
119
|
+
def node_status(status)
|
|
120
|
+
STATUS.fetch(status, 'waiting')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Convert a transferd message to a hash with Node API field names
|
|
124
|
+
# @param message [Google::Protobuf::AbstractMessage] transferd message
|
|
125
|
+
# @param overrides [Hash] field name → Node API name (`nil`: ignored)
|
|
126
|
+
# @return [Hash] all fields (including default values), keys in snake case, time stamps in microseconds
|
|
127
|
+
def message_to_hash(message, overrides)
|
|
128
|
+
message.class.descriptor.each_with_object({}) do |field, hash|
|
|
129
|
+
key = overrides.fetch(field.name) { field.name.capital_to_snake }
|
|
130
|
+
next if key.nil?
|
|
131
|
+
value = message[field.name]
|
|
132
|
+
value *= 1000 if key.end_with?('_time_usec') && value.positive? && value < MAX_MSEC
|
|
133
|
+
hash[key] = value
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Update a store entry with a transferd event
|
|
138
|
+
# @param entry [Hash] store entry
|
|
139
|
+
# @param response [Transferd::Api::TransferResponse] transferd event
|
|
140
|
+
def update_entry(entry, response)
|
|
141
|
+
entry[:status] = response.status
|
|
142
|
+
entry[:info] = response.transferInfo unless response.transferInfo.nil?
|
|
143
|
+
session = response.sessionInfo
|
|
144
|
+
entry[:sessions][session.sessionId] = session unless session.nil? || session.sessionId.empty?
|
|
145
|
+
# event ARG_STOP has a file without id
|
|
146
|
+
file = response.fileInfo
|
|
147
|
+
entry[:files][file.fileId] = file unless file.nil? || file.fileId.empty?
|
|
148
|
+
entry[:error] = response.error.description unless response.error.nil? || response.error.description.empty?
|
|
149
|
+
# session information keeps the initial rates, the ascp management message has the current ones
|
|
150
|
+
entry[:rates][session.sessionId] = management_rates(response.message) if response.transferEvent.eql?(:RATE_MODIFICATION) && entry[:sessions].key?(session&.sessionId)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Build a Node API transfer from a store entry
|
|
154
|
+
# @param id [String] transfer id
|
|
155
|
+
# @param entry [Hash] store entry
|
|
156
|
+
# @param node_id [String] node id of the emulator
|
|
157
|
+
# @return [Hash] Node API transfer (`transferResponseSessionSpec`)
|
|
158
|
+
def transfer_to_node(id, entry, node_id: '')
|
|
159
|
+
info = message_to_hash(entry[:info] || ::Transferd::Api::TransferInfo.new, TRANSFER_OVERRIDES)
|
|
160
|
+
terminal = TERMINAL.include?(entry[:status])
|
|
161
|
+
retry_timeout = xfer_retry(entry[:start_spec])
|
|
162
|
+
sessions = entry[:sessions].map { |session_id, session| session_to_node(session, retry_timeout: retry_timeout, node_id: node_id).merge(entry[:rates].fetch(session_id, {})) }
|
|
163
|
+
error_desc = info['error_desc'].strip
|
|
164
|
+
error_desc = entry[:error].to_s if error_desc.empty?
|
|
165
|
+
precalc = PRECALC.keys.to_h { |field| [field, sessions.sum { |session| session['precalc'][field] }] }
|
|
166
|
+
info.slice(*TRANSFER_FIELDS).merge(
|
|
167
|
+
'id' => id,
|
|
168
|
+
'status' => node_status(entry[:status]),
|
|
169
|
+
'start_spec' => entry[:start_spec],
|
|
170
|
+
'sessions' => sessions,
|
|
171
|
+
# transferd does not provide the start time of the transfer
|
|
172
|
+
'start_time_usec' => sessions.map { |session| session['start_time_usec'] }.select(&:positive?).min || 0,
|
|
173
|
+
'end_time_usec' => terminal ? info['end_time_usec'] : 0,
|
|
174
|
+
'error_code' => info['error_code'].to_i,
|
|
175
|
+
'error_desc' => error_desc,
|
|
176
|
+
'precalc' => precalc.merge(
|
|
177
|
+
'enabled' => sessions.any? { |session| session['precalc']['enabled'] },
|
|
178
|
+
'status' => precalc_status(precalc['bytes_expected'], terminal)
|
|
179
|
+
),
|
|
180
|
+
'files' => entry[:files].values.map { |file| file_to_node(file) }
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Convert a transferd session to a Node API session
|
|
185
|
+
# @param session [Transferd::Api::SessionTransferInformation]
|
|
186
|
+
# @param retry_timeout [Integer] from the transfer spec
|
|
187
|
+
# @param node_id [String] node id of the emulator
|
|
188
|
+
# @return [Hash] Node API session (`transferResponseSessions`)
|
|
189
|
+
def session_to_node(session, retry_timeout: 0, node_id: '')
|
|
190
|
+
source = message_to_hash(session, SESSION_OVERRIDES)
|
|
191
|
+
status = session.status.downcase
|
|
192
|
+
status = SESSION_STATUS.fetch(status, status)
|
|
193
|
+
result = source.slice(*SESSION_FIELDS)
|
|
194
|
+
# transferd provides the address of the remote side only: the server
|
|
195
|
+
result['server_ip_address'] = source['remote_address'] if result['server_ip_address'].empty?
|
|
196
|
+
# transferd provides the node id of the remote side only: the local side is the emulator
|
|
197
|
+
result['client_node_id'] = node_id if result['client_node_id'].empty?
|
|
198
|
+
precalc = PRECALC.transform_values { |field| source[field] }
|
|
199
|
+
result.merge(
|
|
200
|
+
'status' => status,
|
|
201
|
+
'retry_count' => 0,
|
|
202
|
+
'retry_timeout' => retry_timeout,
|
|
203
|
+
'stalled' => false,
|
|
204
|
+
'avg_rate_kbps' => source['elapsed_usec'].positive? ? (source['bytes_transferred'] * 8000.0 / source['elapsed_usec']).round(2) : 0,
|
|
205
|
+
'source_statistics' => SOURCE_STATISTICS.transform_values { |field| source[field] },
|
|
206
|
+
'precalc' => precalc.merge(
|
|
207
|
+
'enabled' => session.precalc.casecmp?('yes'),
|
|
208
|
+
'status' => precalc_status(precalc['bytes_expected'], !ACTIVE.include?(status))
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Convert a transferd file to a Node API file
|
|
214
|
+
# @param file [Transferd::Api::FileTransferInformation]
|
|
215
|
+
# @return [Hash] Node API file (`fileMetadata`)
|
|
216
|
+
def file_to_node(file)
|
|
217
|
+
result = message_to_hash(file, FILE_OVERRIDES).slice(*FILE_FIELDS)
|
|
218
|
+
status = file.status.downcase
|
|
219
|
+
result['status'] = FILE_STATUS.fetch(status, status)
|
|
220
|
+
terminal = !result['status'].eql?('running') && result['start_time_usec'].positive?
|
|
221
|
+
result['end_time_usec'] = terminal ? result['start_time_usec'] + result['elapsed_usec'] : 0
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Build Node API node information from transferd instance information
|
|
226
|
+
# @param info [Transferd::Api::InstanceInfo]
|
|
227
|
+
# @param node_id [String] node id of the emulator
|
|
228
|
+
# @param cluster_id [String] cluster id of the emulator
|
|
229
|
+
# @param docroot [String] real path of the docroot
|
|
230
|
+
# @param transfer_user [String] system user executing transfers
|
|
231
|
+
# @return [Hash] Node API node information (`info-get-200`)
|
|
232
|
+
def info_to_node(info, node_id:, cluster_id:, docroot:, transfer_user:)
|
|
233
|
+
ascp = info.asperaInfo.find { |binary| binary.asperaBinary.eql?('ascp') } || ::Transferd::Api::AsperaInfo.new
|
|
234
|
+
license = info.licenseInfo || ::Transferd::Api::LicenseInfo.new
|
|
235
|
+
{
|
|
236
|
+
'application' => 'node',
|
|
237
|
+
# without build id
|
|
238
|
+
'version' => ascp.asperaVersion.sub(/ .*$/, ''),
|
|
239
|
+
'current_time' => Time.now.utc.iso8601(0),
|
|
240
|
+
'license_expiration_date' => license.license[%r{<expiration_date>([^<]*)</expiration_date>}, 1].to_s,
|
|
241
|
+
'license_max_rate' => license.maxRate,
|
|
242
|
+
'os' => ascp.operatingSystem,
|
|
243
|
+
'node_id' => node_id,
|
|
244
|
+
'cluster_id' => cluster_id,
|
|
245
|
+
'transfer_user' => transfer_user,
|
|
246
|
+
'docroot' => "file:///#{docroot}"
|
|
247
|
+
}.merge(INFO_STATIC)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
# Extract the current rates from an ascp management message
|
|
253
|
+
# @param message [String] ascp management message of event `RATE_MODIFICATION` (JSON)
|
|
254
|
+
# @return [Hash] Node API session rates found in the message
|
|
255
|
+
def management_rates(message)
|
|
256
|
+
management = JSON.parse(message)
|
|
257
|
+
{'target_rate_kbps' => management['Rate'], 'min_rate_kbps' => management['MinRate']}.compact.transform_values(&:to_i)
|
|
258
|
+
rescue JSON::ParserError
|
|
259
|
+
{}
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Compute the status of the pre-calculation of the transfer size
|
|
263
|
+
# @param bytes_expected [Integer] size of the transfer, 0 if not known yet
|
|
264
|
+
# @param terminal [Boolean] `true` if the transfer or session has ended
|
|
265
|
+
# @return [String] `ready` when the size is known, else `pending`
|
|
266
|
+
def precalc_status(bytes_expected, terminal)
|
|
267
|
+
bytes_expected.positive? || terminal ? 'ready' : 'pending'
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Get the retry timeout from the tags of the transfer spec
|
|
271
|
+
# @param start_spec [Hash] transfer spec
|
|
272
|
+
# @return [Integer] retry timeout set by `Agent::Node`, or 0
|
|
273
|
+
def xfer_retry(start_spec)
|
|
274
|
+
reserved = start_spec['tags'][Transfer::Spec::TAG_RESERVED] if start_spec['tags'].is_a?(Hash)
|
|
275
|
+
reserved.is_a?(Hash) ? reserved['xfer_retry'].to_i : 0
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Create an emulator, start a transferd daemon if no client is given
|
|
280
|
+
# @param docroot [String, nil] folder of the node files, default: current folder
|
|
281
|
+
# @param transfer_client [Transferd::Api::TransferService::Stub, nil] gRPC client, default: start a transferd daemon
|
|
282
|
+
# @param retention_sec [Integer] time a transfer is kept after it ended (CLI default in schema)
|
|
283
|
+
def initialize(retention_sec:, docroot: nil, transfer_client: nil)
|
|
284
|
+
Aspera.assert(retention_sec.is_a?(Integer) && retention_sec.positive?) { "retention_sec must be a positive integer: #{retention_sec.inspect}" }
|
|
285
|
+
# paths of browse and local paths of transfers are relative to it
|
|
286
|
+
@docroot = File.realpath(docroot || Dir.pwd)
|
|
287
|
+
# the daemon is stopped at exit by the agent
|
|
288
|
+
@transfer_client = transfer_client || Agent::Transferd.new.transfer_client
|
|
289
|
+
@retention_sec = retention_sec
|
|
290
|
+
# transfer id → store entry: `start_spec` (secrets hidden), last `status`, last `info`, `sessions` by id, `files` by id, current `rates` by session id, `error`,
|
|
291
|
+
# `ended` (monotonic time of the end), accessed with `store`
|
|
292
|
+
@transfers = {}
|
|
293
|
+
# WEBrick serves each request in a thread, monitoring runs in threads
|
|
294
|
+
@mutex = Mutex.new
|
|
295
|
+
# identifiers of the simulated node, in node information and in sessions
|
|
296
|
+
@node_id = SecureRandom.uuid
|
|
297
|
+
@cluster_id = SecureRandom.uuid
|
|
298
|
+
# transferd runs as the user of the emulator
|
|
299
|
+
@transfer_user = Etc.getpwuid(Process.euid)&.name || Etc.getlogin.to_s
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Get the node information
|
|
303
|
+
# @return [Hash] Node API node information, from transferd
|
|
304
|
+
def info
|
|
305
|
+
response = @transfer_client.get_info(::Transferd::Api::InstanceInfoRequest.new)
|
|
306
|
+
Log.dump(:get_info_response, response.to_h, level: :trace2)
|
|
307
|
+
Aspera.assert(response.error.nil? || response.error.description.empty?, type: RuntimeError) { response.error.description }
|
|
308
|
+
self.class.info_to_node(response.info || ::Transferd::Api::InstanceInfo.new, node_id: @node_id, cluster_id: @cluster_id, docroot: @docroot, transfer_user: @transfer_user)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# List a folder of the docroot
|
|
312
|
+
# @param path [String] folder to list, relative to the docroot
|
|
313
|
+
# @param skip [Integer] number of items to skip (paging)
|
|
314
|
+
# @param count [Integer] max number of items returned (paging)
|
|
315
|
+
# @return [Hash] Node API folder content, paths relative to the docroot
|
|
316
|
+
def browse(path, skip: 0, count: nil)
|
|
317
|
+
folder_path = real_path(path)
|
|
318
|
+
# symbolic links shall not lead out of the docroot
|
|
319
|
+
Aspera.assert(in_docroot?(File.realpath(folder_path))) { "Path outside docroot: #{path}" }
|
|
320
|
+
Aspera.assert(File.directory?(folder_path)) { "Not a folder: #{path}" }
|
|
321
|
+
folder_virtual = virtual_path(folder_path)
|
|
322
|
+
|
|
323
|
+
# Build self structure
|
|
324
|
+
folder_stat = File.stat(folder_path)
|
|
325
|
+
structure = {
|
|
326
|
+
'self' => {
|
|
327
|
+
'path' => folder_virtual,
|
|
328
|
+
'basename' => File.basename(folder_path),
|
|
329
|
+
'type' => 'directory',
|
|
330
|
+
# Node API reports 0 for folders
|
|
331
|
+
'size' => 0,
|
|
332
|
+
'mtime' => folder_stat.mtime.utc.iso8601,
|
|
333
|
+
'permissions' => permissions(folder_path)
|
|
334
|
+
},
|
|
335
|
+
'items' => []
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
# Iterate over folder contents
|
|
339
|
+
Dir.foreach(folder_path) do |entry|
|
|
340
|
+
next if entry == '.' || entry == '..' # Skip current and parent directory
|
|
341
|
+
|
|
342
|
+
item_path = File.join(folder_path, entry)
|
|
343
|
+
item_type = File.ftype(item_path) rescue 'unknown' # Get the type of file
|
|
344
|
+
item_type = FILE_TYPES.fetch(item_type, item_type)
|
|
345
|
+
item_stat = File.lstat(item_path) # Use lstat to handle symbolic links correctly
|
|
346
|
+
|
|
347
|
+
item = {
|
|
348
|
+
'path' => File.join(folder_virtual, entry),
|
|
349
|
+
'basename' => entry,
|
|
350
|
+
'type' => item_type,
|
|
351
|
+
'size' => item_type.eql?('directory') ? 0 : item_stat.size,
|
|
352
|
+
'mtime' => item_stat.mtime.utc.iso8601,
|
|
353
|
+
'permissions' => permissions(item_path)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Add additional details for specific types
|
|
357
|
+
case item_type
|
|
358
|
+
when 'file'
|
|
359
|
+
item['partial_file'] = false
|
|
360
|
+
when 'symbolic_link'
|
|
361
|
+
item['target'] = link_target(item_path)
|
|
362
|
+
when 'unknown'
|
|
363
|
+
item['note'] = 'File type could not be determined'
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
structure['items'] << item
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# stable order for paging
|
|
370
|
+
all_items = structure['items'].sort_by { |item| item['basename'] }
|
|
371
|
+
structure['items'] = all_items.drop(skip).first(count || all_items.length)
|
|
372
|
+
structure['item_count'] = structure['items'].length
|
|
373
|
+
structure['total_count'] = all_items.length
|
|
374
|
+
structure
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Start a transfer, and monitor it in a thread until terminated
|
|
378
|
+
# @param transfer_spec [Hash] transfer spec, local paths relative to the docroot (modified)
|
|
379
|
+
# @return [String] transfer id
|
|
380
|
+
def start(transfer_spec)
|
|
381
|
+
Transfer::Spec.fix_transferd_resume_policy(transfer_spec)
|
|
382
|
+
# deep copy, returned in transfer `start_spec`
|
|
383
|
+
start_spec = SecretHider.instance.deep_remove_secret(JSON.parse(transfer_spec.to_json))
|
|
384
|
+
Log.dump(:start_spec, start_spec)
|
|
385
|
+
request = ::Transferd::Api::TransferRequest.new(
|
|
386
|
+
transferType: ::Transferd::Api::TransferType::FILE_REGULAR,
|
|
387
|
+
config: ::Transferd::Api::TransferConfig.new,
|
|
388
|
+
transferSpec: apply_docroot(transfer_spec).to_json
|
|
389
|
+
)
|
|
390
|
+
first_event = Thread::Queue.new
|
|
391
|
+
Thread.new { monitor(request, start_spec, first_event) }
|
|
392
|
+
result = first_event.pop(timeout: START_TIMEOUT_SEC)
|
|
393
|
+
raise Transfer::Error, "No event from transferd after #{START_TIMEOUT_SEC}s" if result.nil?
|
|
394
|
+
raise result if result.is_a?(Exception)
|
|
395
|
+
return result
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Get a transfer started by the emulator
|
|
399
|
+
# @param id [String] transfer id
|
|
400
|
+
# @return [Hash, nil] Node API transfer, `nil` if unknown
|
|
401
|
+
def transfer(id)
|
|
402
|
+
entry = store do |transfers|
|
|
403
|
+
found = transfers[id]
|
|
404
|
+
found&.merge(sessions: found[:sessions].dup, files: found[:files].dup, rates: found[:rates].dup)
|
|
405
|
+
end
|
|
406
|
+
return if entry.nil?
|
|
407
|
+
result = self.class.transfer_to_node(id, entry, node_id: @node_id)
|
|
408
|
+
# transferd gives real paths of local files
|
|
409
|
+
result['files'].each { |file| file['path'] = virtual_path(file['path']) if in_docroot?(file['path']) }
|
|
410
|
+
result
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# List the transfers started by the emulator
|
|
414
|
+
# @param active_only [Boolean, nil] `true`: only waiting or running, `false`: only terminated, `nil`: all
|
|
415
|
+
# @param direction [String, nil] `send` or `receive`
|
|
416
|
+
# @param count [Integer, nil] max number of transfers, oldest first
|
|
417
|
+
# @return [Array<Hash>] Node API transfers
|
|
418
|
+
def transfers(active_only: nil, direction: nil, count: nil)
|
|
419
|
+
# a transfer may expire between the two calls
|
|
420
|
+
result = store(&:keys).filter_map { |id| transfer(id) }
|
|
421
|
+
result.select! { |transfer| ACTIVE.include?(transfer['status']).eql?(active_only) } unless active_only.nil?
|
|
422
|
+
result.select! { |transfer| transfer['start_spec']['direction'].eql?(direction) } unless direction.nil?
|
|
423
|
+
count.nil? ? result : result.first(count)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Stop a transfer: its status becomes `canceled` when transferd notifies it
|
|
427
|
+
# @param id [String] transfer id
|
|
428
|
+
# @return [Boolean] `false` if unknown
|
|
429
|
+
def cancel(id)
|
|
430
|
+
return false unless known?(id)
|
|
431
|
+
response = @transfer_client.stop_transfer(::Transferd::Api::StopTransferRequest.new(transferId: [id]))
|
|
432
|
+
Log.dump(:stop_response, response.to_h, level: :trace2)
|
|
433
|
+
result = response.stopResult.find { |info| info.transferId.eql?(id) }
|
|
434
|
+
Aspera.assert(result&.stopped, type: Transfer::Error) { result&.error&.description || 'Transfer not stopped by transferd' }
|
|
435
|
+
true
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Modify the rates or the policy of a transfer
|
|
439
|
+
# @param id [String] transfer id
|
|
440
|
+
# @param changes [Hash] new values of transfer spec fields: `target_rate_kbps`, `min_rate_kbps`, `rate_policy`
|
|
441
|
+
# @return [Boolean] `false` if unknown
|
|
442
|
+
def modify(id, changes)
|
|
443
|
+
return false unless known?(id)
|
|
444
|
+
response = @transfer_client.modify_transfer(::Transferd::Api::TransferModificationRequest.new(transferId: id, transferSpec: changes.to_json))
|
|
445
|
+
Log.dump(:modify_response, response.to_h, level: :trace2)
|
|
446
|
+
Aspera.assert(response.error.nil? || response.error.description.empty?, type: Transfer::Error) { response.error.description }
|
|
447
|
+
true
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
private
|
|
451
|
+
|
|
452
|
+
# Check if a transfer is known
|
|
453
|
+
# @param id [String] transfer id
|
|
454
|
+
# @return [Boolean] `true` if the transfer was started by the emulator
|
|
455
|
+
def known?(id)
|
|
456
|
+
store { |transfers| transfers.key?(id) }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Lock the store, and remove the transfers ended for longer than the retention time
|
|
460
|
+
# @yieldparam transfers [Hash] transfer id → store entry
|
|
461
|
+
# @return [Object] result of the block
|
|
462
|
+
def store
|
|
463
|
+
@mutex.synchronize do
|
|
464
|
+
now = monotonic_time
|
|
465
|
+
@transfers.delete_if { |_, entry| entry.key?(:ended) && now - entry[:ended] >= @retention_sec }
|
|
466
|
+
yield(@transfers)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# @return [Float] current time in seconds, for durations
|
|
471
|
+
def monotonic_time
|
|
472
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Convert a Node API path to a real path
|
|
476
|
+
# @param path [String] path relative to the docroot, leading `/` optional
|
|
477
|
+
# @return [String] real path, within the docroot
|
|
478
|
+
def real_path(path)
|
|
479
|
+
# `./` prevents expansion of `~`
|
|
480
|
+
real = File.expand_path("./#{path.to_s.sub(%r{\A/+}, '')}", @docroot)
|
|
481
|
+
Aspera.assert(in_docroot?(real)) { "Path outside docroot: #{path}" }
|
|
482
|
+
real
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Convert a real path to a Node API path
|
|
486
|
+
# @param real [String] real path within the docroot
|
|
487
|
+
# @return [String] path relative to the docroot, with leading `/`
|
|
488
|
+
def virtual_path(real)
|
|
489
|
+
"/#{real.delete_prefix(@docroot).delete_prefix('/')}"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Check if a real path is within the docroot
|
|
493
|
+
# @param real [String] real path
|
|
494
|
+
# @return [Boolean] `true` if the path is the docroot or inside it
|
|
495
|
+
def in_docroot?(real)
|
|
496
|
+
real.eql?(@docroot) || real.start_with?(@docroot.end_with?('/') ? @docroot : "#{@docroot}/")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Get the Node API permissions of an item
|
|
500
|
+
# @param real [String] real path within the docroot
|
|
501
|
+
# @return [Array<Hash>] Node API permissions, from the access of the emulator on the item
|
|
502
|
+
def permissions(real)
|
|
503
|
+
names = []
|
|
504
|
+
names.push('view') if File.readable?(real)
|
|
505
|
+
names.push('edit') if File.writable?(real)
|
|
506
|
+
# the docroot itself cannot be deleted
|
|
507
|
+
names.push('delete') if !real.eql?(@docroot) && File.writable?(File.dirname(real))
|
|
508
|
+
names.map { |name| {'name' => name} }
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Get the Node API target of a symbolic link
|
|
512
|
+
# @param link [String] real path of a symbolic link within the docroot
|
|
513
|
+
# @return [Hash] Node API target of the link: only the content of the link if it is dangling or leads out of the docroot
|
|
514
|
+
def link_target(link)
|
|
515
|
+
target = File.realpath(link) rescue nil
|
|
516
|
+
unless target && in_docroot?(target)
|
|
517
|
+
content = File.readlink(link)
|
|
518
|
+
return {'path' => content, 'basename' => File.basename(content)}
|
|
519
|
+
end
|
|
520
|
+
stat = File.stat(target)
|
|
521
|
+
{
|
|
522
|
+
'path' => virtual_path(target),
|
|
523
|
+
'basename' => File.basename(target),
|
|
524
|
+
'type' => File.ftype(target),
|
|
525
|
+
'size' => stat.directory? ? 0 : stat.size
|
|
526
|
+
}
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Local paths of the transfer spec (sources for `send`, destination for `receive`) are relative to the docroot
|
|
530
|
+
# @param transfer_spec [Hash] modified: local paths are replaced with real paths
|
|
531
|
+
# @return [Hash] the transfer spec
|
|
532
|
+
def apply_docroot(transfer_spec)
|
|
533
|
+
receive = transfer_spec['direction'].eql?(Transfer::Spec::DIRECTION_RECEIVE)
|
|
534
|
+
root_field, path_field = receive ? %w[destination_root destination] : %w[source_root source]
|
|
535
|
+
# a download without destination goes to the docroot
|
|
536
|
+
root = transfer_spec[root_field] || ('/' if receive)
|
|
537
|
+
if root.nil?
|
|
538
|
+
transfer_spec['paths']&.each { |item| item[path_field] = real_path(item[path_field]) if item.key?(path_field) }
|
|
539
|
+
else
|
|
540
|
+
transfer_spec[root_field] = real_path(root)
|
|
541
|
+
# paths are relative to the root: they shall not lead out of the docroot
|
|
542
|
+
transfer_spec['paths']&.each { |item| real_path(File.join(root, item[path_field])) if item.key?(path_field) }
|
|
543
|
+
end
|
|
544
|
+
transfer_spec
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Start the transfer and update the store with its events, until terminated.
|
|
548
|
+
# Runs in a thread.
|
|
549
|
+
# @param request [Transferd::Api::TransferRequest]
|
|
550
|
+
# @param start_spec [Hash] transfer spec without secrets
|
|
551
|
+
# @param first_event [Thread::Queue] receives the transfer id, or the exception if the transfer could not start
|
|
552
|
+
def monitor(request, start_spec, first_event)
|
|
553
|
+
id = nil
|
|
554
|
+
@transfer_client.start_transfer_with_monitor(request) do |response|
|
|
555
|
+
Log.dump(:transferd_event, response.to_h, level: :trace2)
|
|
556
|
+
new_transfer = id.nil?
|
|
557
|
+
if new_transfer
|
|
558
|
+
Aspera.assert(!response.transferId.empty?, type: Transfer::Error) { response.error&.description || 'No transfer id from transferd' }
|
|
559
|
+
id = response.transferId
|
|
560
|
+
end
|
|
561
|
+
store do |transfers|
|
|
562
|
+
entry = (transfers[id] ||= {start_spec: start_spec, sessions: {}, files: {}, rates: {}})
|
|
563
|
+
self.class.update_entry(entry, response)
|
|
564
|
+
# no more event, and so no more retry: retention starts
|
|
565
|
+
entry[:ended] = monotonic_time if TERMINAL.include?(entry[:status])
|
|
566
|
+
end
|
|
567
|
+
first_event.push(id) if new_transfer
|
|
568
|
+
break if TERMINAL.include?(response.status)
|
|
569
|
+
end
|
|
570
|
+
first_event.push(Transfer::Error.new('transferd closed the stream without event')) if id.nil?
|
|
571
|
+
rescue StandardError => e
|
|
572
|
+
if id.nil?
|
|
573
|
+
first_event.push(e)
|
|
574
|
+
else
|
|
575
|
+
Log.log.error { "Transfer #{id}: #{e.message}" }
|
|
576
|
+
store { |transfers| transfers[id].merge!(status: :FAILED, error: e.message, ended: monotonic_time) }
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Answers a subset of the Node API, transfers are delegated to a NodeEmulator
|
|
582
|
+
# a new instance is created for each request
|
|
583
|
+
class NodeEmulatorServlet < WEBrick::HTTPServlet::AbstractServlet
|
|
584
|
+
PATH_TRANSFERS = '/ops/transfers'
|
|
585
|
+
PATH_ONE_TRANSFER = %r{/ops/transfers/(.+)$}
|
|
586
|
+
PATH_BROWSE = '/files/browse'
|
|
587
|
+
REALM = 'Aspera Node Emulator'
|
|
588
|
+
# `PUT` values of `status` that cancel the transfer (pause and resume are not supported)
|
|
589
|
+
CANCEL_STATUSES = %w[canceled cancelled stopped].freeze
|
|
590
|
+
# `PUT` fields modified by transferd
|
|
591
|
+
MODIFIABLE = %w[target_rate_kbps min_rate_kbps rate_policy].freeze
|
|
592
|
+
# Create the servlet, with optional Basic authentication
|
|
593
|
+
# @param server [WEBrick::HTTPServer]
|
|
594
|
+
# @param config [Hash] `username` and `password` (Basic authentication expected from clients, optional)
|
|
595
|
+
# @param simulator [NodeEmulator]
|
|
596
|
+
def initialize(server, config, simulator)
|
|
597
|
+
super(server)
|
|
598
|
+
@simulator = simulator
|
|
599
|
+
@expected_auth = "#{config[:username]}:#{config[:password]}" unless config[:username].nil?
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Check authentication, dispatch to `do_<verb>`, and send errors in Node API format
|
|
603
|
+
# @param request [WEBrick::HTTPRequest]
|
|
604
|
+
# @param response [WEBrick::HTTPResponse]
|
|
605
|
+
def service(request, response)
|
|
606
|
+
unless authorized?(request)
|
|
607
|
+
response['WWW-Authenticate'] = %Q(Basic realm="#{REALM}")
|
|
608
|
+
return set_error(request, response, 401, 'Invalid or missing credentials')
|
|
609
|
+
end
|
|
610
|
+
super
|
|
611
|
+
rescue WEBrick::HTTPStatus::Error => e
|
|
612
|
+
set_error(request, response, e.code, e.message)
|
|
613
|
+
rescue JSON::ParserError, AssertError, Transfer::Error => e
|
|
614
|
+
set_error(request, response, 400, e.message)
|
|
615
|
+
rescue Errno::ENOENT => e
|
|
616
|
+
set_error(request, response, 404, e.message)
|
|
617
|
+
rescue StandardError => e
|
|
618
|
+
Log.log.error { "#{request.request_method} #{request.path}: #{e.class}: #{e.message}" }
|
|
619
|
+
set_error(request, response, 500, e.message)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Start a transfer, or list a folder
|
|
623
|
+
# @param request [WEBrick::HTTPRequest]
|
|
624
|
+
# @param response [WEBrick::HTTPResponse]
|
|
625
|
+
def do_POST(request, response)
|
|
626
|
+
case request.path
|
|
627
|
+
when PATH_TRANSFERS
|
|
628
|
+
set_json_response(request, response, @simulator.transfer(@simulator.start(JSON.parse(request.body))))
|
|
629
|
+
when PATH_BROWSE
|
|
630
|
+
req = JSON.parse(request.body)
|
|
631
|
+
set_json_response(request, response, @simulator.browse(req['path'] || '/', skip: req['skip'].to_i, count: req['count']&.to_i))
|
|
632
|
+
else
|
|
633
|
+
set_error(request, response, 404, "Unknown path: #{request.path}")
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Get the node information, the list of transfers, or one transfer
|
|
638
|
+
# @param request [WEBrick::HTTPRequest]
|
|
639
|
+
# @param response [WEBrick::HTTPResponse]
|
|
640
|
+
def do_GET(request, response)
|
|
641
|
+
case request.path
|
|
642
|
+
when '/info'
|
|
643
|
+
set_json_response(request, response, @simulator.info)
|
|
644
|
+
when PATH_TRANSFERS
|
|
645
|
+
set_json_response(request, response, @simulator.transfers(
|
|
646
|
+
active_only: query_boolean(request, 'active_only'),
|
|
647
|
+
direction: request.query['direction']&.to_s,
|
|
648
|
+
count: query_positive(request, 'count')
|
|
649
|
+
))
|
|
650
|
+
when PATH_ONE_TRANSFER
|
|
651
|
+
transfer = @simulator.transfer(request.path.match(PATH_ONE_TRANSFER)[1])
|
|
652
|
+
if transfer.nil?
|
|
653
|
+
set_error(request, response, 404, 'Unknown transfer')
|
|
654
|
+
else
|
|
655
|
+
set_json_response(request, response, transfer)
|
|
656
|
+
end
|
|
657
|
+
else
|
|
658
|
+
set_error(request, response, 404, "Unknown path: #{request.path}")
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# Modify a transfer, or cancel it with `status`
|
|
663
|
+
# @param request [WEBrick::HTTPRequest]
|
|
664
|
+
# @param response [WEBrick::HTTPResponse]
|
|
665
|
+
def do_PUT(request, response)
|
|
666
|
+
id = transfer_id(request)
|
|
667
|
+
changes = JSON.parse(request.body.to_s)
|
|
668
|
+
raise WEBrick::HTTPStatus::BadRequest, 'Body must be a JSON object' unless changes.is_a?(Hash)
|
|
669
|
+
status = changes.delete('status')
|
|
670
|
+
if status.nil?
|
|
671
|
+
unsupported = changes.keys - MODIFIABLE
|
|
672
|
+
raise WEBrick::HTTPStatus::BadRequest, "Cannot modify: #{unsupported.join(', ')}" unless unsupported.empty?
|
|
673
|
+
raise WEBrick::HTTPStatus::BadRequest, 'Nothing to modify' if changes.empty?
|
|
674
|
+
found = @simulator.modify(id, changes)
|
|
675
|
+
else
|
|
676
|
+
raise WEBrick::HTTPStatus::BadRequest, "Unsupported status: #{status}" unless CANCEL_STATUSES.include?(status)
|
|
677
|
+
found = @simulator.cancel(id)
|
|
678
|
+
end
|
|
679
|
+
return set_error(request, response, 404, 'Unknown transfer') unless found
|
|
680
|
+
set_json_response(request, response, @simulator.transfer(id))
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# Node API cancels a transfer with HTTP verb `CANCEL`
|
|
684
|
+
# @param request [WEBrick::HTTPRequest]
|
|
685
|
+
# @param response [WEBrick::HTTPResponse]
|
|
686
|
+
def do_CANCEL(request, response)
|
|
687
|
+
return set_error(request, response, 404, 'Unknown transfer') unless @simulator.cancel(transfer_id(request))
|
|
688
|
+
response.status = 204
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
private
|
|
692
|
+
|
|
693
|
+
# Extract the transfer id from the path of the request
|
|
694
|
+
# @param request [WEBrick::HTTPRequest]
|
|
695
|
+
# @return [String] transfer id
|
|
696
|
+
# @raise [WEBrick::HTTPStatus::NotFound] if the path is not the one of a transfer
|
|
697
|
+
def transfer_id(request)
|
|
698
|
+
match = request.path.match(PATH_ONE_TRANSFER)
|
|
699
|
+
raise WEBrick::HTTPStatus::NotFound, "Unknown path: #{request.path}" if match.nil?
|
|
700
|
+
match[1]
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Read a boolean query parameter
|
|
704
|
+
# @param request [WEBrick::HTTPRequest]
|
|
705
|
+
# @param name [String] name of the query parameter
|
|
706
|
+
# @return [Boolean, nil] value, `nil` if absent
|
|
707
|
+
# @raise [WEBrick::HTTPStatus::BadRequest] if the value is not `true` or `false`
|
|
708
|
+
def query_boolean(request, name)
|
|
709
|
+
value = request.query[name]&.to_s
|
|
710
|
+
return if value.nil?
|
|
711
|
+
raise WEBrick::HTTPStatus::BadRequest, "Query #{name} must be true or false: #{value}" unless %w[true false].include?(value)
|
|
712
|
+
value.eql?('true')
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Read a positive integer query parameter
|
|
716
|
+
# @param request [WEBrick::HTTPRequest]
|
|
717
|
+
# @param name [String] name of the query parameter
|
|
718
|
+
# @return [Integer, nil] value, `nil` if absent
|
|
719
|
+
# @raise [WEBrick::HTTPStatus::BadRequest] if the value is not a positive integer
|
|
720
|
+
def query_positive(request, name)
|
|
721
|
+
value = request.query[name]&.to_s
|
|
722
|
+
return if value.nil?
|
|
723
|
+
number = Integer(value, exception: false)
|
|
724
|
+
raise WEBrick::HTTPStatus::BadRequest, "Query #{name} must be a positive integer: #{value}" unless number&.positive?
|
|
725
|
+
number
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Set error body in Node API format
|
|
729
|
+
# @param request [WEBrick::HTTPRequest]
|
|
730
|
+
# @param response [WEBrick::HTTPResponse]
|
|
731
|
+
# @param code [Integer] HTTP status code
|
|
732
|
+
# @param message [String] message for the user
|
|
733
|
+
def set_error(request, response, code, message)
|
|
734
|
+
set_json_response(request, response, {error: {code: code, reason: WEBrick::HTTPStatus.reason_phrase(code), user_message: message}}, code: code)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# Check the Basic credentials of the request
|
|
738
|
+
# @param request [WEBrick::HTTPRequest]
|
|
739
|
+
# @return [Boolean] `true` if the request has the expected Basic credentials, or if none are expected
|
|
740
|
+
def authorized?(request)
|
|
741
|
+
return true if @expected_auth.nil?
|
|
742
|
+
scheme, value = request['Authorization'].to_s.split(' ', 2)
|
|
743
|
+
# constant time comparison
|
|
744
|
+
scheme.to_s.casecmp?('Basic') && OpenSSL.secure_compare(value.to_s.unpack1('m'), @expected_auth)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Set the status and the JSON body of the response
|
|
748
|
+
# @param request [WEBrick::HTTPRequest] for the log
|
|
749
|
+
# @param response [WEBrick::HTTPResponse]
|
|
750
|
+
# @param json [Hash, Array] body of the response
|
|
751
|
+
# @param code [Integer] HTTP status code
|
|
752
|
+
def set_json_response(request, response, json, code: 200)
|
|
753
|
+
response.status = code
|
|
754
|
+
response['Content-Type'] = Mime::JSON
|
|
755
|
+
response.body = json.to_json
|
|
756
|
+
Log.log.trace1 { Log.obj_dump("response for #{request.request_method} #{request.path}", json) }
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
end
|